flybrain/infra/docs/room-escape.md
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

25 KiB

Room escape: choosing the direction hold

Measured 2026-09-16 for docs/design/room-escape.md section 1, which asked for a hold value between 600 and 1000 ms chosen by measurement rather than by argument.

Superseded in part by the v0.1.1 section below, which reversed the fatigueGain half of this result and lowered the hysteresis: the random walker used here cannot build an incumbency, so it cannot see what a low fatigue gain does to a real network. The 800 ms hold survived.

Chosen: holdMs = decisionMs = 800, fatigueGain = 0.04. Baked into packages/brain/src/readout/presets/gameboy.ts and its Rust twin flybrain-core::decoder::gameboy, noted in docs/readout.md.

What was run

services/flysim/crates/flysim/examples/room_escape.rs, on the pinned Pokémon Red cartridge (SHA-256 0e85…219f), unthrottled, 15 worker threads on the WSL development box. The real PopulationDecoder with the real Game Boy preset, the real PokemonRedReward adapter, and seeded uniform random rates in place of a network — the random_walker.rs pattern, which is how the readout gets measured without 139,255 neurons of confound.

Two starting rooms, both reached by the harness itself rather than from an archive:

  • Red's bedroom, map 38 (REDS_HOUSE_2F). Booted from a cold cartridge: Start and A on a slow alternation through the intro and the naming screens, then B until the adapter reports a stable, unscripted, dialogue-free overworld sample. One warp, the stairs at (7, 1); the fly spawns at (3, 6) with (3, 5) blocked.
  • The ground floor, map 37 (REDS_HOUSE_1F). One run walked down the stairs and its state was cached. Captured 15 brain seconds after arrival, not on arrival: the first safe sample after a warp is standing on the staircase, and "leave the map" from the staircase is one step back up. Three warps — the two front-door tiles at (2, 7) and (3, 7), and the stairs at (7, 1) — and a table between them.

Each cell is 60 seeded runs of 10 brain minutes, run to the full budget rather than stopped at the first map change, so the coverage column is uncensored.

The design's twenty seeds

Twenty is what the design asked for. It is not enough to choose on: every candidate leaves both rooms in every one of twenty runs, so the leave fraction saturates at 20/20 everywhere, and the median time to leave moved by more between neighbouring hold values than it did across the whole range (bedroom, fatigue 0.04: 38.1 s at 400, 26.8 s at 600, 29.2 s at 800, 43.7 s at 1000 — not monotone in either direction). The tables below are therefore 60 seeds, of which those twenty are the first.

From Red's bedroom, map 38

60 seeded runs per row.

hold = decision fatigue gain left the map median time to leave mean time to leave median tiles mean tiles median maps
400 ms 0.04 60/60 34.3 s 52.4 s 173.0 168.9 4.0
400 ms 0.08 60/60 37.5 s 56.6 s 179.0 173.0 3.0
600 ms 0.04 60/60 31.3 s 39.6 s 174.5 173.2 4.0
600 ms 0.08 60/60 28.7 s 36.0 s 174.0 178.9 4.0
800 ms 0.04 60/60 22.6 s 27.0 s 188.0 181.6 4.0
800 ms 0.08 60/60 23.8 s 31.6 s 172.5 171.1 4.0
1000 ms 0.04 60/60 30.2 s 35.9 s 174.0 173.8 4.0
1000 ms 0.08 60/60 24.0 s 31.0 s 182.0 174.3 4.0

Maps reached across all 480 runs in this table: Pallet Town (0) in 470, the ground floor (37) in 480, the bedroom again (38) in 384, Oak's Lab (40) in 323 and Blue's house (39) in 73. No run reached route 1 (12) in either table: the game blocks the north exit from Pallet Town until the player has a starter, so a walker with no plan circles the four maps it can open.

From the ground floor, map 37

60 seeded runs per row.

