Skip to content

Learning autopilot (personas + MAP-Elites archive)

Current source of truth for the Dev-only learning autopilot: evolving “personas” that fly the autopilot, a shared MAP-Elites archive in the control Worker, the headless soak learner, the on-device watch panel and the console Personas tab. Design: spec docs/superpowers/specs/2026-10-11-learning-autopilot-design.md (its §8 records every place the shipped code differs from the original design). Plan: docs/superpowers/plans/2026-10-11-learning-autopilot.md.

Store builds are untouched. Everything below is gated on BuildConfig.dev_tools(); a store build keeps the fixed attract AI and sends nothing.

File What
ai/persona/persona_genome.gd (PersonaGenome) The genes, clamps, mutation, crossover, nudged_up, hash_id(), to_dict()/from_dict()
ai/persona/persona_auto_player.gd (PersonaAutoPlayer extends ProfiledAutoPlayer) Flies a genome at a cell’s SkillProfile; genome-scored level-up picks; tally / offered role counts
ai/persona/persona_jobs.gd (PersonaJobs) Pure job picking (mutate / candidate / elite / pinned), breeding, role_of, sample_for (the POST body)
ai/persona/persona_tuning.gd (PersonaTuning) Game-side console tunables (below)
ai/skilled_auto_player.gd The 8 playstyle consts became instance vars defaulting to the same consts (byte-identical when no persona; tests/test_skilled_knob_baseline.gd)
net/persona_client.gd (PersonaClient) Device side: archive fetch + user:// cache, offline post queue, result_posted(resp, sample)
main.gd Learning-run decision at the _new_run meta block, the attract chain, take-over / dev-command cancels, _submit_persona_sample, the overlay wiring, the telemetry marker
meta/dev_flags.gd, ui/debug_settings_panel.gd The learning_autopilot Dev flag (row “Dev: learning autopilot (personas)”)
ui/persona_overlay.gd (PersonaOverlay) The on-device corner panel
tools/persona_evolve/evolve_batch.gd (EvolveBatch), evolve_runner.gd Headless soak learner: plan a batch (--mode=plan), fly one unit (--mode=fly); the old per-slot round mode is kept
tools/persona_evolve/evolve_queue.mjs, personas_http.mjs, scripts/personas-evolve.sh The work-queue driver, the HTTP half, and the shell entry (args, key, import snapshot, traps)
scripts/personas-offline.sh, tools/persona_evolve/personas_upload.mjs Offline learning: the learner against a local Worker seeded from prod, and the winners-only upload
tools/persona_evolve/benchmark/ (PersonaBench, bench_runner.gd, benchmark.mjs) Elites vs the default genome on fixed seeds (paired t)
tools/soak/soak_run.gd persona / humanize keys so SoakRun can fly a persona
control/src/personas.js, control/migrations/0003_personas.sql + 0004_persona_score.sql + 0005_persona_sample_dedupe.sql (also in control/schema.sql) Worker routes and D1 tables
control/src/panel/client.js (+ markup.js, styles.js, tooltips.js) Console Personas tab
tools/economy_sim/human_targets.py Excludes persona runs from the human calibration targets

Tests: tests/test_persona_genome.gd, test_persona_auto_player.gd, test_persona_jobs.gd, test_persona_evolve.gd, test_persona_client.gd, test_persona_overlay.gd, test_persona_sample_contract.gd, test_persona_bench.gd, test_learning_autopilot_main.gd, test_skilled_knob_baseline.gd; control/test/personas.test.mjs, control/test/panel-personas.test.mjs, scripts/test/personas-evolve.test.mjs, scripts/test/personas-offline.test.mjs, scripts/test/persona-benchmark.test.mjs, telemetry/test/events.test.mjs (persona marker), tools/economy_sim/tests/human_targets.test.py.

PersonaGenome.to_dict() is the wire and archive shape:

