Learning autopilot (personas + MAP-Elites archive)
Learning autopilot
Section titled “Learning autopilot”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 map
Section titled “File map”| 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.
The genome
Section titled “The genome”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. Aweapon_prefof 0 is omitted. visPersonaGenome.SCHEMA_VERSION; the Worker rejects any othervwith 400 (GENOME_VERSIONinpersonas.js, pinned equal by a test).- The default genome flies exactly like today’s AI.
tests/test_persona_auto_player.gdpins it input-for-input againstProfiledAutoPlayer("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 = trueandthrottle_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.
Cells, roles and jobs
Section titled “Cells, roles and jobs”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).
The archive (control Worker + D1)
Section titled “The archive (control Worker + D1)”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 thancandidate_max_samples(12) samples byucb_s, plus previous-epoch seeds, sorted byucb_s. The game’s job picker reads it.cell_candidates= each cell’s best candidate (highestucb_s, then more samples), excluding the cell’s own elite, via aROW_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_sis null for a row with fewer than 2 samples and for a previous-epoch seed.POST /personas/resultalso answersscore_sandelite_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.
Device learning (Dev builds)
Section titled “Device learning (Dev builds)”- 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
MetaStateandDEFAULT_SHIP(Cobalt) throughMetaRunSetup.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 intests/test_learning_run_tag.gd(final review D2, Chris’s ruling: learning stays ON by default in Dev). Any non-NET_SAFEdev 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’chosensignals 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 reportios, Apple TVtvos. A platform with no kind (web, BSDs) does not post. - Network:
PersonaClientrefreshes the archive at boot and at every learning run start (cached inuser://persona_archive.json; no archive = cache = default genome) and queues posts inuser://persona_pending.json(max 50) until the Worker answers. It does nothing (no HTTP, nouser://) unlesslive(), 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: trueinrun_start, the repro block and the storedtimeline_json.tools/economy_sim/human_targets.pyexcludes runs with that marker, so fresh-save persona runs never skew the human calibration targets.
Watch panel (PersonaOverlay)
Section titled “Watch panel (PersonaOverlay)”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_Sread when a verdict arrives.
Console Personas tab
Section titled “Console Personas tab”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.
Tunables
Section titled “Tunables”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.
Running the soak learner
Section titled “Running the soak learner”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.
Local end-to-end (no remote)
Section titled “Local end-to-end (no remote)”S=<scratch dir>; cd controlsed "s#main = \"src/worker.js\"#main = \"$PWD/src/worker.js\"#" wrangler.toml > $S/wrangler.tomlnpx wrangler d1 execute bullet-heaven-control --local --persist-to $S/d1 -c $S/wrangler.toml --file=schema.sqlnpx 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 5Offline learning + upload (the recommended overnight run)
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.
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 samplesscripts/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 (alockdir; 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), appliescontrol/schema.sqlto a new local D1, then inserts prod’s epoch row and prod’s elites with every column,score_sincluded (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 --localon--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 forGET /personas= 200, checks the local epoch, runsscripts/personas-evolve.shwithPERSONAS_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, elsemean_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 withwrangler 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: trueand 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), aduplicateanswer counts as accepted, and an unreadable ledger stops the upload instead of being read as empty. - Nothing here uses remote wrangler, so
CLOUDFLARE_ACCOUNT_IDis not needed. Env for tests:PERSONAS_PROD_API,WRANGLER,PERSONAS_EVOLVE,PERSONAS_OFFLINE_PORT,PERSONAS_OFFLINE_BOOT_S.
Benchmark: elites vs the default genome
Section titled “Benchmark: elites vs the default genome”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-rendersFor 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.
- Remote D1 migration:
cd control && npx wrangler d1 execute bullet-heaven-control --remote --file=migrations/0003_personas.sql. Additive (CREATE TABLE IF NOT EXISTS+ oneINSERT OR IGNOREof epoch 1). Then--file=migrations/0004_persona_score.sql(addspersona_elites.score_s) and--file=migrations/0004b_persona_rescore.sql(backfills it; SQLitesqrt, present on remote D1). Apply 0004 BEFORE deploying a Worker that writesscore_s, or every result post fails on the missing column. - Deploy the control Worker:
cd control && npx wrangler deploy(after the migration, percontrol/README.md). The console Personas tab reads “Archive read failed: HTTP 404” until this lands. Then re-run--file=migrations/0004b_persona_rescore.sqlstraight away. The old Worker’s UPSERT never writesscore_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). - Check
cell_candidateson production D1:GET /personas?key=…returns{"ok":true,"epoch":1,…}with acell_candidatesarray (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 wholeGET /personasfails, so check before any device relies on it. - 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). - Live soak learner:
scripts/personas-evolve.sh --hours 0.25 --jobs 4 --children 5against the live Worker. Expected: at least 3 cells filled inGET /personas.