hold = decision fatigue gain left the map median time to leave mean time to leave median tiles mean tiles median maps
400 ms 0.04 60/60 17.3 s 24.6 s 158.0 151.6 3.0
400 ms 0.08 60/60 16.2 s 19.6 s 159.5 152.7 3.0
600 ms 0.04 60/60 15.0 s 21.2 s 166.0 162.5 3.0
600 ms 0.08 60/60 19.4 s 24.0 s 148.0 152.7 3.0
800 ms 0.04 60/60 20.0 s 28.5 s 171.0 164.7 3.0
800 ms 0.08 60/60 18.9 s 25.8 s 162.5 163.8 3.0
1000 ms 0.04 60/60 24.1 s 28.1 s 165.0 162.6 3.0
1000 ms 0.08 60/60 19.6 s 25.9 s 178.0 171.7 4.0

Maps reached across all 480 runs in this table: Pallet Town (0) in 480, the ground floor again (37) in 369, the bedroom (38) in 275, Oak's Lab (40) in 350 and Blue's house (39) in 91.

Confirmation with the real brain

FLY_ESCAPE_BRAIN=data/fafb-v783: NeuralAgent on the FlyWire connectome, 139,255 neurons, four sweep threads, warm-up included, the adapter's payouts reinforcing as they do on the stream, from the same bedroom state. Five runs each, and the runs differ only in warm-up length — the network and binjgb are both deterministic, so five runs from one state with one warm-up are one run reported five times. (That was the first attempt: all five left the bedroom at the same millisecond.)

hold / fatigue run 1 run 2 run 3 run 4 run 5 left median mean
400 ms / 0.08 (before) 38.3 s 22.7 s 129.1 s 52.5 s 294.2 s 5/5 52.5 s 107.4 s
800 ms / 0.04 (chosen) 18.6 s 28.6 s 68.3 s 30.3 s 68.8 s 5/5 30.3 s 42.9 s

The brain is slower than the random walker at both settings and moves the same way between them: the median more than halves and the mean falls by 60%. So the choice is not a random-walker artefact. Every run left through the stairs to map 37; none of the ten went anywhere else first.

Why 800 and 0.04 (the 0.04 half was wrong)

  • Leave fraction cannot decide it. Every candidate escapes both rooms in every run at 60 seeds, which is worth recording as a result in itself: the design's premise, that the fly cannot get out of a room, is not reproducible by a random walker in these two rooms at any of the four hold values. What the old setting cost was time, not success.
  • 800 ms is the best row in the bedroom on every column. Fastest median (22.6 s), fastest mean (27.0 s), most ground covered (188 median tiles). The bedroom is the tight room — 8x8 tiles with furniture and one exit — and it is the case the design was written about.
  • 1000 ms is worse, and consistently. Slower than 800 in both rooms at both fatigue gains. A commitment long enough to cross the whole room means running into a wall and staying there until the next decision.
  • 0.04 beats 0.08 at 800 ms, on the bedroom mean (27.0 s against 31.6 s) and on coverage in both rooms. Elsewhere in the table the two fatigue gains are inside the seed-to-seed spread, so this is a weak result on its own; it agrees with the mechanism the design gives, which is that a lower gain is what lets a committed direction survive a re-decision, and it is the value the design proposed. This was the wrong call, and the weakness of the evidence was the warning. A uniform random source re-rolls a fresh direction at every decision, so it never builds the long incumbency the gain governs; on a real network at 0.04 one direction won 4.1 consecutive holds, twelve tiles of commitment in an eight-tile room. See the v0.1.1 section.
  • The ground floor costs a few seconds. 800/0.04 is 20.0 s against 15.0 s for the best row (600/0.04), inside the spread of that room's eight rows (15.0 to 24.1 s) and paid back in coverage (171 against 166 median tiles). The ground floor has three warps including two adjacent front-door tiles, so it is easy at every setting and is the weaker of the two signals.

Honesty

This measures the readout. A random walker is not a brain, and a configuration that leaves a room faster is not learning anything — the whole reason the walker is the instrument here is that it removes the brain from the loop so the readout can be seen on its own. The five-run brain confirmation is five runs, on one cartridge, from one room; it says the number is not an artefact of the random source, and it is not a benchmark of the fly.

Reproducing

FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \
FLY_ESCAPE_SEEDS=60 FLY_ESCAPE_JOBS=15 \
  cargo run --release -p flysim --example room_escape

