SpaceCities / docs /ai-evolution-design.md
Claude
Phase 6: play against yourself -- fingerprint a human into a genome
21a54e4 unverified
|
Raw History Blame
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:

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:

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

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 skiffs 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
    1. 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:

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.