{"v": 1,
"genes": {"w_base": 1.0, "w_tank": 0.0, "w_support": 0.0, "w_damage": 0.0, "w_utility": 0.0,
"w_drone": 0.0, "new_weapon_bias": 0.0,
"engage_seek": 1.0, "orbit": 1.0, "kite": 1.0, "dash_min": 1.0, "dash_max": 1.0,
"warp_gap": 1.0, "elite_danger": 1.0, "boss_flee": 1.0},
"weapon_pref": {"<weapon id>": 0.5}}
  • Build genes (w_*, new_weapon_bias, weapon_pref) score level-up cards: w_base × (upgrade_effects score ÷ best score in the offer) plus the tag, weapon and new-weapon terms.
  • Playstyle genes are multipliers on SkilledAutoPlayer’s consts. Survival-critical dodge logic (escape rings, reaction times, warp landing search) is not evolvable.
  • Every value is clamped to its declared range and snapped to 0.001, so hash_id() (first 16 hex of the sha256 of the sorted JSON) is stable across save/load. A weapon_pref of 0 is omitted.
  • v is PersonaGenome.SCHEMA_VERSION; the Worker rejects any other v with 400 (GENOME_VERSION in personas.js, pinned equal by a test).
  • The default genome flies exactly like today’s AI. tests/test_persona_auto_player.gd pins it input-for-input against ProfiledAutoPlayer("strong") with humanize + throttle on. With learning off nothing persona-related runs, so attract mode and the determinism baselines are unchanged.
  • Personas always fly with humanize = true and throttle_planner = true, headless too, so soak and device scores are comparable.
  • The genome only drives the controller; nothing reaches /sim. A run is reproducible from seed + genome.

12 cells = role (tank, support, damage, utility) × skill (weak, median, strong). Skill comes from the job’s target cell and is never evolved.

Role played (PersonaJobs.role_of(taken, offered, target_role)): for each role, the share of role tags among the cards the run TOOK minus its share among the cards it was OFFERED. The highest wins. That measures preference, not content prevalence: 15 of 16 weapons are damage-tagged and every support card also carries tank, so a raw taken share read damage for almost every genome. A tie at the top goes to the target role when it is among the tied, else to the first tied role in tank, support, damage, utility order. Nothing taken at all reads as the target role. The run scores into the cell of the role it played at the job’s skill, which is how empty cells fill.

Jobs (PersonaJobs.pick_job, rng = the run seed on device, seed_base in soak):

Job Default share What
mutate MUTATE_SHARE 0.6 Target = an empty cell, else the least-sampled (ties by rng). Parent = the target’s elite, else a same-role, same-skill, then any elite, else the default genome. With CROSSOVER_P it first crosses with another cell’s elite. Then 2-3 Gaussian gene nudges (SIGMA × range), then the directed nudge: w_<target role> always steps up by TARGET_NUDGE × its range. A child that hashes back to its parent is re-mutated up to 8 times
candidate CANDIDATE_SHARE 0.3 Re-fly the candidate with the highest ucb_s (mean_s for an archive without it) to add samples. Label RE-CHECK candidate i/min, or RE-CHECK candidate, sample i past min_samples
elite the rest Re-fly a random elite unchanged (corrects lucky scores)
pinned console pin The pinned genome at its cell; the first pinned result clears the pin (devices only; soak ignores pins)

MUTATE_SHARE is clamped first and CANDIDATE_SHARE gets at most the remainder. “Stale cell first” targeting from the spec is not implemented (epochs cover staleness for now).

Lives in the control Worker (control/src/personas.js), behind CONTROL_KEY, not the telemetry Worker. Tables (migration 0003_personas.sql): persona_epochs, persona_elites (one row per epoch + cell), persona_samples (one per run), persona_promotions (history for the chart), persona_pin (single row). No personal data: finder is a device kind, so these tables are not in telemetry’s RETENTION_TABLES; samples older than the previous epoch are pruned when a new epoch starts.

One run = one sample (migration 0005_persona_sample_dedupe.sql). A run is deterministic from seed + genome, so (epoch, genome_hash, cell, seed, finder) identifies it: a unique index enforces it, the Worker inserts with INSERT OR IGNORE, and a repeat (a retry after a lost answer, an offline upload re-run) is answered {ok: true, duplicate: true, ...the aggregate as it stands} without the pin clear or the promotion pass. Distinct runs never collide: devices send run_seed = randi() (32-bit, per run; main.gd _new_run, sent by _submit_persona_sample as "seed": run_seed), soak runs EvolveBatch.run_seed(seed_base, index) with seed_base from the start time; replays and LAN runs, which reuse a seed, never learn. A soak re-fly of the same genome on the same seed would be the same run anyway, so dropping it loses nothing. 0005 also deletes existing repeats (keeping the lowest id) and re-aggregates the elites (mean, gold, n, score). Release order: see the step below.