About 17 minutes of wall time for the 960 runs. Then, for the confirmation rows:

FLY_ROM=... FLY_ESCAPE_SEEDS=1 FLY_ESCAPE_HOLDS=800 FLY_ESCAPE_FATIGUES=0.04 \
FLY_ESCAPE_BRAIN=data/fafb-v783 FLY_ESCAPE_HOLD=800 FLY_ESCAPE_FATIGUE=0.04 \
  cargo run --release -p flysim --example room_escape

binjgb prints the cartridge header to stdout on every boot, so the report is easier to read through grep -v. The ROM is read from the path in FLY_ROM and is never copied into the repository.

v0.1.1: why the live fly stayed on the ground floor

Measured 2026-09-16 for docs/design/room-escape.md section 3, after the release box (the release container, twitch.tv/<twitch-channel>, v0.1.0) spent forty minutes on rung 2.

Chosen: hysteresis 1.15 -> 1.05, fatigueGain 0.04 -> 0.08, and the blocked-direction cooldown on at blockedFatigue 0.35 / blockedMs 800. holdMs and decisionMs stay at 800. Baked into packages/brain/src/readout/presets/gameboy.ts and its Rust twin flybrain-core::decoder::gameboy, dated in docs/readout.md.

The state this was measured from

Generation 525 of the release run, pct pulled read-only off the release container and never written back: SHA-256 a899997a36ab1706…fd3d49, 2,667,148 bytes, brain clock 2,589,850 ms (43.2 minutes), emulator frame 154,537, rank 2 since 95,740 ms, ratchet best 2 with 0 recoveries and 0 attempts, tileCounts 37:32 and 38:39, all eight boundary payouts spent, lifetime reward 3.20.

The fly was standing at map 37 (7, 1) — on the staircase tile, which only warps on the step onto it — holding up into the wall above it, with stable at 248 samples.

Its four direction scores at that instant:

channel rate baseline score fatigue adjusted
up 50.744 50.661 1.0016 0.1446 0.8751
down 69.265 71.920 0.9636 0.0000 0.9636
left 62.733 59.897 1.0466 0.0512 0.9956
right 65.500 66.633 0.9833 0.0939 0.8988

The spread from best to worst is 8.6%, well under the 15% a challenger needed to beat an incumbent: no direction could ever win on score alone. Only fatigue could move the winner, and at gain 0.04 that took a run of four wins — so the readout was a slow four-step rotation through a fixed preference order, and down, the only direction that opens the front door, was last in it.

Where the preference order comes from

calibrate() runs once, at warm-up, and stores each role's rate as its baseline. At that instant every score is exactly 1 by construction and the four directions are exactly tied. The whole spread above is drift of the four command_* roles since that calibration, 43 brain minutes earlier, frozen into the score. It is not a claim about what the fly wants now; it is the sign of each role's slow drift, and it holds its shape for as long as the run does. That is why habituation was the only thing that could reorder the directions, and why fatigueGain and hysteresis — two numbers section 1 treated as secondary — turned out to decide whether the fly could leave a room.

The room

survey in the harness walks map 37 with real presses on throwaway emulators. 48 tiles are reachable and exactly six presses leave the map:

from press to
(6, 1) right 38, the bedroom
(7, 2) up 38, the bedroom
(2, 6) down 0, Pallet Town
(3, 6) down 0, Pallet Town
(2, 7) down 0, Pallet Town
(3, 7) down 0, Pallet Town

The live ledger held 32 of the 48; the 16 it had never stood on were (1,4), (1,5), (1,6), (2,1), (2,4), (2,5), (2,6), (2,7), (3,6), (3,7), (4,6), (5,5), (5,6), (6,4), (6,5) and (6,6) — the southern interior, and with it three of the four tiles the front door needs. So "71 unique locations (the whole floor)" was wrong in two ways: 71 is both floors, and neither floor was covered.

The two doormats are the exception that proves the mechanism. boundary:37:7:2:on and boundary:37:7:3:on were both paid, 0.10 each, at 355 s — the fly did stand on both mats. It walked onto them sideways along row 7, which does nothing, and the next direction it committed to was not down. (They are missing from the tile ledger because the main sample gate rejects wMovementFlags & 0xc7, which includes BIT_STANDING_ON_DOOR; the boundary rule keeps its own gate at 0xc0 precisely so the on-exit half can fire. Both behaviours are correct and unchanged.)

