Spaces:
Paused
Paused
|
Download docs/ai-evolution-design.md from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 37.2 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/docs/ai-evolution-design.md
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/docs/ai-evolution-design.md
-
curl -L -o ai-evolution-design.md https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/docs/ai-evolution-design.md
37.2 kB
| # Evolving the AI β a genome, mutation, and crossover | |
| *2026-08-06 β a brainstorm, not a commitment. No code changes accompany this document.* | |
| The question: **how could the AI be parameterised so that it can evolve β by random mutation, or | |
| by crossing over two AIs β and get better at the game?** | |
| The short answer is that most of the machinery already exists and nobody has called it evolution. | |
| `tools/ailab.js`'s `applyOverrides` is a genome loader. `tools/candidates/*.json` are genomes. | |
| `runDuel`/`runSwissTournament` plus `elo.js` are a fitness function that pits individuals against | |
| each other. `runSearch` is a search loop β just the weakest one in the family (a one-dial-at-a-time | |
| coordinate scan). What is genuinely missing is three things: a **schema** that says what a legal | |
| gene is, a set of **variation operators** (mutate, cross), and a **selection loop** that runs | |
| generations instead of a single scan. | |
| This document works through each, and then through the traps this particular codebase will spring. | |
| --- | |
| ## 1. The premise: a candidate AI is already data | |
| `tools/ailab.js:508`: | |
| ```js | |
| export function applyOverrides(ov = {}) { | |
| for (const [name, row] of Object.entries(ov.strategies || {})) β¦ | |
| for (const [name, row] of Object.entries(ov.archetypes || {})) β¦ | |
| for (const [key, row] of Object.entries(ov.difficulties || {})) β¦ | |
| } | |
| ``` | |
| Three plain exported tables β `STRATEGIES`, `ARCHETYPES`, `DIFFICULTY_OPTIONS` β every field of | |
| which is read at its use site through `|| 1` or a falsy-flag default. That property is what makes | |
| this whole idea cheap: **an arbitrary mutation is always a runnable AI.** There is no parser to | |
| write, no patch to apply, no engine change, and no way for a malformed gene to crash a match β a | |
| nonsense value produces a bad player, not an exception. Evolutionary algorithms need exactly that | |
| (a *tolerant* genotypeβphenotype map); most codebases have to be retrofitted for it and this one | |
| already is, by accident of the `|| 1` convention. | |
| `snapshotTables`/`restoreTables` (`tools/ailab.js:525`) already give clean isolation between | |
| individuals, which is the other half of what a population loop needs. | |
| --- | |
| ## 2. The genome β three chromosomes, only two of which should evolve | |
| | Layer | File | Evolve? | Why | | |
| |---|---|---|---| | |
| | **Strategy** | `engine/aiStrategy.js` | **Yes β the main chromosome** | Composes over any archetype; `galaxy.js:61` `neighbourAiProfile` draws strategies *uniformly at random*, so a new strategy row immediately populates ~1/N of the galaxy | | |
| | **Archetype** | `engine/aiArchetypes.js` | **Yes, carefully** | Carries the world's identity (faction, unit mix, doctrine). Evolving it risks flattening the roster into one optimum β see Β§6E | | |
| | **Difficulty** | `engine/aiDifficulty.js` | **No β freeze it** | It is the *fairness pin*. `pinnedDuelDials` (`tools/duelCore.js:34`) reads one difficulty row into one object both sides spread from. Let a genome touch it and fitness measures an APM edge, not strategy | | |
| ### 2.1 The gene inventory as it stands today | |
| Everything the AI actually reads, with its type β this is the search space, and it is already | |
| about 30-dimensional without adding a single new dial. | |
| **Strategy chromosome** (`engine/aiStrategy.js`): | |
| | Gene | Type | Read at | | |
| |---|---|---| | |
| | `attackTimeoutMult` | continuous ratio | `aiMilitary.js` `aiOffense` | | |
| | `armyAttackSizeMult` | continuous ratio | `aiMilitary.js` | | |
| | `garrisonMult` | continuous ratio | `aiMilitary.js` | | |
| | `workerTargetMult` | continuous ratio | `aiEconomy.js:170` | | |
| | `turretCountMult` | continuous ratio | `aiEconomy.js` | | |
| | `standingArmyCap` | integer count | `aiEconomy.js` | | |
| | `warFootingMult` | continuous ratio | `aiEconomy.js` | | |
| | `warFootingTime` | seconds | `ai.js:146`, `aiEconomy.js` | | |
| | `matchBuffer` | continuous ratio | `aiEconomy.js` | | |
| | `matchFloor` | integer count | `aiEconomy.js` | | |
| | `graceMult` / `grievanceMult` / `forgiveness` | continuous ratio | `engine/diplomacy.js` | | |
| | `matchEnemyForce` | **flag** | `aiEconomy.js` | | |
| | `neverInitiates` | **flag** | `aiMilitary.js` (Γ3) | | |
| | `useBombOffensively` | **flag** | `aiSuperweapon.js` | | |
| | `wantsIndustryAlways` | **flag** | `aiIndustry.js` (Γ2) | | |
| **Archetype chromosome** (`engine/aiArchetypes.js`), plus its `odyssey:` overlay which shadows the | |
| same names when `state.diplomacy` exists (`ai.js:133`, the `arch` reader): | |
| | Gene | Type | | |
| |---|---| | |
| | `workerTarget`, `armyAttackSize`, `turretCount`, `maxBarracks`, `garrison`, `odyssey.probeMin` | integer counts | | |
| | `attackTimeout` | seconds | | |
| | `expandWhenNodesBelow` | continuous, bounded 0β1 | | |
| | `wantsRefinery` | flag | | |
| | `doctrine` | **categorical** (`assault` \| `bulwark`) | | |
| | `faction` | **categorical** (`syndicate` \| `miners` \| `frontier` \| β¦) | | |
| | `unitMix` | **sequence** β see Β§3.5, the interesting one | | |
| ### 2.2 Genes worth *adding* because evolution can find their values | |
| Evolution is only as good as its search space. Three cheap additions widen it a lot, and each is a | |
| `|| 1`-style read at one site, so each is a no-op when absent: | |
| - **`expandAggressionMult`** β today expansion is one threshold (`expandWhenNodesBelow`). A | |
| multiplier on *how many* expansions it will chase gives the GA a macro/greed axis it currently | |
| cannot express. | |
| - **`techRushBias`** β the AI's tech-gate order in `aiEconomy.js` `aiBaseAndTech` is hardcoded. | |
| A single scalar reordering Barracks-first vs Foundry-first opens the whole build-order axis, | |
| which is where real RTS strategy lives and where the genome is currently blind. | |
| - **`waveCommitFraction`** β commit with a *fraction* of the army rather than all-but-garrison. | |
| Turns a binary (attack / don't) into a continuous dial, and continuous dials are what mutation | |
| operators are good at. | |
| Without at least one build-order gene, evolution can only tune the AI's *tempo*, never its *plan*. | |
| --- | |
| ## 3. Gene types decide the operators | |
| The single most common way a GA fails on a mixed genome is one operator applied to every gene. | |
| These five kinds each want different treatment. | |
| ### 3.1 Continuous ratios β mutate multiplicatively, not additively | |
| `attackTimeoutMult`, `graceMult`, `workerTargetMult` β¦ are *ratios*. An additive step of Β±0.1 is a | |
| 50% change at 0.2 and a 7% change at 1.5 β so additive Gaussian mutation searches the low end far | |
| too coarsely and the high end far too finely. Mutate in log space: | |
| ```js | |
| gene *= Math.exp(sigma * gaussian()); // log-normal step; symmetric in ratio terms | |
| ``` | |
| with `sigma β 0.15` for a fine local search and `β 0.4` for exploration. **Self-adapt sigma** | |
| (carry it as a gene of its own, mutate it too): this is the single highest-value trick in | |
| evolution strategies, and it costs about four lines. | |
| ### 3.2 Integer counts β geometric step, clamped at zero | |
| `standingArmyCap`, `matchFloor`, `turretCount`, `maxBarracks`, `garrison`, `probeMin`. Round after | |
| a small geometric/Poisson step and clamp to `[0, β¦]`. Note **0 is a meaningful allele**, not a | |
| degenerate one β `rusher.turretCount: 0` and `rusher.garrison: 0` are deliberate. Never clamp to 1. | |
| ### 3.3 Flags β low bit-flip rate, because they are *epistatic switches* | |
| `neverInitiates` is not one gene among fifteen. Flipping it decides whether | |
| `attackTimeoutMult`/`armyAttackSizeMult` mean *anything at all* β under `neverInitiates: true` | |
| every offense gene is inert (`aiMilitary.js` blocks every voluntary commit). Likewise | |
| `matchEnemyForce` makes `standingArmyCap` dead, and `warFootingMult` is dead without | |
| `warFootingTime`. | |
| This is textbook **epistasis**, and it has two consequences: | |
| 1. Flip flags **rarely** (~2-5%). A flag flip is a jump to a different fitness landscape entirely; | |
| at a 20% rate the population never settles anywhere long enough to tune the dials. | |
| 2. The inert genes become **junk DNA** β and that is a feature, not a bug. A lineage that has been | |
| `neverInitiates` for ten generations still carries drifting offense genes; when a descendant | |
| flips the flag back it lands somewhere new rather than at the ancestral value. This is how | |
| evolution actually escapes local optima, and it comes free here because the `|| 1` convention | |
| means an inert gene is *carried*, not *erased*. | |
| ### 3.4 Categoricals β resample uniformly; cross by allele choice | |
| `doctrine`, `faction`. There is no metric on these β no sense in which `miners` is "between" | |
| `syndicate` and `frontier`. Mutation = pick a different one uniformly; crossover = take one | |
| parent's allele wholesale. Note `faction` interacts strongly with `unitMix` (both feed combat | |
| strength through `factions.js`), so these two should be treated as one **linkage group** β see Β§4. | |
| ### 3.5 `unitMix` β the sequence gene, and the most interesting one in the game | |
| ```js | |
| unitMix: ["skiff", "lancer", "lancer", "dreadnought", "colossus", "wraith"], // technologist | |
| ``` | |
| This is not a vector. It is a **cyclic sequence over an alphabet of unit types**, of variable | |
| length, where order matters (it is the production cycle β `ai.js`'s comment: "a repeating | |
| production cycle β¦ not a random weighting, so a profile's composition is exact and testable") and | |
| multiplicity matters (three `skiff`s in four slots is the Rusher's whole identity). | |
| Its operators are the ones from sequence/permutation GAs: | |
| | Operator | Effect | | |
| |---|---| | |
| | **Point substitution** | one slot β a different unit type. Changes composition ratio | | |
| | **Duplication** | copy a slot in place. The classic way biology adds material β lengthens the cycle and doubles one unit's share | | |
| | **Deletion** | drop a slot. Must guard length β₯ 1 | | |
| | **Insertion** | a new random type at a random index | | |
| | **Cyclic rotation** | *neutral in steady state, not in the opening* β rotating decides which unit gets built first, which is exactly the difference between a rush that works and one that dies | | |
| | **Order crossover (OX)** | take a contiguous run from parent A, fill the rest from B in B's order β preserves composition and adjacency from both | | |
| And the reason this is safe to mutate wildly: **`effectiveMix` already filters out units the world | |
| cannot pay for** (`aiWorkers.js`; the Balanced archetype's Wraith/Aegis entries are dropped on | |
| worlds without gas or ice). An illegal gene is silently ignored rather than fatal. That single | |
| existing behaviour makes `unitMix` the most evolvable structure in the codebase β a random | |
| `["colossus","colossus","colossus"]` on pyralis degrades to a no-op instead of a crash. | |
| --- | |
| ## 4. Crossover β cross at module boundaries, not per-gene | |
| Uniform crossover (each gene independently from A or B) is the default and is probably wrong here. | |
| The dials cluster into **functional modules that map one-to-one onto engine files**: | |
| | Module | Genes | Consumer | | |
| |---|---|---| | |
| | OFFENSE | `attackTimeoutMult`, `armyAttackSizeMult`, `garrisonMult`, `neverInitiates`, `useBombOffensively` | `aiMilitary.js`, `aiSuperweapon.js` | | |
| | ECONOMY | `workerTargetMult`, `standingArmyCap`, `warFooting*`, `wantsIndustryAlways` | `aiEconomy.js`, `aiIndustry.js` | | |
| | DIPLOMACY | `graceMult`, `grievanceMult`, `forgiveness` | `diplomacy.js` | | |
| | DEFENSE | `turretCountMult`, `matchEnemyForce`, `matchBuffer`, `matchFloor` | `aiEconomy.js` | | |
| | COMPOSITION | `unitMix`, `faction`, `doctrine` | `aiWorkers.js`, `factions.js` | | |
| Genes *within* a module interact strongly (a smaller muster only works with a thinner garrison); | |
| genes *across* modules interact weakly. That is precisely the condition under which crossover beats | |
| mutation alone: **cross at module boundaries and a working sub-solution survives intact.** Uniform | |
| crossover would shred an offense package that only works as a set. | |
| There is a documented, in-repo example of why this matters. `aiStrategy.js:53-64` records that the | |
| Aggressive strategy was *last* of four in self-play (25W-47L), that a coordinate scan over its own | |
| offense dials could not fix it, and that what fixed it was an **economy** gene (`workerTargetMult: | |
| 1.25`) borrowed from the Economic strategy β 25W-47L β 38W-34L. That is a hand-executed | |
| module-wise crossover between two strategies. It worked. It took a human a search-and-a-half to | |
| find. A GA doing module-wise crossover over a population containing both parents finds it in one | |
| generation, by construction. | |
| **Recommended recombination:** 60% module-wise crossover, 25% arithmetic blend (geometric mean in | |
| log space for the continuous genes β good for fine-tuning between two similar parents), 15% clone. | |
| Then mutate the offspring. | |
| --- | |
| ## 5. Fitness β three exist, and they reward different things | |
| | Fitness | Where | Cost | What it actually rewards | | |
| |---|---|---|---| | |
| | `score()` | `tools/ailab.js:475` | ~seconds/world | Being a good *neighbour* in a play-forever sandbox: develops, keeps growing, spends, applies pressure proportional to hostility | | |
| | `runLeaderboard` | `:559` | N Γ the above | The same thing, N times, ranked. **A proxy, not head-to-head** β its own header says so | | |
| | `runDuel` / `runSwissTournament` + `elo.js` | `:667`, `:935` | ~a match each | *Winning.* Real self-play, resolved by `engine/victory.js` | | |
| **Use Elo from Swiss-paired self-play as the fitness, and `score()` as a diagnostic only.** | |
| Reasons: | |
| - **Goodhart.** `score()`'s six components are a hand-written definition of good, and a GA is a | |
| machine for exploiting hand-written definitions. `thrift: clamp01(1 - bankedFinal / 8000)` | |
| rewards ending poor β a genome that dumps ore into worthless turrets scores well and plays | |
| badly. `pressure` counts waves β a genome that trickles one unit at a time farms it. Every one | |
| of these is a real attack surface. A win/loss cannot be farmed except by winning. | |
| - **Cost.** `runSwissTournament` pairs by closest standing over `swissRoundCount(n)` rounds | |
| (`pairing.js:223`) instead of the full C(n,2) round-robin β O(n log n) matches per generation | |
| for a population of n. A 24-individual generation is ~5 rounds Γ 12 matches = 60 matches, not | |
| 276. This is the difference between a generation costing minutes and costing an hour. | |
| - **Relative fitness is what a GA wants anyway.** Selection only needs a ranking. | |
| **But coevolution has its own failure mode, and it must be planned for.** A population evolving | |
| only against itself can cycle forever (rusher beats greedy-economy beats turtle beats rusher) with | |
| no absolute progress β the Red Queen. Two standard countermeasures, both cheap here: | |
| 1. **Hall of fame.** Seed every generation's tournament with the four shipped baselines β | |
| `tools/candidates/baseline-{adaptive,aggressive,economic,force-parity}.json` already exist and | |
| are exactly this. Elo against a fixed anchor is absolute, not relative. | |
| 2. **Archive the best-of-generation** and pair against a random sample of past champions, not just | |
| current peers. This is what stops the population from specialising into a rock-paper-scissors | |
| loop that beats itself and loses to the shipped AI. | |
| **And a hard filter on top of fitness:** any genome that trips a `CHECKS` entry | |
| (`tools/ailab.js:977` β `supply-deadlock`, `hoarding`, `dev-flatline`, `hostile-but-idle`, | |
| `production-stall`) is disqualified regardless of Elo. A genome that wins duels while deadlocking | |
| its own supply is a bug the GA found, not a strategy. | |
| --- | |
| ## 6. Five loops, cheapest first | |
| ### A. (1+Ξ») hill climb with Gaussian mutation β *~150 lines, the obvious first step* | |
| Take the current best, make Ξ» mutated copies, keep the winner. This is a strict upgrade on | |
| `runSearch`'s coordinate scan for one structural reason: **the scan moves one dial at a time and | |
| keeps improvements greedily, so it cannot cross a valley where two dials must move together.** | |
| Aggressive's documented fix β offense dials only pay off once the economy funds them β is exactly | |
| such a valley, and the scan is on record as having failed to find it. Multi-dial Gaussian mutation | |
| crosses valleys by default. No crossover, no population, no new concepts. | |
| ### B. (ΞΌ,Ξ»)-ES with self-adaptive step size β *the noise-robust choice* | |
| Keep the ΞΌ best of Ξ» offspring, each carrying its own mutation sigma. Handles the 10-15 continuous | |
| genes very well and is more robust to a noisy objective than any hill climb, because it averages | |
| over a population rather than trusting one comparison. Still no crossover. | |
| ### C. Genetic algorithm with module-wise crossover β *the direct answer to the question* | |
| Population of 20-30, Swiss tournament each generation, tournament selection (pick 3 at random, | |
| best of them breeds β cheap and doesn't need normalised fitness), module-wise crossover per Β§4, | |
| mutation per Β§3, 2 elites carried unchanged. This is the design that can find the | |
| Aggressive-borrows-Economic's-economy result by itself. | |
| ### D. Island model β *the fix for "improved on the mean, regressed on three worlds"* | |
| One sub-population per world group (or per archetype), evolving independently, with occasional | |
| migration of champions between islands. The ai-lab skill's own warning is that "a mean that | |
| improved because one world improved a lot and three got worse is a regression wearing a disguise"; | |
| islands attack that directly by never letting one world's optimum swamp the roster. It also | |
| parallelises perfectly, which matters when each fitness evaluation is a real simulated match. | |
| ### E. MAP-Elites β *the one that fits this game best, and the recommendation* | |
| Odyssey has no win condition. The game does not want *the* optimal AI; it wants a **cast of | |
| distinct, interesting opponents** β which is what `ARCHETYPES` is trying to be by hand, with four | |
| entries written by a human. | |
| MAP-Elites keeps an archive binned by *behavioural* descriptors and stores the best genome in each | |
| bin. Every descriptor it needs is already measured, in `sample()`/`summarise()` | |
| (`tools/ailab.js:318`): | |
| - mean army size (`army` / `armyValue`) β turtle ββ swarm | |
| - time to first wave (`waves` first non-zero) β rush ββ late | |
| - development at 40 min (`devFinal`) β brawler ββ teched | |
| - banked ore (`bankedFinal`) β spendthrift ββ hoarder | |
| - expansions (`buildings`) β tall ββ wide | |
| Output: a grid of the strongest AI *of each play style*, every cell a genome, every genome a | |
| runnable roster entry. That is directly shippable content β new archetypes with authentic | |
| identities, discovered rather than hand-tuned β and it dodges the "evolution converges on one | |
| degenerate cheese strategy" failure that would make the game worse even while the fitness number | |
| goes up. It also composes with C: use the GA as the variation operator, MAP-Elites as the archive. | |
| --- | |
| ## 7. Where it runs β three deployment options, increasingly ambitious | |
| **7.1 Offline, in `tools/evolve.js`.** Genomes are JSON, results go in the search ledger | |
| (`docs/odyssey-ai-review.md`), winners are promoted into `engine/` by hand under the existing | |
| discipline (`npm test` + `ailab check` + a full-roster multi-seed sweep). Safest, matches the | |
| established workflow exactly, and needs zero engine changes. | |
| **7.2 In-game: the roster breeds.** The Competition mode already has everything β a persistent | |
| roster (`competitionLedger.js` `addRosterEntry`), Elo brackets, Swiss/round-robin/knockout | |
| scheduling (`pairing.js`), a background worker (`competitionWorker.js`), seasons | |
| (`archiveSeason`). Add one thing: when a season is archived, **the top two entrants produce | |
| offspring** that join the next season's roster. Genomes are already JSON, so they serialise into | |
| the ledger with no new format. The player-facing pitch writes itself: *a ladder that gets harder | |
| because the AIs are breeding.* Note `competitionWorker.js:109` already plumbs a per-entrant | |
| `archetype`, so an entrant is already more than just a strategy name. | |
| **7.3 In-galaxy: the living galaxy evolves.** Each Odyssey neighbour world carries a genome. When | |
| you conquer a world and it is re-settled, the new neighbour inherits a crossover of the two | |
| strongest *surviving* neighbours β so wiping out the aggressive worlds leaves you a galaxy of | |
| economists, and the survivors' traits propagate. This is the most interesting design and the most | |
| dangerous: it must be seeded from `galaxy.seed` (never wall-clock, never `Math.random`), it changes | |
| the Odyssey meta permanently, and a difficulty ratchet with no ceiling is a balance problem, not a | |
| feature. Worth prototyping only after 7.1 has produced genomes anyone actually wants to face. | |
| --- | |
| ## 8. Traps this codebase will spring | |
| These are specific, and each one has cost someone time already or clearly will. | |
| **8.1 `duelSeed` hashes the candidates' names β so renaming a genome changes its maps.** | |
| `tools/duelCore.js:60`: | |
| ```js | |
| return hashStr(`${base}:duel:${world}:${difficulty}:${[aName, bName].sort().join("|")}:${rep}`); | |
| ``` | |
| Within one duel this is fine and deliberate (the sort is there so a side-swap draws the identical | |
| map). Across *generations* it is a real confound: `gen3-ind7` vs `gen2-ind4` is fought on entirely | |
| different maps than `gen2-ind4` vs `gen1-ind2` was, so "the child scored better than its parent | |
| did" is partly a statement about map luck. **Fixed** (Β§9): `duelSeed` takes an optional `seedKey` | |
| that replaces the names segment and defaults to today's exact derivation; `runDuel` forwards it, | |
| and the evolution loop passes one fixed key for the whole run. The cost of pinning is that a run | |
| can overfit the pinned map set β which is what held-out worlds are for (Β§8.6). | |
| **8.2 `assertNoOverrideCollision` will refuse a population of mutants.** | |
| `tools/ailab.js:720` throws if two candidates override the same table key β correctly, since they | |
| share one live table in a duel and the patches would merge. A GA whose individuals are all mutants | |
| of `"aggressive"` hits this on the very first pairing. **Give every individual a unique key** | |
| (`gen3-ind7`), and treat the existing check as the naming discipline it is. | |
| **8.3 Difficulty must be pinned, and every genome evaluated across brackets.** | |
| Layers compose *multiplicatively* (archetype Γ strategy Γ difficulty), so a mild-looking gene is a | |
| 3Γ swing on Hard + Aggressive + Rusher. `aiStrategy.js:60-64` records the exact failure: | |
| `workerTargetMult` at 1.4 wins Medium harder but *loses* Hard, because Hard's own row already | |
| carries 1.25 and 1.4 there is really 1.75Γ. **A genome scored on one difficulty is not scored.** | |
| Fitness must be the worst β or at minimum the mean β across brackets, never a single one. | |
| **8.4 `runDuel` didn't plumb per-side archetypes; `runDuelMatch` always has.** | |
| `tools/duelCore.js:84` accepts `aArchetype`/`bArchetype` and `competitionWorker.js:109` passes | |
| them, but `tools/ailab.js`'s `runDuel` never set them β so in a CLI duel **both sides played the | |
| world's own archetype**, and a candidate file carrying its own was silently ignored. **Fixed** | |
| (Β§9), which is what makes the macro/tempo/composition chromosome measurable at all. Note this was | |
| a pre-existing bug in the shipped bench, not something evolution introduced: any `duel` run | |
| comparing two archetypes before this was measuring something else. | |
| **8.5 Engine purity is non-negotiable.** No `Math.random`, no `Date.now()` under `engine/`, ever | |
| (`test/engine-purity.test.js`). All mutation RNG uses `mulberry32` from `engine/rng.js` with an | |
| explicit seed, and lives in `tools/`. If evolution ever moves in-game (Β§7.3) it must draw from a | |
| seeded stream derived from `galaxy.seed`, exactly as `neighbourAiProfile` already does. | |
| **8.6 Overfitting the roster.** `runSearch` "optimises a noisy mean and will happily overfit three | |
| worlds". A GA is far better at overfitting than a coordinate scan is. **Hold worlds out**: evolve | |
| on six, validate the champion on the other five, and report both numbers. A champion that wins on | |
| its training worlds and not on the held-out ones is a champion of the seed set. | |
| **8.7 Noise, and how much compute to spend proving a winner.** Duels are high-variance; 4-1 over | |
| five matches is not significance. Rather than raising the seed count for everyone, use **successive | |
| halving**: evaluate the whole generation cheaply (few seeds), keep the top half, re-evaluate with | |
| more seeds, repeat. Compute concentrates on the individuals that might actually win. | |
| **8.8 Strong is not the same as fun.** The fitness function is a definition of "better AI" and | |
| evolution will satisfy it literally. A GA optimising win rate will find cheese β the same all-in | |
| opening every game β and it will be *correct* to do so, and the game will be worse. `WEIGHTS` | |
| (`:466`) exists to be edited before optimising, not after; and Β§6E's MAP-Elites is the structural | |
| answer, because it selects for a *diverse cast* rather than a single maximum. | |
| **8.9 The blast radius of promoting a winner.** `neighbourAiProfile` (`galaxy.js:61`) draws | |
| strategies uniformly, so adding one evolved strategy row changes roughly 1/N of every Odyssey | |
| galaxy. Editing an *existing* row changes every save that uses it. Keeping evolved values in the | |
| `odyssey:` overlay only (read behind the `state.diplomacy` guard, `ai.js:133`) leaves the skirmish | |
| game byte-identical and the determinism tests green β the same containment `aiArchetypes.js` | |
| already uses for its labour retuning. | |
| --- | |
| ## 9. Phase 1 β shipped | |
| Everything in this section is implemented. Nothing under `engine/` changed: the shipped game, the | |
| skirmish path, and every determinism guarantee are untouched, which is the whole point of the | |
| genotype living in `tools/`. | |
| ### `tools/genome.js` β the schema and the operators | |
| `GENOME_SCHEMA` is the one source of truth for what a legal gene is β `{ key, layer, kind, module, | |
| min, max, odysseyOnly, gatedBy }` β the same role `DIFFICULTY_OPTIONS` plays for difficulty keys. | |
| 28 genes across two chromosomes and six linkage groups. Alongside it: `mutate`, `cross`, | |
| `randomGenome`, `genomeFrom` (seed from a shipped row), and `toOverrides`/`toCandidate`, which | |
| lower a genome back into exactly the `--overrides` / `{ name, strategy, overrides }` shapes the | |
| rest of the bench already consumes. Pure and seeded β every operator takes an explicit `mulberry32` | |
| stream, so a whole run reproduces from one integer. | |
| Two schema fields are load-bearing and were not in the original sketch: | |
| - **`odysseyOnly`** marks a gene whose use site is behind a `state.endless` / `state.diplomacy` | |
| guard. Every duel is a **skirmish** (`tools/selfplay.js`: "no Odyssey, no diplomacy, no galaxy"), | |
| so the entire DIPLOMACY module plus `wantsIndustryAlways` and `useBombOffensively` are read by | |
| *nothing* under a duel objective. They are excluded by default; evolving them against duel Elo | |
| would be optimising pure drift. This was nearly a silent waste of a whole search. | |
| - **`gatedBy`** handles the genes whose use site tests *presence* rather than value. | |
| `aiEconomy.js` reads `strategy.standingArmyCap != null`, so emitting a default would cap the army | |
| of every genome in the population. It is modelled as an explicit `capsArmy` switch plus a value | |
| that keeps drifting while the switch is off β Β§3.3's junk-DNA argument made concrete. | |
| ### The two plumbing fixes from Β§8 | |
| - **`duelSeed` takes an optional `seedKey`** (`tools/duelCore.js`) that replaces the sorted-names | |
| segment of the hash. Omitted, it is byte-for-byte the derivation it has always been β pinned by a | |
| test. The evolution loop passes one fixed key for the whole run, so every genome ever evaluated | |
| in round *R* faced the identical maps. | |
| - **`runDuel` now forwards each candidate's own `archetype`** to `runDuelMatch`, which has always | |
| accepted it and which `competitionWorker.js` has always passed. Before this, a CLI duel gave both | |
| seats whatever temperament the *world* hands out and silently ignored the candidate's own β so | |
| the archetype chromosome could not have been measured at all. | |
| ### `node tools/ailab.js evolve` | |
| Population, generations, elites, tournament selection, module-wise crossover, self-adaptive | |
| mutation. Fitness is Swiss-scheduled duel Elo with the four shipped baselines riding along as a | |
| hall of fame, every difficulty bracket rated separately and aggregated by the worst (`--agg min`). | |
| The champion is written as a runnable candidate file, so it goes straight back into | |
| `duel`/`sweep`/`leaderboard`. | |
| One correction the implementation forced, worth recording because the bug was invisible: | |
| `eloForMatches` folds every rating from a **fresh 1200** over one generation's own matches, so a | |
| raw Elo is only comparable *within* a generation β the field changes every generation and the mean | |
| is pinned at 1200 regardless of the field's absolute strength. Comparing generation 6's champion to | |
| generation 1's on raw Elo would have measured nothing, reintroducing the exact Red Queen failure | |
| the hall of fame exists to prevent, in the arithmetic rather than in the design. The loop therefore | |
| reports **`edge`** β rating above the hall of fame's own mean in the same tournament. Anchors are | |
| fixed genomes, so their mean is a fixed point of skill and an edge *is* comparable across | |
| generations; within a generation it is a constant offset, so it changes no ranking. | |
| `--screen` (CHECKS as a disqualifier) is **opt-in**, and that is a calibration argument rather than | |
| a cost one. Every detector in `CHECKS` is written for a 40β60 sim-minute *Odyssey* run and several | |
| carry an explicit "β¦and it never resolved in the last third" term keyed to that length; run over a | |
| duel-length skirmish they fire on healthy genomes, which is precisely the failure this repo's own | |
| CHECKS header records three rewrites of. So the screen runs at its own length, on its own Odyssey | |
| worlds, and only when asked. | |
| ### What the first run actually found | |
| 12 genomes Γ 6 generations, korrath/ferros/vesper, Medium+Hard, 3h21m. Full rows in the search | |
| ledger (`docs/odyssey-ai-review.md`); the three findings worth carrying forward: | |
| **1. It works, and it generalises.** The champion beats all four hand-tuned strategies on four | |
| worlds it never trained on, at a longer clock than it was selected under: 50W-14L (78%) at Medium, | |
| 40W-24L (63%) at Hard, over 320 matches. Medium is ~4.5Ο over even. So the loop is not producing | |
| noise, and it is not overfitting its training worlds. | |
| **2. And it should not be promoted, because it is Goodhart's law in a can.** Stripped of inert | |
| genes the champion is three dials β `neverInitiates`, `workerTargetMult` at the schema ceiling, and | |
| no army cap β which is to say: *max economy, uncapped army, never attack*. At Hard **every one of | |
| its 40 wins is a `timeout-score` win and none is an elimination**, while 16 of its 24 losses are | |
| eliminations. It does not play better; it exploits the fact that `engine/victory.js` weights combat | |
| units 1.35Γ and banked ore 0.25Γ, so an army that never fights and never takes attrition is the | |
| most valuable thing you can own at the clock. Β§8.8 predicted this in the abstract; it took one | |
| generation to arrive in practice. | |
| This is the single most useful thing the run produced, and it is an argument about the *objective*, | |
| not about the search. Duel-Elo was chosen over `score()` precisely because a win "cannot be farmed | |
| except by winning" β that reasoning was incomplete. A win at the timeout is farmable, because the | |
| timeout is scored by a heuristic, and any heuristic is a hand-written definition of good. Three | |
| plausible responses, none free: | |
| - **Score only eliminations** (a timeout is a draw). Cleanest, but it selects hard for all-in | |
| aggression and would probably breed the opposite degenerate. | |
| - **Fitness = win rate weighted toward elimination wins.** A dial, therefore another thing to | |
| tune, therefore another thing to Goodhart. | |
| - **Take the finding as being about the game, not the bench**: a tiebreak that pays 1.35Γ for an | |
| army you never commit rewards turtling in *real* matches too, not just evolved ones. That is | |
| worth a balance conversation independent of any of this. | |
| **3. Read the trajectory, not the numbers.** Elitism accidentally supplied a control: generation | |
| 3's champion is cloned unchanged into generation 4 and scored **+199 then +118** β 81 edge points | |
| apart on a byte-identical genome. The apparent per-generation climb is a flat line with Β±80 | |
| scatter. Only the gap to the anchors (+120 to +200, consistently) survives that, and the held-out | |
| round-robin is what actually confirmed it. **A single-seed generation cannot rank genomes**; the | |
| next run spends its compute on `--seeds` or on Β§8.7's successive halving, not on more generations. | |
| The honest summary: Phase 1 delivered a working variation-and-selection loop and a genuinely | |
| generalising champion that the game should not ship. Both halves of that are the result. | |
| --- | |
| ## 9b. Play against yourself β what the genome was for | |
| The search was never the point. The point was that a genome is a *thing a player can own*: edit one | |
| into existence, or have their own play captured into an AI and then face it. | |
| **`playerFingerprint.js`** is the second half of that. It measures a finished match from ground | |
| truth β deliberately NOT fog-limited, unlike `engine/aiIntel.js`, because this is analysis of a | |
| game that already happened rather than a decision an AI is making β and fits a genome to it. | |
| The fit is stated as **ratios against the archetype's baseline**, never absolutes, because that is | |
| how the engine composes its layers: a player who ran 18 workers is not `workerTargetMult: 18`, they | |
| are "1.5Γ what this temperament would have run", and only the ratio transfers to another world. | |
| Every value is clamped to its own gene's schema bounds, so an extreme game yields a legal AI rather | |
| than a broken one β the same guarantee mutation has, through the same schema. | |
| One gene needs no fitting at all: **`unitMix` is directly observable.** The player's own army, read | |
| back in descending count order, *is* a production cycle. That ordering matters rather than being | |
| cosmetic β it decides what gets built first, which is why the mix has a rotate operator. | |
| **Reachable now** from the game-over screen: "Save an AI that plays like you" writes the mirror as | |
| a candidate file that drops straight into `ailab duel`. | |
| **What it cannot see, stated honestly:** tempo. A fingerprint is taken from a *state*, so it reads | |
| standing composition and investment β army versus economy, workers kept, whether they walled, what | |
| they built β but not *when* they attacked or how often, because the sim keeps no player-side | |
| history. The fit therefore sets stance and investment from evidence and leaves timing at the | |
| archetype default rather than inventing it. Sampling the fingerprint across a whole match (exactly | |
| what `tools/ailab.js`'s `sample()` already does for the AI) is the obvious upgrade. | |
| **The blocker for putting a mirror on the competition roster** is worth recording because it is not | |
| obvious: `competitionLedger.js`'s `addRosterEntry` validates `strategy` against `KNOWN_STRATEGIES` | |
| and `archetype` against `KNOWN_ARCHETYPES`. A roster entry can *name* a shipped strategy but cannot | |
| *carry* a genome, so storing a mirror there today would silently flatten it back to `default`. | |
| Making the ledger carry a genome β sanitized, versioned, and applied by `competitionWorker.js` | |
| before a duel β is the next stage, and it is also what player-editable genomes need. | |
| ## 10. Open questions | |
| - **What should the objective be, now that duel-Elo is known to be farmable?** The three options in | |
| Β§9 are the start of that argument, not the end of it. Until it is settled, no evolved genome | |
| should be promoted into `engine/` on duel record alone β the held-out round-robin proves | |
| generalisation, not quality. | |
| - **Should the schema's bounds be widened where evolution pins them?** `workerTargetMult` landed | |
| exactly on 2.0, its ceiling. A binding bound means the optimum is outside the range, and the | |
| honest options are to widen it or to state why the range is a design constraint rather than a | |
| search constraint (on Hard the difficulty row multiplies by a further 1.25, so 2.0 is really | |
| 2.5Γ). | |
| - **`runEvolution` has no resume.** A 3-hour run that dies is a 3-hour run lost. Per-generation | |
| checkpointing now writes the champion-so-far, but the *population* is not serialised, so a run | |
| can be salvaged but not continued. The fix is the same shape as `Workflow`'s resume: write the | |
| population alongside the champion and take a `--resume-from`. | |
| - **Should the player ever see a genome?** A "Lineage" panel on a Competition roster entry β | |
| parents, generation, which genes it inherited from which side β is nearly free (it is all JSON | |
| already) and is the thing that would make Β§7.2 feel alive rather than like a number going up. | |
| - **Does the game want an AI that keeps getting harder?** Β§7.3's ratchet has no ceiling. Capping | |
| it against the player's own Elo (the ledger already tracks a human rating) is one answer; making | |
| it a per-galaxy toggle is another. | |
| - **Is `unitMix` better evolved or better hand-written?** It is the gene most tied to a world's | |
| identity and flavour. Evolution may well produce a stronger mix that reads as characterless. | |
| Worth measuring against a "does this still feel like Korrath" judgement that no fitness function | |
| contains. | |