Promotion is Worker-side. POST /personas/result stores the sample, aggregates that genome’s samples in that cell (COUNT, AVG(x), AVG(x*x)), and when it has PROMOTE_MIN_SAMPLES (4, a Worker const, sent to clients as min_samples) runs ONE upsert whose WHERE holds the rule: a different genome takes the cell only with a higher score, or an equal score and more gold; the same genome refreshes its own row (mean and score can go down) but keeps its identity and lineage. The game never decides promotion.

Score = mean − SE, SE = sample sd (n−1) / √n (scoreOf in personas.js), stored on persona_elites.score_s (migration 0004_persona_score.sql adds the column; the re-runnable 0004b_persona_rescore.sql backfills every elite from its own samples; a row with no score is compared on its mean). Why (2026-10-13, local soak, 800 samples): survival sd is 50-70% of the mean per genome (tank:strong 14 samples: mean 238, sd 160, range 66-625), and on raw mean at 3 samples promotions were mostly luck (support:strong promoted at 316.9 s on 3 samples, 202.7 s by 7). A steady elite’s SE shrinks with every re-check, so a challenger needs a mean clearly above the elite’s, not a lucky streak. So that a truly-better but noisy genome is not lost at n=4, a non-elite genome stays a candidate until CANDIDATE_MAX_SAMPLES (12, Worker const), and candidates rank on the optimistic ucb_s = mean + SE (n=1: SE taken as N1_CV_PRIOR × mean, Worker const 0.6 = the measured survival CV, sd 50-70% of the mean on the 2026-10-13 soak; a previous-epoch seed ranks on 1.6 × its old mean. A prior of 1.0 let every one-run mutant outrank soak near-misses at n=5..11, whose SE is about 0.27 × mean), so promising-but-uncertain genomes are re-checked first while pessimistic score_s decides promotion. CANDIDATE_SHARE went 0.2 → 0.3 (MUTATE_SHARE 0.7 → 0.6) for the extra re-check volume. The ucb_s SQL uses SQLite sqrt (verified on local workerd D1 via wrangler dev, and on remote D1 by the 2026-10-13 review: SELECT sqrt(4.0) = 2, sqrt(-1e-9) = NULL, which the MAX(0.0, …) clamp never reaches). Validation: cells, job, hash format, genome v and size, numeric ranges, patch_id length, and finder in soak / tvos / ios / macos / android / linux / windows (400 otherwise); a stale epoch is 409.

GET /personas?key= answers:

{"ok": true, "epoch": 1, "min_samples": 4,
"elites": [{"epoch","cell","genome_hash","genome","persona_id","name","parent_id","generation",
"mean_s","score_s","mean_gold","samples","finder","build","updated_ms"}],
"candidates": [{"genome_hash","cell","genome","parent_id","generation","mean_s","score_s","ucb_s","mean_gold","samples","seed?"}],
"candidate_max_samples": 12,
"cell_candidates": [ /* same shape, the best candidate per cell */ ],
"pinned": {"genome", "cell"} | null,
"recent": [ /* last 50 samples: ts, cell, target_cell, job, survival_s, gold, finder, patch_id, genome_hash, generation, build */ ],
"history": [ /* promotions: ts, cell, name, genome_hash, mean_s, samples */ ]}
  • candidates = the global top 20 non-elite genomes with fewer than candidate_max_samples (12) samples by ucb_s, plus previous-epoch seeds, sorted by ucb_s. The game’s job picker reads it.
  • cell_candidates = each cell’s best candidate (highest ucb_s, then more samples), excluding the cell’s own elite, via a ROW_NUMBER() OVER (PARTITION BY cell …) window query; a previous-epoch seed fills only a cell with no current-epoch candidate. The console grid reads it, so a low-survival cell is not shown as empty just because the global top 20 dropped it.
  • Persona names are <Role>-<Skill> #<generation>, e.g. Tank-Strong #14.
  • score_s is null for a row with fewer than 2 samples and for a previous-epoch seed. POST /personas/result also answers score_s and elite_score_s.