How the runs were made independent

Fifteen runs of ten brain minutes per candidate, unthrottled, the real NeuralAgent on data/fafb-v783 (139,255 neurons), the real PokemonRedReward with the checkpoint's own ledger imported, and the real Ratchet with the checkpoint's own budget — one thread each, fifteen at a time, on the 16-core WSL development box, about nine minutes of wall time per candidate.

A restore has no warm-up length to vary, because simloop.rs deliberately skips the warm-up for a checkpoint that already carries a settled network and a calibrated readout. So the runs differ only in LifState::rng, the xorshift state behind the per-tick noise kicks, displaced by the harness's own seed list. Everything else — membranes, refractory counters, learned gains, eligibility traces, the brain clock, the decoder's fatigue, holds and baseline, the emulator, the reward ledger — is the live fly's to the byte. Fifteen noise streams from one state is the same kind of variation fifteen warm-ups are, and on a restored run it is the only arbitrary quantity left.

Runs stop when the fly leaves the house. Leaving the map is not leaving the house: two of the six exits are the staircase, and the bedroom is a room the fly has also exhausted.

The baseline: what v0.1.0 does from here

Fifteen runs, ten brain minutes each, at holdMs 800 / fatigueGain 0.04 / hysteresis 1.15. Columns after the first four are the diagnosis: the share of decisions each direction won, the mean length of a same-direction run in decisions, the share of decisions where the fatigue-adjusted argmax lost to the incumbent under hysteresis, the share of holds over which the position never changed at all, the longest single motionless stretch, and the decisions taken while standing on a tile one press from leaving (over how many such decisions there were).

run left the map out of the house new tiles decisions up down left right mean run hysteresis blocked holds longest still exit decisions recoveries reward
1 28.7 s no 0 747 29% 17% 34% 20% 4.15 72% 481 (64%) 23.5 s 13/25 0 0.00
2 20.0 s no 0 747 31% 15% 33% 21% 4.22 73% 460 (62%) 21.7 s 14/41 0 0.00
3 6.0 s no 6 747 30% 18% 33% 20% 4.10 72% 475 (64%) 23.6 s 7/20 0 0.00
4 6.5 s no 0 747 30% 14% 33% 24% 4.17 73% 466 (62%) 8.2 s 14/14 0 0.00
5 22.7 s no 0 747 29% 17% 32% 22% 4.08 71% 462 (62%) 11.6 s 10/14 0 0.00
6 33.5 s no 8 747 30% 16% 32% 22% 4.13 72% 474 (63%) 34.5 s 11/28 0 0.05
7 13.0 s no 0 747 31% 16% 33% 21% 4.08 72% 473 (63%) 27.1 s 10/27 0 0.00
8 62.4 s no 0 747 29% 16% 32% 22% 4.20 73% 472 (63%) 7.2 s 8/21 0 0.00
9 48.1 s no 0 747 29% 16% 34% 22% 4.10 73% 470 (63%) 26.5 s 5/21 0 0.00
10 13.3 s no 4 747 30% 15% 32% 23% 4.10 73% 442 (59%) 11.7 s 11/39 0 0.00
11 46.5 s no 0 747 30% 15% 32% 23% 4.17 73% 483 (65%) 31.3 s 9/17 0 0.00
12 11.8 s no 0 747 30% 16% 33% 21% 4.17 72% 460 (62%) 8.0 s 13/30 0 0.00
13 11.7 s no 0 747 30% 15% 33% 21% 4.17 74% 469 (63%) 9.6 s 10/27 0 0.00
14 172.7 s no 4 747 31% 14% 32% 23% 4.20 73% 452 (61%) 17.2 s 9/27 0 0.00
15 6.9 s 573.5 s 5 714 30% 16% 32% 23% 4.13 73% 432 (61%) 9.1 s 12/23 0 0.00

