Spaces:
Paused
Download docs/ai-evolution-design.md from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 37.2 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/docs/ai-evolution-design.md
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/docs/ai-evolution-design.md
-
curl -L -o ai-evolution-design.md https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/docs/ai-evolution-design.md
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 inaiEconomy.jsaiBaseAndTechis 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:
- 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.
- The inert genes become junk DNA β and that is a feature, not a bug. A lineage that has been
neverInitiatesfor 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|| 1convention 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.pressurecounts 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.
runSwissTournamentpairs by closest standing overswissRoundCount(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- 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:
- Hall of fame. Seed every generation's tournament with the four shipped baselines β
tools/candidates/baseline-{adaptive,aggressive,economic,force-parity}.jsonalready exist and are exactly this. Elo against a fixed anchor is absolute, not relative. - 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 (
wavesfirst 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:
odysseyOnlymarks a gene whose use site is behind astate.endless/state.diplomacyguard. Every duel is a skirmish (tools/selfplay.js: "no Odyssey, no diplomacy, no galaxy"), so the entire DIPLOMACY module pluswantsIndustryAlwaysanduseBombOffensivelyare 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.gatedByhandles the genes whose use site tests presence rather than value.aiEconomy.jsreadsstrategy.standingArmyCap != null, so emitting a default would cap the army of every genome in the population. It is modelled as an explicitcapsArmyswitch 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
duelSeedtakes an optionalseedKey(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.runDuelnow forwards each candidate's ownarchetypetorunDuelMatch, which has always accepted it and whichcompetitionWorker.jshas 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?
workerTargetMultlanded 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Γ).runEvolutionhas 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 asWorkflow'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
unitMixbetter 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.