Epochs. Elites carry the build they were scored on. After a balance change, start a new epoch (console New epoch, or POST /personas/epoch {key, note}). The new epoch starts with no elites; the previous epoch’s elites come back as 0-sample candidates (seed: true) and must be re-scored to hold a cell.

  • Flag: DevFlags.learning_autopilot, on by default in a Dev build (absent in store builds), toggled from Debug Settings or the console Settings tab (dev_flag learning_autopilot).
  • Which runs learn (main.learning_run_wanted): Dev + flag + survival + not LAN + no blocking A/B (tackler_spawns, web_drone, suppressor_spawns, hard_tuning, perf_bench, auto_showcase). With the flag on, EVERY menu Survival and every Redeploy is a learning run until a human takes over (Chris, 2026-10-11). Flag off = normal play on the real save. Replay playback and captures never learn.
  • Fixed conditions: a learning run applies a fresh MetaState and DEFAULT_SHIP (Cobalt) through MetaRunSetup.apply, default area, survival, so device and soak scores compare. Gold earned is still banked into the real save, as attract runs always were.
  • Human take-over cancels the job (no post), but the rest of that run keeps the fresh save + Cobalt. So the human knows, LearningRunTag (ui/learning_run_tag.gd) then shows “LEARNING RUN · fresh save · ” in the AUTOPILOT ACTIVE slot (top centre, which that label frees on take-over) for the rest of the live run: Dev learning runs only, gone on the death screen and the next run, sliding right past the revealed weapon/drone docks on narrow screens (iPad). Colour and size are tokens (warn.main, font.sm); fit at tv / iphone / ipad in tests/test_learning_run_tag.gd (final review D2, Chris’s ruling: learning stays ON by default in Dev). Any non-NET_SAFE dev command, Bay save/deploy mid-run, a P2 join and a dev build reset also cancel. The console autopilot toggle ({kind:"autopilot"}, dev-tools.md) never starts or revives a job: OFF on a live learning run cancels it like a take-over, ON on a taken-over one flies without scoring. At bank time a console tunable edit (RemoteConfig.live_edited), a blocking flag or invulnerability skips the post. Each sample records the active remote-config patch id ("" for the shipped defaults). A human pick on the level-up or Dimension panel also cancels it (the panels’ chosen signals only fire for a human; the AI’s auto-pick calls the handlers directly).
  • Attract chain: a learning survival run (never a replay or capture) that dies untouched starts the next learning run after PersonaTuning.ATTRACT_CHAIN_S (12 s) on the death screen. Any press, or the flag going off, stops it. Nothing else in the game restarts a run on its own, so without it an unattended device flies one run.
  • Finder = OS.get_name().to_lower() mapped onto the Worker’s kinds (PersonaClient.finder_kind): iPhone and iPad both report ios, Apple TV tvos. A platform with no kind (web, BSDs) does not post.
  • Network: PersonaClient refreshes the archive at boot and at every learning run start (cached in user://persona_archive.json; no archive = cache = default genome) and queues posts in user://persona_pending.json (max 50) until the Worker answers. It does nothing (no HTTP, no user://) unless live(), which is false in the editor and headless, so a Mac editor run never fetches or posts.
  • Telemetry marker: a learning run (taken over or not) reports the save and hull it actually flew and carries persona_run: true in run_start, the repro block and the stored timeline_json. tools/economy_sim/human_targets.py excludes runs with that marker, so fresh-save persona runs never skew the human calibration targets.

Opened on the per-run auto_layer for a learning run only; any cancel closes it. Shows the persona name, cell and job label (MUTANT of …, RE-CHECK candidate 2/3, ELITE re-check: …, PINNED persona), up to 3 gene diffs vs the parent, the role it is playing so far and the cell it would score into, survival vs the cell elite (bar hidden for an empty cell), and the last pick.

  • Verdict (“New candidate …”, “PROMOTED …”) shows only when the Worker answer matches the sample THIS run posted (owns_sample): the queue can flush older samples, and a slow answer can land after the next run started.
  • The panel has its own CanvasLayer: under the HUD and level-up cards during the run, raised above the death fade and results screen when the verdict arrives, and collapsed to title + verdict (compact mode) so it never covers the results buttons, checked at the iPhone / iPad / TV profiles.
  • Placement stays inside the safe area and the 5% TV title-safe band; top corners step below the weapon/drone docks and pairing code. The spec’s mini 4×3 grid on the device was dropped: the verdict names the cell and the full grid lives on the console tab.
  • Tunables (LIVE): PersonaOverlay.CORNER (0 TL, 1 TR, 2 BL default, 3 BR), OPACITY; VERDICT_S read when a verdict arrives.

See docs/architecture/dev-tools.md § Remote Playtest Console. In short: reachable before pairing a device; 4×3 grid (each cell shows the elite, or the best candidate when there is no elite; finder only in the detail box); detail = mean and score (mean − SE), genome, lineage, Pin; promotions chart; latest-runs feed; New epoch.

Game side, PersonaTuning (all LiveScope NEXT_RUN except ATTRACT_CHAIN_S, LIVE): MUTATE_SHARE 0.6, CANDIDATE_SHARE 0.3, SIGMA 0.15, TARGET_NUDGE 0.15, CROSSOVER_P 0.15, SOAK_SEEDS 5, ATTRACT_CHAIN_S 12 s. Overlay: PersonaOverlay.CORNER/OPACITY/VERDICT_S. Labels live in tools/tunable_docs/docs.d/persona.json and persona_overlay.json (“Learning autopilot” group). PROMOTE_MIN_SAMPLES is deliberately a Worker const, not a tunable: promotion must be one rule for every learner.

Terminal window
scripts/personas-evolve.sh --hours 8 --jobs 8 [--children 4] [--seeds 5] [--max_ticks 72000] [--rounds N] [--dry-run]

A work queue, no round barrier (2026-10-13, tools/persona_evolve/evolve_queue.mjs; the old loop waited for each round’s slowest slot). One “round” = fetch the archive, then one evolve_runner --mode=plan call plans --jobs × --children units (one child × --seeds runs each), excluding every genome still queued, flying or being posted (EvolveBatch.plan(..., exclude); the driver also drops any planned unit whose hash is busy). --jobs slots each run one --mode=fly godot process per unit and start the next queued unit the moment one finishes. A unit’s samples are POSTed as soon as it lands, and its genome leaves the in-flight set only after the post answers, so a re-plan never hands a second slot a genome another slot is flying (PersonaJobs candidate picks are a deterministic best ucb_s, so independent slots would all re-fly the top candidate). When the queue is down to --jobs units, the next round is planned from a fresh archive while the slots keep flying. An archive fetch that throws (ECONNREFUSED while a local wrangler restarts, DNS, a dropped socket, the 20 s timeout) takes the same “archive fetch failed; retry” path as an HTTP error. However the driver ends (done, signal, or an uncaught error), it SIGTERMs every godot it started, fly slots and the planner, first: the shell’s EXIT trap deletes the work dir they write into. --rounds N = N plans (a failed fetch or plan counts as one), all flown. --hours takes fractions; past it no new unit starts, running units finish and post, and queued units are dropped (“N planned units not flown (budget)”). Each godot process costs about 1 s of startup per unit (measured, M5), small next to a 5-seed unit. The exit line prints slot utilisation = busy slot-seconds / (jobs × wall).

Bash only (not zsh). Env: BULLET_HEAVEN_CONTROL_KEY (sourced from ~/.secrets when unset), PERSONAS_API (default: the LIVE control Worker), GODOT, PERSONAS_RETRY_S, PERSONAS_SLOT_TIMEOUT_S. It snapshots and restores project.godot / audio/bus_layout.tres around its --import. Posting to the live Worker changes the archive every device learns from: it needs Chris’s OK. Use --dry-run, or point PERSONAS_API at a local wrangler dev (below), for trials. For real (overnight) learning use scripts/personas-offline.sh (§ Offline learning + upload), which wraps this script against a local Worker seeded from prod and uploads only winners.

Utilisation (2026-10-13, --jobs 4 --children 5 --seeds 5 --rounds 3 --dry-run, same machine sharing CPU with an 8-slot soak): barrier loop 80.3% (sum of slot walls / (4 × 444 s), startup and fetch excluded, so optimistic), work queue 98.4% (1001 busy slot-s / (4 × 254 s), which counts the ~1 s godot startup per unit, so about 92% flying).

Throughput (M5, 2026-10-11, local Worker, barrier loop): --jobs 4 --children 5 = 100 runs per round, about 60 s wall on an empty archive and up to about 2 min once elites survive longer (27-35× real time per slot). That run (one 20-child round, then the 15-minute command above: 8 rounds, 800 samples) filled all 12 cells, 9 of them after the first round.

Terminal window
S=<scratch dir>; cd control
sed "s#main = \"src/worker.js\"#main = \"$PWD/src/worker.js\"#" wrangler.toml > $S/wrangler.toml
npx wrangler d1 execute bullet-heaven-control --local --persist-to $S/d1 -c $S/wrangler.toml --file=schema.sql
npx wrangler dev -c $S/wrangler.toml --local --persist-to $S/d1 --port 8799 --var CONTROL_KEY:localtest
# other shell, repo root:
PERSONAS_API=http://localhost:8799 BULLET_HEAVEN_CONTROL_KEY=localtest bash scripts/personas-evolve.sh --hours 0.25 --jobs 4 --children 5
Section titled “Offline learning + upload (the recommended overnight run)”

Since 2026-10-13 the soak learner runs offline against a local control Worker and only winners go to prod. The live archive then sees a few strong genomes with all their samples instead of thousands of one-run mutants, and nothing touches prod until you decide.

Terminal window
scripts/personas-offline.sh --hours 8 --jobs 8 [--state DIR] [--fresh] [--port 8797] [-- <personas-evolve.sh args>]
scripts/personas-offline.sh upload # dry run: which local elites beat prod, and how many samples
scripts/personas-offline.sh upload --apply # posts them to prod (needs Chris's OK, like any live post)
  • State lives in ~/.cache/bullet-heaven/personas-offline (override --state / PERSONAS_OFFLINE_STATE): outside every checkout, so sessions and worktrees accumulate into one local archive across runs. One run per state dir (a lock dir; a dead owner’s lock is taken over).
  • First run, or --fresh: reads prod’s archive over HTTP (BULLET_HEAVEN_CONTROL_KEY = the prod key, from ~/.secrets), applies control/schema.sql to a new local D1, then inserts prod’s epoch row and prod’s elites with every column, score_s included (personas_upload.mjs seed-sql). Local promotion therefore has to beat what prod already holds. Prod’s candidates and samples are not copied.
  • Every run then starts wrangler dev --local on --port (default 8797, so a developer’s own 8787 stays free; a port that already answers is refused, never reused), in its own process group, waits for GET /personas = 200, checks the local epoch, runs scripts/personas-evolve.sh with PERSONAS_API = the local Worker and the local-only key, stops wrangler on any exit (Ctrl-C and TERM too), and prints the upload dry run.
  • Epochs: state seeded for an older epoch is refused, by the run and by upload (“use –fresh”): prod answers 409 to a sample from another epoch. Upload any winners BEFORE prod starts a new epoch.
  • Upload rule (personas_upload.mjs): for each local elite whose score (score_s, else mean_s) beats prod’s elite in that cell (or the cell is empty on prod), POST every local sample of that genome in that cell, with prod’s epoch. Prod’s own promotion rule (score = mean - SE over the samples it holds) decides; a genome with no local samples (a seeded prod elite) is skipped. Local samples are read with wrangler d1 execute --local (no extra Worker route).
  • No double counting is a PROD guarantee (migration 0005, below): one run = one sample, so a re-sent sample is answered duplicate: true and changes nothing. <state>/uploaded.json (local sample ids prod accepted, per prod epoch) is only an optimisation that skips re-sending them. It is written atomically after every accepted post (a killed upload loses nothing), a duplicate answer counts as accepted, and an unreadable ledger stops the upload instead of being read as empty.
  • Nothing here uses remote wrangler, so CLOUDFLARE_ACCOUNT_ID is not needed. Env for tests: PERSONAS_PROD_API, WRANGLER, PERSONAS_EVOLVE, PERSONAS_OFFLINE_PORT, PERSONAS_OFFLINE_BOOT_S.
Terminal window
node tools/persona_evolve/benchmark/benchmark.mjs --archive=<GET /personas body .json> [--n=20] [--jobs=8] \
[--chunk=5] [--max_ticks=72000] [--out=results.json] [--md=table.md] # --from=results.json re-renders

For every cell’s elite it flies the elite AND PersonaGenome.new() at that cell’s skill on the same --n seeds (PersonaBench.seeds, fixed by SEED_BASE, so reruns and later archives compare), through EvolveBatch.soak_persona, i.e. exactly the learner’s conditions (fresh MetaState, default ship, survival, humanize + throttle: both call the one function, which is the guarantee). The default genome ignores its target role, so it is flown once per skill and paired with each of that skill’s cells. Output per cell: elite / default mean ± sd, paired mean difference with a 95% CI (paired t, n-1 df), p, wins-losses, the role the elite actually played (role_of for its own cell’s role), and runs that hit max_ticks alive (capped); per skill, the best elite (by benchmark mean) against the default. 20 seeds × (12 elites + 3 defaults) = 300 runs. Results: see docs/research/2026-10-13-persona-benchmark-vs-default.md.

Release steps (each needs Chris’s same-day OK, in this order)

Section titled “Release steps (each needs Chris’s same-day OK, in this order)”

0005 (one run = one sample), from persona-offline-bench: deploy the Worker FIRST, then apply migrations/0005_persona_sample_dedupe.sql. The new Worker’s INSERT OR IGNORE behaves exactly like the old INSERT while the index is missing, so that order has no failure window. The reverse order has one: the OLD Worker’s plain INSERT on a duplicate throws D1_ERROR: UNIQUE constraint failed, uncaught, so it answers HTTP 500 and stores nothing (verified on local workerd D1 with the origin/main Worker, 2026-10-13). The soak learner counts that as a failed post; a device keeps the sample at the head of its queue and retries it at every refresh, holding back the samples behind it until the 50-item cap drops it. If 0005 does go first, deploy straight after. 0005 is re-runnable.

Status (2026-10-13): migration 0003 was applied to remote D1 and the control Worker deployed on 2026-10-11; epoch 2 started 2026-10-12. Still to do for the persona-promote-queue branch, once merged: 0004 + 0004b, the deploy, and the 0004b re-run (steps 1-2 below), then a re-check of step 3 on the new Worker.

  1. Remote D1 migration: cd control && npx wrangler d1 execute bullet-heaven-control --remote --file=migrations/0003_personas.sql. Additive (CREATE TABLE IF NOT EXISTS + one INSERT OR IGNORE of epoch 1). Then --file=migrations/0004_persona_score.sql (adds persona_elites.score_s) and --file=migrations/0004b_persona_rescore.sql (backfills it; SQLite sqrt, present on remote D1). Apply 0004 BEFORE deploying a Worker that writes score_s, or every result post fails on the missing column.
  2. Deploy the control Worker: cd control && npx wrangler deploy (after the migration, per control/README.md). The console Personas tab reads “Archive read failed: HTTP 404” until this lands. Then re-run --file=migrations/0004b_persona_rescore.sql straight away. The old Worker’s UPSERT never writes score_s, so anything it promoted between step 1 and the deploy is stale: a promoted challenger keeps the previous elite’s score, and a first-ever elite has NULL (compared on its mean). 0004b only runs UPDATEs, so it is safe to re-run any time (same samples, same score).
  3. Check cell_candidates on production D1: GET /personas?key=… returns {"ok":true,"epoch":1,…} with a cell_candidates array (bound the output, e.g. | head -c 600). The window-function query was verified only on local D1 (wrangler 4.149 / workerd) and node:sqlite. If it errors remotely, the whole GET /personas fails, so check before any device relies on it.
  4. Device installs: a Dev build from merged main (bh-deploy / bh-worktree-device-install). Leave Survival running on the autopilot: the panel shows the persona; after death a verdict appears and the run shows in the console Personas “Latest runs” feed with the device’s finder kind (tvos / ios).
  5. Live soak learner: scripts/personas-evolve.sh --hours 0.25 --jobs 4 --children 5 against the live Worker. Expected: at least 3 cells filled in GET /personas.