1/15 out of the house, 0/15 inside five minutes. Every run left the map within three minutes and, except for run 15, every one of those exits was the staircase. Ten of the fifteen discovered not one new tile in ten minutes, and fourteen of the fifteen earned no reward at all. Frames spent on an exit tile, summed over the fifteen runs: the two staircase tiles 5,524 and 8,710, the two doormats 1,441 and 1,427, and the two tiles above the doormats 68 and 55 — 0.02% of the run.

59 to 65% of holds moved the player not one tile, for up to 34.5 seconds at a stretch.

The mean same-direction run was 4.1 decisions. At holdMs 800 that is 3.3 seconds of committed travel, and one overworld step is about 268 ms, so a run commits to about 12 tiles of straight walking. Red's ground floor is 8 tiles wide. This is the arithmetic section 1 got wrong: it doubled the hold from 400 to 800 ms, which was right, and halved fatigueGain from 0.08 to 0.04 at the same time, which doubled the run length in holds as well — so the committed distance went from about 6 tiles to about 12, and every run began ending in a wall. The random walker could not see it, because a uniform random source re-rolls a fresh direction at every decision and so builds no incumbency.

71 to 74% of decisions were settled by hysteresis, not by score, which is the 8.6% spread against the 15% margin, measured rather than argued.

The candidates

Fifteen runs of ten brain minutes each, all from generation 525, all measured the same way.

candidate holdMs fatigueGain hysteresis blockedFatigue out of the house inside 5 min median mean run hysteresis share blocked holds
v0.1.0 800 0.04 1.15 off 1/15 0/15 573.5 s 4.1 71-74% 59-65%
fatigue only 800 0.08 1.15 off 11/15 4/15 314.6 s 2.5 57-61% 25-45%
fatigue + cooldown 800 0.08 1.15 0.35 7/15 4/15 282.7 s 2.0 44-50% 30-39%
no commitment bonus 800 0.08 1.00 off 8/15 3/15 330.2 s 1.0 0% 31-54%
shorter hold 600 0.08 1.05 off 8/15 4/15 276.7 s 1.2 12-19% 41-55%
hysteresis only 800 0.08 1.05 off 13/15 6/15 312.3 s 1.2 12-20% 30-62%
chosen 800 0.08 1.05 0.35 14/15 13/15 130.3 s 1.2 11-21% 27-57%

The blocked-direction cooldown alone, at v0.1.0's fatigue and hysteresis (800 / 0.04 / 1.15 / 0.35), left the house in 0 of 5 runs and is not in the table for that reason.

Three things are worth reading off it.

  • The hysteresis is the lock, and 1.05 is the right amount to unlock. 1.00, no commitment bonus at all, is worse than 1.05 on every column (8/15 against 13/15): the hold is a commitment device and dropping the last of the margin wastes it. The mean run at 1.00 is exactly 1.0 — the incumbent never once survived a decision.
  • The shorter hold is not the answer. 600 ms at the same fatigue and hysteresis gets 8/15. The three-tile commitment an 800 ms hold buys is worth keeping; section 1 was right about that.
  • The cooldown's sign depends on the hysteresis. At 1.15 it makes things worse (7/15 against 11/15 without it) because a fly pushed off walls faster orbits the perimeter faster, and the staircase is on the perimeter. At 1.05 the fly turns inward instead and the cooldown stops each crossing being spent on a wall. Time on the two staircase tiles over the fifteen runs: 16,268 frames at 1.15 with the cooldown, 3,642 at 1.05 with it. Time on the two tiles above the doormats: 55 and 68 frames under v0.1.0, 482 and 367 under the chosen config.

The chosen config also gets the fly paid again — 0.05 to 0.10 per run against v0.1.0's 0.00 — and back to discovering 5 to 19 new tiles per run against 0.

From a fresh boot

Five runs each, real brain, cold warm-up, the same harness without FLY_ESCAPE_CHECKPOINT.

room out of the house inside 5 min median left the starting map
ground floor (map 37) 5/5 4/5 53.2 s 5/5, median 16.6 s
bedroom (map 38) 5/5 4/5 83.8 s 5/5, median 20.3 s

