Spaces:
Paused
Paused
File size: 37,199 Bytes
a4e6e2e 9f53528 a4e6e2e 9f53528 a4e6e2e 9f53528 a4e6e2e 9f53528 6f03f6f a4e6e2e 21a54e4 a4e6e2e 6f03f6f a4e6e2e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 | # 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.
|