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.