The bedroom row is the check against section 1's confirmation table, which measured leaving the bedroom and nothing else: 5/5 at a median 30.3 s under v0.1.0, 5/5 at a median 20.3 s here. No regression, and the fly now also leaves the building. A fresh fly earns 1.75 to 2.30 of reward in these runs, against the live fly's 0.00, because a cold ledger still has the operator's payouts in it.

The random walker, for scale

Twenty seeded walker runs per cell, both rooms, ten brain minutes, no brain. The walker cannot exercise the blocked-direction cooldown — nothing tells it whether it moved — so this covers the hold, the fatigue gain and the hysteresis only, and it is the bedroom-regression check section 1's tables were for.

room fatigueGain hysteresis left the map median out of the house median
bedroom 0.04 1.15 20/20 29.2 s 20/20 162.1 s
bedroom 0.08 1.05 20/20 36.0 s 20/20 122.2 s
ground floor 0.04 1.15 20/20 18.1 s 20/20 24.8 s
ground floor 0.08 1.05 20/20 12.4 s 20/20 25.8 s

Two readings. The bedroom does not regress: still 20/20, the median time to leave it moves by 7 seconds inside a table whose v0.1.0 spread was 22.6 to 43.7 seconds, and the time to get outside from the bedroom improves by a quarter. And the scale is worth stating plainly: a uniform random walker leaves the ground floor for Pallet Town in a median 25 seconds at either setting. The fly's best measured median is 130 seconds. The readout is no longer the thing stopping it, but it is still five times slower than choosing directions at random, and nothing here claims otherwise.

Reproducing

The checkpoint is not in the repository and never will be. Pull one read-only off the release box — read manifest.json first for the generation to ask for:

ssh the host 'pct exec <release-ctid> -- cat /srv/fly/state/manifest.json'
ssh the host 'pct pull <release-ctid> /srv/fly/state/<latest>.checkpoint /tmp/fly.checkpoint'
scp the host:/tmp/fly.checkpoint ./
ssh the host 'rm -f /tmp/fly.checkpoint'

Then, from services/flysim:

FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \
FLY_ESCAPE_CHECKPOINT=./fly.checkpoint FLY_ESCAPE_BRAIN=../../data/fafb-v783 \
FLY_ESCAPE_BRAIN_RUNS=15 FLY_ESCAPE_MINUTES=10 \
FLY_ESCAPE_BRAIN_THREADS=1 FLY_ESCAPE_BRAIN_PARALLEL=15 \
FLY_ESCAPE_CANDIDATES=800/0.08/0.35/1.05 \
  cargo run --release -p flysim --example room_escape

About nine minutes of wall time per candidate on a 16-core box. FLY_ESCAPE_CANDIDATES takes names from the harness's own list or bare hold/fatigue/blocked[/hysteresis] tuples, so a follow-up point needs no recompile. The fresh-boot half is the same binary without FLY_ESCAPE_CHECKPOINT:

FLY_ROM=... FLY_ESCAPE_SKIP_SWEEP=1 FLY_ESCAPE_BRAIN=../../data/fafb-v783 \
FLY_ESCAPE_BRAIN_ROOM=1F FLY_ESCAPE_BRAIN_RUNS=5 FLY_ESCAPE_MINUTES=10 \
FLY_ESCAPE_BRAIN_THREADS=15 FLY_ESCAPE_CANDIDATES=800/0.08/0.35/1.05 \
  cargo run --release -p flysim --example room_escape

which boots the cartridge, walks one random-walker run down the stairs, caches that state in room-escape-1f.state (gitignored) and runs the brain from it. The walker table is the same binary with neither variable and FLY_ESCAPE_HYSTERESES set. binjgb prints the cartridge header on every boot, so read the report through grep -v.

Honesty

This measures the readout, from one state, on one cartridge, with one connectome. Fifteen noise streams from a single checkpoint are fifteen samples of one initial condition, not fifteen independent flies, and a configuration that leaves a house faster has not learned anything — the whole reason to measure from the live state was to see the readout as the stream actually runs it, after the drift and the spent ledger a fresh boot does not have. The one thing here that is evidence about the fly is the last row of the walker table, and it says the fly is still slower than chance.