Skip to content

CHECKLIST

3ndetz edited this page Oct 2, 2026 · 1 revision

Source: docs/CHECKLIST.md

How to work here

One loop. Repeated forever, autonomously, with no pause between iterations.

AUDIT -> PLAN -> FIX -> TEST -> VIDEO -> ASSESS -> REPORT -> next iteration

No questions to the user between steps. He sets big goals; he does not adjudicate details.

Language rule: instructional text and code comments are written in ENGLISH (AGENTS.md).


0. Where we are actually going

END GOAL: @gamer plays the WHOLE game on tungsten, and baritone/shredder are deleted from the project.

Everything else is a step toward that. Work that does not move toward it is not needed.

Progress is measured by the courses — not by feelings, not by reports:

python deploy/runner/run_suite.py nav      # navigation, 10 courses
python deploy/runner/run_suite.py pvp      # combat

The score IS "how far we got".


0a. ⛔⛔ CHECKPOINTS: NEVER TEST A LATE-GAME PROBLEM FROM AN EMPTY INVENTORY (operator, 2026-09-16 and 2026-09-24)

The playthrough is the test, and a playthrough from zero costs minutes-to-hours to reach the stage where the trouble is. So:

  1. Every gamer_smoke.py run checkpoints densely by default -- the world every 5 minutes (last 4 kept per run) and rung-<rung> at every new ladder rung (rung-iron, rung-bucket, rung-diamond-pick, ... always the newest run to get there), plus nether-fresh on the first overworld->nether transition and last at the end. Do not pass --checkpoint-every 0 on a playthrough.

  2. A bug seen at minute N is reproduced from the checkpoint just before N, not from zero: python deploy/runner/checkpoint.py list, then python deploy/runner/gamer_smoke.py 15 --from NAME --record. The fix is tested from the same checkpoint, and the A/B is the same checkpoint twice.

  3. A fresh run from zero is for measuring the early game itself (first craft .. iron tools) or for producing new rung checkpoints -- nothing else.

  4. When a stage has no checkpoint yet, the run that first reaches it creates one automatically; if a stage needs a hand-made entry (e.g. "just inside a fortress"), save it with checkpoint.py save NAME --note "..." the moment the run is there.

  5. ⛔ DISK HYGIENE (operator, 2026-09-25: checkpoints filled ~190 GB and took the host disk to 98%). A checkpoint is LIGHT: seed and time (level.dat), the player (position, inventory, health, hunger), stats/advancements/data, the nether when small, and only the 3x3 region files around the bot -- the rest regenerates from the seed. ~20-120 MB, never the 2 GB world. The folder is capped at 15 GB (checkpoint.py drops the oldest periodic ones first after every save; last, nether-fresh, rung-ender are protected), and no save happens with under 40 GB free on the disk. Check python deploy/runner/checkpoint.py list (prints folder size and free disk) at the start of a session and after long runs; clean up recordings and artifacts you no longer need as well.

Machinery: deploy/runner/checkpoint.py (save / restore / list / budget / drop), gamer_smoke.py --from. Full history of the rule: section 4 item 7 ("Freeze the run where the trouble starts").


1. AUDIT — every iteration, before a single line of code

⛔ RULE FIVE: A COUNTER FROM ONE RUN IS A SAMPLE, NOT A MECHANISM.

Added 2026-08-08, and it is the subtle one. RULE FOUR is about claims nobody ever checked. This is about a claim that WAS checked — once — which feels like evidence and is not.

mob_skeleton read dte=2022/0/0/0/0/0: of the five guards on the engage gate, only inRange was ever false, on all 2022 evaluations. That was read as a property of the bot — "it never closes on a skeleton" — and a fix was built on it: hold SPRINT while approaching, since a walking bot cannot catch a mob that backs away at walking pace.

The fix measured WORSE (closest_gap 5.73 → 7.78, min_hp 5.0 → 2.0) and was reverted. Then a third run refuted the mechanism outright:

run A   dte=2022/0/0/0/0/0     gap 5.73    in range NEVER
run B   dte=1842/0/0/0/0/0     gap 7.78    in range NEVER   (with the sprint fix)
run C   dte=1903/37/0/0/0/57   gap 4.85    IN RANGE 37 TIMES

The counter was accurate every time. The inference from one sample was not, and the spread between runs was larger than the effect being chased.

The rule this repo already had, applied to counters as well as verdicts: one good run is not a result. A rate is 5–6 runs. That applies to the numbers you diagnose from, not only to the pass or fail you report — because a diagnosis is a claim about the bot, and a single run cannot support one on a stand whose frame rate moves between 9 and 15.

Second example, 2026-08-09, because knowing the rule is not the same as obeying it. A ballistics fix was reported as moving bow_flee from hits=0 avg_dist=6.66 to hits=2 avg_dist=11.35. One run, at 5.9 fps. The repeat on a healthy stand: hits=0 avg_dist=6.07. The physics was right and the attribution was invented — and the tell was in the report itself, which said in the same breath that the change had no mechanism by which it could move a DISTANCE gate at all. When you cannot name the mechanism connecting your change to the number that moved, the number is noise until a repeat says otherwise. Say so instead of publishing it.

⛔ RULE SEVEN: A BROKEN STAND OUTLIVES THE RUN THAT BROKE IT, AND LOOKS EXACTLY LIKE A BROKEN BOT.

ensure_grounded lifts a bot out of the void with gamemode spectator → tp → gamemode survival. A scenario aborted between those lines. tester2 stayed a SPECTATOR for the rest of the suite, through a rebuild, through a redeploy, and into two later runs — where every course reported at once:

melee_basic  swings=0 crits=0 damage=0.0      bow_flee  bowShots=0 avg_dist=0.0 hits=0
chase_flat   contact=None freezes=13          ranged_moving hits=0

A spectator cannot be hit, takes no damage and has no collision, so it stands inside the bot. That profile is indistinguishable from a dead mod, and a build plus two runs went into hunting one — client logs read for a stack trace, the last code change suspected, the format string suspected.

The tell was dist=0.0 in EVERY sample of a fight whose victim never lost a hit point: not "the bot cannot reach it" but "the bench thinks they are in the same block". One command settles it — data get entity <name> playerGameType — and it is worth running the moment a whole suite goes to zero at once.

Two habits follow, both now in the harness:

  • state a run changes, it restores in a finally — teardown cannot run when SETUP is what threw;
  • normalise at the START of each course, not only at the end of the previous one. That bounds any leak of this shape to a single course instead of a night.

And when the whole suite fails at once, suspect the stand before the bot. Bots regress on one course. Stands take out every course simultaneously.

⛔ RULE SIX: A COURSE THAT HANDS THE BOT THE FINISHED STATE CANNOT TEST HOW THE BOT REACHES IT.

Added 2026-08-08. The most expensive kind of blind spot, because every gate stays GREEN.

Every pvp and mob course kits the bot with

item replace entity {name} weapon.mainhand with iron_sword

The weapon starts already in the hand. So the entire "choose a weapon and equip it" path was invisible to the bench — and it was completely dead on 1.21.11 for as long as the port has existed. KillAura.equipWeapon's whole body sat inside a //#if MC < 12111 branch, and bestWeapon returned whatever was already held instead of scanning the pack. Twelve pvp courses and three mob courses ran against that for weeks and none of them could see it.

The reason nothing rang: the force field wins bare-handed. Measured both ways on the same arena, same host, only the Java differing:

pre-fix  jar   held_during_run=[]               the zombie still DIED
post-fix jar   held_during_run=['iron_sword']   the zombie still DIED

Every outcome gate above the missing capability — "the zombie is dead", "won the exchange" — stayed green while the bot fought with its fists. A capability can be entirely absent and every gate above it still pass, which is why "the suite is green" is not the same as "the suite covers it".

The test that finds this asks for the STEP, not the outcome: empty hand, sword in inventory.0 where it must be found rather than selected, and the gate is "is the sword in the hand". Two further properties are worth copying:

  • It is an inventory outcome, so it survives a starved host. Both runs above sat at 5–6 fps against a floor of 14 and both gave a clean verdict. Same reason the craft ladder judges the item in the pack rather than the seconds it took.
  • It guards against lying to itself. "The bot started EMPTY-HANDED" is its own gate, so a kit that silently failed — or a sword inherited from the previous run's hotbar — cannot make "it armed itself" pass without the bot doing anything.

Before trusting a green suite on a capability, ask what the kit or the arena already GIVES the bot. Whatever is handed over is not being tested, however many courses pass.

⛔ RULE FOUR: A REMOVAL MAY DECLARE A DEBT. IT MAY NOT ASSERT AN UNCHECKED FACT.

Added 2026-08-08, after it cost six days on one course.

InteractWithBlockTask's out-of-reach branch was reduced to setDebugState("Getting to our goal") and a timer reset. No goal, no movement. The bot stood dist=28.0 from a crafting table for five minutes announcing it was on its way. The comment justifying the removal said the legacy engine does not drive the body — TRUE — and that "something else does the walking" — FALSE. Nothing else did.

The distinction that matters, because two other removals in the same pass were FINE:

  • TimeoutWanderTask keeps a dead-engine explore() call and says why: an A/B odometer showed the live-engine replacement covered LESS ground (wanderMoved 42.6 vs 24.6). A measurement.
  • AbstractDoToEntityTask drops the step-away and says "NOT replaced, deliberately", with a reason and a passing gate. A declared debt.
  • InteractWithBlockTask asserted a checkable claim about the running system and never checked it.

The first two are honest. The third reads like reassurance and rots silently, because nothing fails loudly — the task still returns, the chain still ticks, the debug string still says something plausible. All 15 files of that pass were swept afterwards; this was the only one, but one was enough.

When a cleanup comment contains a claim of the form "X still happens elsewhere", that claim is a TEST YOU OWE, not a note. Either measure it in the same pass, or write "unmeasured" and gate it.

⛔ RULE THREE: gradlew build DOES NOT DEPLOY. AFTER A BUILD, RUN deploy/deploy_jar.sh.

Added 2026-08-08. Building writes a jar into versions/1.21.11/build/libs. The containers run whatever deploy/deploy_jar.sh last copied into deploy/run/mods — a SEPARATE step. Build without deploy and the bench measures the PREVIOUS code while you read the numbers as if they were the new code.

It cost a wrong conclusion in the BlockScanner work. A fix was built, the course was run twice, and the station lookup dropped from 510 hits to 0 and then 0 again — read as "my change made it strictly worse", which nearly got it reverted. Both runs were the OLD jar, and the 510-vs-0 spread was this course's ordinary run-to-run variance. Deployed, the same change took the hit rate to 6034/6034.

Two ways to not get caught: deploy in the same command as the build, and when a result is surprising, confirm the binary under test contains the change before believing anything it says.

⛔ RULE TWO: AN IDLE BOT RUNS NOTHING. A COURSE MUST GIVE IT A TASK BEFORE MEASURING ANYTHING.

Added 2026-08-08, after it produced a fully corroborated finding that was completely wrong. TaskRunner.tick() opens with if (!active) return;, and active stays false until a USER TASK starts. So a bot standing idle ticks NO chains at all — not survival, not mob defence, not food.

The escape_lava course issued no command, dropped the bot in lava, and watched it do nothing for ninety seconds. Three independent witnesses agreed: the block read back as lava through py4j, 24 timeline samples showed it never moving, health and death counters showed it alive and stable. The conclusion — "lava escape is a dead survival path" — was published, and it was false. A counter on the FIRST LINE of WorldSurvivalChain.getPriority() read zero: the method was never called, because the runner was switched off.

With a harmless @get oak_log 1 ordered first, the same course reads surv=145/145, lavaEsc=101, and the bot escapes — into WATER six blocks south rather than dry ground one step east, which is the behaviour the old heuristic's -100 water term was there to produce.

Agreement between witnesses is not truth when they share a hidden assumption. All three were answering "what does an idle bot do", and none of them could see that the question was wrong.


⛔ RULE ONE: WHEN PLAUSIBLE FIXES KEEP SCORING THE SAME, THE INPUT IS LYING — INSTRUMENT IT.

Added 2026-08-08, after it cost most of an evening. TODOS #37 took FOUR consecutive fixes, each made by careful reading of a real mechanism, and each measured EXACTLY the same course score (2/4). All four were reverted. What broke it was giving up on fixes for three runs and putting a counter on the INPUT instead: the block filter reported cb=0/18456/0/0 on its first run — every candidate refused by a fifty-block no-break ban that one unreachable log had triggered. Reading had blamed four different links and been wrong every time.

The tell is not "the fix failed". It is identical numbers across different fixes. Different causes produce different failures; the same number four times means none of them was the cause, and the thing feeding the code is what needs measuring.

Two corollaries, both paid for the same day:

  • A counter at zero is ambiguous until you know it CAN be non-zero. recipesKnown=0 meant "nobody asked" (trackers rebuild lazily), not "the port failed"; toolSwap=0 meant "the branch never fired", so a green suite proved nothing about it.
  • Poll a per-run counter and you will lose the window: chop_canopy finishes in fifteen seconds when the bot ignores the bait, so a fifteen-second sampler read 0 for runs where the branch DID fire. Print it in the verdict instead.

RULE ONE HAS A MIRROR IMAGE, and it cost a fix that was probably correct. A disconnected SENSOR reports nothing happening. A disconnected ACTUATOR also reports nothing happening, and looks exactly like a refuted hypothesis.

Two of these on one evening, 2026-08-09:

  • dmgTaken=0.0 hits=0/0/0/0 on a course where the bot died five times. altoclef's damage tracker hangs off MobDefenseChain.getPriority(), and on courses the agent drives with tungsten primitives that chain never ticks — mdCalls=0. The number meant "nothing is measuring", not "nothing hit the bot".
  • A break-contact fallback that drove the movement keys away during a path search measured 22 hits against 23, i.e. nothing — and was reverted as refuted. It had never run. RunAwayTask ticks BEFORE MovementQueue/BlockPathWalker, which release every key and press their own. The rule is spelled out in that very tick file: "ONE OWNER OF THE TICK ... a SECOND per-tick writer does not merely conflict, it silently wins half the ticks" — pitfall P1, with its own measured failure (called=11041 inRange=11040 clicked=0) already recorded next to it.

So before filing a behaviour change as refuted, prove it EXECUTED: a counter on the branch, a visible effect in the trace, anything that distinguishes "did nothing" from "did not run". A fix that never reached the game has not been tested, and burying it as a dead end is worse than never having tried it — the next session inherits the wrong conclusion.

And the same trap on the other side: REPRODUCE THE FAILURE IN ITS OWN CONTEXT BEFORE COMPARING VERSIONS. nav_ladder failed twice in a full nav sweep (self_falls=2), so the night's shared-code edits went on trial. Pre-tonight sources passed, which looked like confirmation, and a five-step bisect followed — every commit passing, until only one remained whose diff could not touch a nav course. The suspect by then was the method, and the control proved it: CURRENT HEAD, run the same isolated way, PASSES. Every version passes alone; the failure exists only at position nine of a twelve-course suite on a warmed, loaded stand.

All five bisect steps had been comparing isolated-vs-in-suite, never commit-vs-commit. One control run — the one skipped at the start — settled what an hour of bisection could not. If a failure appeared in a suite, the A/B has to run in that suite too; otherwise the thing being varied is the harness, not the code.


⛔ RULE ZERO: BEFORE BELIEVING A RED, ASK WHETHER THE MACHINE WAS THERE.

Added 2026-08-06, after most of an evening's readings turned out to be about the host. Another project's containers took 250-450% of this machine's CPU for hours; the stand ran at 10 fps; four sweeps in a row said "nothing reached" against 3/3 from the last quiet sample. Nothing in the output distinguished that from a code regression, and a whole pass nearly went to bisecting my own commits.

docker stats --no-stream answers it in one second. Both benches now answer it themselves — run_suite counts a starved run INVALID (never PASS: it must be re-run, not counted), and gamer_smoke samples the client's fps every poll and stands down below the healthy line. If a run is INVALID, it measured the MACHINE. Do not read it as a regression, do not revert on it, and do not ship a behaviour change measured against it.

The mirror rule holds too: a behaviour change that CANNOT be measured today does not get shipped today. Park it with its patch, its blast radius and its reason written at the site, and bench it first thing when the machine is quiet. There is one parked right now (TODOS G-0.3).

⛔ THE RULE: before fixing anything, PROVE BY EXPERIMENT that the code runs.

Breaking this rule on 2026-07-27 cost a whole session: a search engine was reworked while a different one drives the bot. It is cheap to check — pin a setting (;settings <flag> false) and look for the engine's fingerprint in the log (fingerprint table: NAVIGATION.md).

Then:

  • Read the CODE, not the comments or the docs — both have been wrong repeatedly.
  • Every claim carries a file:line you actually opened. Cannot cite it? Write "unverified".
  • Distinguish: runs by default / gated off / dead / missing. A dead flag is not a detail, it is a missing feature — that was the root cause four times in a row.

1b. READ UPSTREAM FIRST — you are almost certainly re-deriving something (user, 2026-07-30)

⛔ BEFORE writing a mechanism, OPEN THE ONE THAT ALREADY EXISTS. baritone/ is in this repo as source reference precisely so its logic can be COPIED rather than rediscovered. Also check TODOS.md — the register frequently already names the bug you are about to hunt.

Three questions, answered with file:line, before any new mechanism:

  1. Does baritone already do this? Where, and what exactly does it do differently?
  2. Am I walking its path of mistakes from scratch? If the answer is "probably", stop and port instead. Copying working logic is not cheating — it is the job.
  3. Does TODOS.md already name this? If yes, the entry has the diagnosis; read it first.

Evidence that this rule was earned, all from 2026-07-29:

  • Placement was hand-rolled. MovementTraverse.cost prefers a SIDE place and only falls back to a backplace at SNEAK_ONE_BLOCK_COST, and updateState holds Input.SNEAK and clicks only once isInSneakingPose(). Tungsten implemented the backplace alone, without sneaking, so the bot slid off the lip it was paving and fell into the void — 20.7 blocks short, twice in a row, for a reason upstream solved years ago.
  • canPlaceAgainst accepts normal cubes and glass only. Tungsten accepted "any non-empty collision shape", which is not the same question.
  • A whole session went into finding that the search burns its budget writing chat from the inner loop. TODOS.md already carried it: C4.4 "Search threads write to Minecraft chat directly from background threads." The diagnosis was sitting in the register.

The ponytail skill (installed) is the same instinct as a standing rule: stop at the first rung that holds, reach for what exists, and do not build what you can borrow. ponytail-audit scans the repo for exactly the over-building this rule is meant to prevent.

2. PLAN

  • One iteration = one root cause. Fixing five things at once tells you nothing about which worked.
  • Fix in the CORE. Band-aids, hardcode and reactive timeouts are banned.
  • Never keep a duplicate engine "just in case" — that is exactly how four pathfinders appeared.

3. FIX

  • Build: .\gradlew.bat :1.21.11:build -x check (via PowerShell).
  • Deploy ONLY with sh deploy/deploy_jar.sh. Never copy by hand: the previous jar stays next to the new one, Fabric loads the wrong one, and a whole before/after comparison runs against old code. This already happened.

4. TEST

  • Run the target course AND the three green baselines (nav_flat, nav_staircase, nav_descend) — otherwise you will not see a regression.
  • --only takes a comma-separated LIST, so that is one invocation, not five. The stand is rebuilt per invocation, and courses run in the order you ask for — put the fixed one first.
  • One good run is not a result. Run 5-6 times and report the rate (4/6). Flaky is RED, not "mostly works".
  • Diagnose a failure from that run's log, never by guessing.

Launch long runs with the tool's own background mode, never nohup … & in a subshell. A backgrounded shell job is killed when the tool call returns: the suite dies seconds in and leaves an artifacts directory containing one empty timeline.jsonl. That looks exactly like a course that crashed on entry, and it will be diagnosed as a bot bug. Symptom to recognise: the log stops right after the --- <course> --- banner and the process is already gone.

4a. ⛔ ON THE PLAYTHROUGH, PAIR THE ARMS ON THE SAME GROUND (2026-08-20)

gamer_smoke starts every run 300 blocks further along a square spiral, and the ground decides the metric -- its own comment records "first log in 21 seconds on one patch, never on another, same build". So interleaving arms by RUN INDEX gives arm A every even spiral point and arm B every odd one: two disjoint fixed sets of terrain, permanently, which is the opposite of what rule 4r interleaving is for.

It is visible without any flag. One series' CONTROL ARM alone scored rungs 4, 1, 4, 1 with nothing alternating but the spiral. This is very probably why two sweeps of the same flag disagreed at n=6 and n=8, and it makes the conclusion drawn there ("eight per arm is not enough") wrong in its diagnosis: no amount of n fixes a design where the arms sample different populations.

Every --pin-alt result on the playthrough taken before 2026-08-20 is UNMEASURED, not refuted. Re-measure the ones current work depends on.

Fixed in the harness: with --pin-alt live the first arm of a pair saves its RESOLVED spawn and the second teleports to it. Holding the spiral index alone is not enough -- the treeless-start skip walks it forward by however much the ground requires, and the arms still landed five steps apart. Score with deploy/runner/paired_ab.py.

⛔ AND DO NOT "FIX" THE VARIANCE BY PINNING THE START. It is the obvious next idea and the harness refutes it: the world is never wiped, the bot fells the trees around wherever it starts, and a fixed point gets harder every run -- 21.5s to first log on untouched ground against 300-585 seconds, or never, at a spot the bot had been working all session. That decay was read as code regressions for hours. The spiral exists to prevent it.

4a1. A PAIR WHERE THE MECHANISM NEVER FIRED MEASURED NOTHING (2026-08-20)

Print a mechanism counter with every flag and check it in BOTH arms:

  • control arm must read 0 -- otherwise the flag is not what differs (rule 4u);
  • fix arm must read non-zero -- otherwise that pair says nothing about the flag, however large its outcome delta.

Three of six pairs in one series had the counter at 0 in the FIX arm, including the pair whose -3 dragged the mean. Report those pairs, do not delete them, and say which reading is which: as-run over all pairs first, mechanism-fired second, with the caveat that conditioning after the fact can bias.

4a2. FOR A STALL FIX, THE OUTCOME IS STALL TIME, NOT RUNGS (2026-08-20)

Rungs are 0-5 with a pair spread near 3, so they cannot resolve anything smaller than a whole rung without dozens of pairs. Seconds the body spent not moving is continuous and is the quantity a stall fix actually changes. On identical runs it read t=-1.51 where rungs managed +0.93. paired_ab.py reports both; quote the one that matches what the change does.

4a3. ⛔ THE BOT EATS THE COURSE IT IS BEING GRADED ON (2026-08-22)

Rule 4a says pair the arms on the same ground. That is right and it is not sufficient, because the world PERSISTS between runs and the bot's whole job is to change it.

Found on a terrain repro that reproduced a stall reliably -- until it did not. The stall is a NOTCH: two solid corners at the body's level with the route threading between them. The bot's task at that moment is to mine wood, and over a few dozen runs it simply dug the notch away:

cornerA   (85,125,-55)   grass_block  ->  air
cornerB   (84,125,-54)   dirt         ->  air
underDest (84,124,-55)   grass_block  ->  air

After that the wedge stopped happening in BOTH arms, and the tool measured nothing while looking exactly like a fix -- five runs of five walked away cleanly with the flag on, and so did the control.

The bias is not random, it is directional. Arms run in sequence, so the SECOND arm of every pair always inherits the ground the first arm degraded. On the playthrough that is literal: the pair reuses the resolved spawn, and the course grades on "log blocks within 40" -- which the control arm spent five minutes chopping down.

What to do.

  • For an arena course: rebuild the geometry every run. That is what the nav_* courses already do and it is why they do not suffer from this.
  • For a persistent world: RESTORE the cells the test depends on before each run. repro_stall.py --restore <file.json> takes {"x,y,z": "minecraft:block"} and sets each one.
  • For the playthrough, where restoring a forest is not practical: MEASURE the erosion and say so. gamer_smoke now records the wood count when it saves a pair's ground and re-reads it for the second arm, printing pair wood: arm1 N -> arm2 M and declaring the pair UNMEASURED when more than a quarter is gone.

And treat past results accordingly. Every paired playthrough A/B taken before this was run with the alt arm on poorer ground than its control. That does not flip a result by itself, but it is a systematic effect pointing one way, and any narrow margin measured that way is not safe.

4b. MEASURE THE SPREAD BEFORE YOU COMPARE MEANS (2026-08-10)

A metric you have not characterised cannot support a before/after claim, and most bench metrics here are noisier than the effects being chased.

Worked example, from the session that produced this rule: bow_flee death counts came back 10, 4, 5, 6 on four runs with the same jar, the same criteria and healthy fps. On that course a "10 -> 4" improvement is one draw from each tail of an uncharacterised distribution. Three separate claims were built on such pairs that night and all three had to be retracted -- including one that had been recorded as "REPLICATED".

The rule:

  1. Before quoting any delta, run the metric n >= 8 under fixed conditions and record its median and range, not its mean.
  2. Treat any difference smaller than that range as unproven. Say "not distinguishable from run-to-run variation", never "improved".
  3. A single run can still refute (one counter-example kills a universal), and a counter can still be exact (rimBack=0, self=0, an import count). Asymmetry is fine: cheap evidence for "this is impossible", expensive evidence for "this is better".
  4. Prefer a metric with no spread when one exists. Mechanisms read from source, and counts that are zero or not-zero, do not need eight runs.

The failure this prevents is not carelessness -- it is comparing point estimates while feeling rigorous, which is exactly how a whole evening of measurements ends up unable to carry its own conclusions.

4c. TWO RULES THAT COST MORE THAN THE VARIANCE ONE (2026-08-10)

Read the CLOSED tasks before you instrument. The largest defect of that session --- a flee objective that ran itself into a corner and stood there --- was already written down, in one sentence, in a task marked completed: "seeks corners --- furthest from the threat has no continuation" (G-1.66). Finding it again from the outside cost thirteen refuted hypotheses and six reverted fixes. Closed does not mean absent; it often means described and not yet fixed. Grep the task list for the subsystem before building a single counter.

Measure across the population, not inside the condition. Every wrong number that session --- nine corrections to figures I had published --- was the same error: a statistic taken over the ticks that already satisfied the thing being studied. "Sprint 3-5%" was measured inside melee range, where sprint fails by construction; over the whole flee it was 24%. A subsample selected by the condition under study is not a measurement of the population, and it will read as evidence.

Two corollaries, both earned the hard way:

  • Count the denominator, never assume it. "538 of 1200 ticks" was a deficit invented by assuming 60 seconds at 20 tps. The player entity does not tick while dead, and that course kills the bot five times a run. Count it.
  • A duplicated constant will drift. The same reach band lived in the mod and the bench and diverged twice in one evening, each time because one side was corrected. Care at the moment of editing prevented neither. Have one side READ the other.

4d. READ THE FRAME RATE OF BOTH SIDES BEFORE COMPARING THEM (2026-08-10)

The suite marks a run INVALID when it is starved and something load-sensitive failed. A run that happens to pass while starved is not marked at all — so it never triggered a client refresh, and every later run in that series inherited the degradation.

What that cost, the day this rule was written: a --repeat 8 comparison of narrow_bridge_duel returned +1, +4, +1, +2, −3 against a baseline of −3, −6. It was the strongest-looking result of the session and it was worthless — every run of it was taken at 10.0 fps while the baseline had been recorded at 29.3 and 18.1. The summary printed clean passes throughout. melee_basic in the same series held 27.9–29.6 for six runs and then fell to 9.9, which is the shape to remember: the wear arrives mid-series, precisely when a before/after is least able to notice it.

The rule:

  1. A verdict and a comparison have different requirements. A starved run's PASS is real — it passed. It still cannot be set against a baseline taken at three times the frame rate.
  2. Print and check avg_fps for the baseline runs and the after runs. If they differ by more than a few fps, you are comparing stands, not builds. The runner now marks these [starved — not comparable] in the SUMMARY, but read the number yourself.
  3. This is retrospective. Any earlier comparison in this repo where one side ran starved and passed carries the same defect and was equally unflagged. Before quoting an old before/after, check whether its fps were recorded at all.

The general form is rule 4c's: a statistic taken under conditions selected by something other than the thing you are studying. Here the selector was the clock rather than the code.

4h. AN EVENT-TERMINATED COURSE MEASURES THE PHASE BEFORE THE EVENT, NOT THE SCENARIO (2026-08-10)

early_stop ends a run when something happens. Then the run is not the course — it is the stretch up to that event, and the composition of that stretch can have nothing in common with the scenario the course is named after.

allround is duration = 120 with early_stop: kills >= 1. Every run it has ever produced lasted about twenty seconds, and those twenty seconds are the APPROACH phase. Measured over them, the bot looked as though it barely fought: chasing dominated, the head pointed away from the opponent 63% of the time, the swing gate was consulted 58 times. Run to its declared length the same build gives:

truncated (~20 s) full 120 s
kills / deaths 1 / 1 5 / 6
fps samples 3 11
follow task active 149–164 ticks active 0
aim at enemy 31 155 (71%)
forced-narrow pin fires never fires

Seven mechanisms were proposed for the "defect" over one session and every one of them was an artefact of the window. Nothing was scaled — the composition changed, because the phase being sampled changed.

The rule:

  1. Before quoting any per-run figure, check early_stop. If it ends on an event, ask which PHASE the run actually covered.
  2. A gate that ACCUMULATES (deaths, damage taken, time survived) must not be judged over an event-terminated window. bot deaths <= 0 over "the sprint to a first kill" is a different criterion from the same words over a two-minute fight, and the short one is easier.
  3. samples=N in the fps line is the cheapest tell. Three samples on a two-minute course means the course did not run for two minutes.
  4. melee_basic already carried this fix, with the reasoning written out, while three other courses kept the naive line. A fix applied to one course is not applied to the suite — grep for the pattern.

⭐ FOLLOWED UP 2026-08-15, AND POINT 4 NOW OVER-REACHES. allround has since been fixed and says so at the site. Two courses still carry the bare return ctx.kills() >= 1, and they should: the test is not "does it stop on an event" but whether the gate ACCUMULATES past that event.

  • allround — the kill was INCIDENTAL to a 120-second fight, so stopping at it truncated the very thing being measured. Fixed, correctly.
  • the chase course (ran >= 30 blocks) and bridge_assault (crossed, kill <= 120s, self-falls == 0) — the kill IS the objective. The run is COMPLETE when it lands, and running on would measure post-objective wandering rather than the course.

So grepping for the pattern and "fixing" all of them would slow two courses and change what they mean. Grep for the pattern, then ask of each hit: does anything I gate on keep accumulating after the stopping event? If not, the naive line is the right line.

4g. A CHANGE THAT MAKES A RARE PATH COMMON PUTS UNTESTED CODE ON THE HOT PATH (2026-08-10)

Rare code is unverified because it is rare. Raise its frequency and its faults surface immediately — and they surface in the new feature's name, not the old code's.

One guard, added in a single session, produced three defects and none of them was in its own logic. All three were in paths it had merely made frequent:

what broke the path it made common
UnicodeEncodeError killed an 8-run series at run 5 a warning line that had never once been printed
a starved verdict recorded unmarked the flake-retry result, which the check had never inspected
an automatic refresh redeploys the CURRENT build deploy_jar.sh firing mid-series instead of rarely

The guard fired on INVALID before — rare, and wherever it landed. Now it fires on any starved run, which is often and mid-series.

The rule: when a change increases how often something runs, the review is of that something, not of the change's own condition. Ask what now executes ten times a suite that used to execute once a week, and read that code. Its comments, its failure modes, and what it assumes about when it runs.

4f. ⛔ CHECK THAT YOUR REGRESSION GUARD IS NOT A MIRROR (2026-08-10)

Scenario.victim_settings defaults to {}. A duel that does not override it gives the OPPONENT the same build, the same settings and the same kit as the bot — so a symmetric engine change is handed to both sides and cancels. Expected margin 0 by construction; the pass rate is a coin flip, ties included.

melee_basic and narrow_bridge_duel pin the victim to combatReachControl=false for exactly this reason, and TungstenConfig states it outright: "melee_basic is a MIRROR duel — both fighters are this same jar with the same kit — so shipping a combat change on by default hands it to the opponent too and the course stays a coin flip." edge_duel was never given that pin.

What that cost, the day this rule was written: edge_duel was chosen as the primary regression guard for a combat change, run at n=8, and returned median −1.5 with 3 passes in 8 against a 4/4 baseline. That was read as a confirmed regression, a fix was designed and shipped in response, and none of it was measurable: the course cannot see a symmetric change at all. The 4/4 was luck (~2% at that pass rate, which is unlikely and not impossible), and 3/8 is a coin flip.

The rule:

  1. Before using a course to guard against regression, check victim_settings. If the opponent runs your change too, the course measures nothing about it — whatever it prints.
  2. The knowledge existing SOMEWHERE does not protect you. This was documented in the config's own javadoc, quoted earlier the same session, and still not applied to the choice of guard. Check the scenario, not your memory of the doctrine.
  3. A course can be a perfectly good GATE and a useless GUARD. edge_duel still tests that both fighters keep their footing; it just cannot attribute a difference to your build.

4e. ONE CONDITION CAN HOLD SEVERAL BEHAVIOURS — DELETING IT DELETES ALL OF THEM (2026-08-10)

A branch is not one decision. if (wounded && out of reach) held two: stepping OUT to a range where a bowless bot can do nothing (unjustified — the comment above it argued from a bow the kit did not carry), and declining to walk INTO a fight it was losing (perfectly sound). Removing the branch removed both, and the second one was doing all the work on the course where the first was impossible: on a 5×5 platform over void dirSafe(back) is false near the rim, so there had never been a retreat there at all.

The bill: melee_basic improved and edge_duel went from 4/4 to 3 passes in 8, median −1.5.

The rule:

  1. Before deleting or gating a condition, enumerate every behaviour it currently produces — read the function it calls, do not infer from the condition's name or its comment. The comment states the author's intent; the callee states the effect.
  2. Check each behaviour against a course where the OTHERS cannot operate. Here the retreat was impossible on edge_duel by terrain, which is exactly what made that course able to isolate the hold — and exactly why measuring only melee_basic certified the change.
  3. When they turn out to be separable, separate them in the code rather than choosing one. The fix was a flag suppressing the forward press, NOT the callee's early return — that return also skipped the strafe, the crit hop and the trigger, and the other course's gain depended on those.
  4. Re-measure every course the split touches, not the one being repaired. Winning one back while quietly losing the other is invisible from a single-course measurement.

Related in shape to 4c: a conclusion drawn from a sample selected by something other than the thing being studied. Here the sample was the courses on which the surviving behaviour happened to matter.

4m. ⛔ A COUNT OF ZERO FROM A RUN THAT NEVER RAN LOOKS EXACTLY LIKE A CLEAN RESULT (2026-08-12)

Caught three times in one session, twice within an hour, and each time only because the run COUNT was checked alongside the thing being counted.

python run_suite.py mob > out.txt ; grep -c "LEFT THE ARENA" out.txt   ->  0     "no falls!"

The suite had refused to start — REFUSING TO START: another suite holds the bench — because a previous invocation still held the lock, or because a killed foreground run left the lock stale. Zero falls, zero failures, zero everything: a perfect result from a suite that never executed.

Same silhouette as the two worst defects in this project: a gate whose awake half could never fail, and a dodge whose keys never reached the game. Zero from "it went well" is indistinguishable from zero from "nothing ran" unless you ask separately.

The rule:

  1. Never read a count without reading the denominator. grep -cE "^ => " for the number of course verdicts, and refuse to interpret anything if it is 0.
  2. A killed foreground run (10-minute tool timeout) leaves the lock behind. The lock names the invocation that holds it — cat it before clearing, and if it names YOUR dead run, it is stale.
  3. This is why --repeat N results should be reported as k/N, never as a bare count: N makes the denominator impossible to omit.

4k1. ⛔ "IN A WORLD" IS NOT "IN THE RIGHT WORLD" (2026-08-22)

Rule 4k asks whether the bot is in the arena. This is its twin, and it cost four arms of an A/B before the coordinates gave it away.

repro_stall.py connected to the gamer server only if not inGame. After any suite run the client IS in a game -- it sits on the flat test arena at y=-60 -- so the connect was skipped. The teleport that follows goes over rcon to the gamer server, which the bot is not on, so it does nothing at all and returns no error. The repro then ran its full fifty seconds on featureless flat ground, and every counter was honestly zero:

holeGate=false   end pos 26.4,-60.0,-2.4    walkerHoleHeld=0
holeGate=true    end pos 26.3,-60.0,-2.3    walkerHoleHeld=0

Four arms read as "the gate never fires". The gate was fine; there were no holes, because there was no terrain. y=-60 is the arena floor, and that is the tell.

The rule. A tool that needs a particular world must CONNECT UNCONDITIONALLY, and then verify by RESULT rather than by belief: teleport, read the position back, and compare. gamer_smoke.py already carried this lesson in a comment -- "inGame says the bot is in A world, not that it is in the GAMER one" -- and the repro was written later without it.

Two traps inside the verification itself, both hit on the way:

  • Compare HORIZONTALLY. Two seconds after landing at 74,127,-54 the bot has already fallen seven blocks, because the spot is above ground. A 3D tolerance rejected a teleport that had worked and sent the run into a reconnect loop. X and Z do not lie about which world it is in.
  • Know the shape of what you are reading. The gs op does str(dict(...)), so self is ALWAYS text that merely looks like a dict. .get("pos") on it raised AttributeError; the isinstance guard that replaced it could never be true and reported "did not land" for every run, which looks exactly like a stand fault.

4k. ⛔ CHECK THE BOT IS IN THE ARENA BEFORE READING ANY NUMBER FROM A RUN (2026-08-12)

The most expensive failure of this project so far, and the user caught it from a VIDEO while the numbers were being analysed as if they meant something.

The bot was falling into the void and dying, and every counter kept reporting. Timeline Y over four recorded runs:

healthy runs   minY = -60.0     (STAND_Y, the arena floor)
broken runs    minY = -234.2, -229.2, -246.5, -180.5

Nothing in the SUMMARY said so. The course printed PASS/FAIL, dmgTaken counted, min_hp counted, dw counted — all of it describing a bot 170 blocks below the world it was supposed to be fighting in. Two clips were sent to the operator as evidence of combat behaviour; they showed a bot dropping into nothing.

The tell that WAS in the data, unread: dw gapMax near 200 on a 14-block arena. DamageWatch measures the distance to the nearest living entity when a hit lands, so ~200 means there is nothing near the bot at all. It was visible for hours and read as "the stand degrades" instead of "the bot is not in the arena".

The rule:

  1. Before believing any verdict, check min(bot Y) against STAND_Y. One line over timeline.jsonl. If the bot left the arena, the run measured falling, not fighting — the verdict is void, not merely suspicious.
  2. A fall CASCADES and outlives its run. Later runs opened with the bot ALREADY at -234 (min == max, never near the floor): ensure_grounded did not recover it, so every subsequent run in that series was measuring a corpse in the void. Same shape as RULE SEVEN's spectator leak, worse because the counters look plausible.
  3. A criterion that cannot see this is not a criterion. The gate asked "is the skeleton dead" and "how much health was lost" — both answerable, wrongly, by a bot that fell. Add the arena check to the SUITE, not to one course.
  4. Watch the clip before quoting the run. The video showed it instantly. Numbers from a bench are a model of the run; the recording is the run.

4l. ⛔ A PRIMITIVE THAT PRESSES MOVEMENT KEYS MUST GUARANTEE ITS OWN RELEASE (2026-08-12)

ProjectileDodge presses SPRINT plus a direction. Its cleanup was changed so the keys are released only when no other owner is driving — reasoning that the walker releases everything each tick anyway, so clearing would merely stomp the walker's own presses. The reasoning is correct for the tick the walker runs. It is wrong for the tick the walker STOPS: nobody clears, the sprint key stays held, and the bot walks off the arena and into the void.

Reverted after the bot was observed at Y=-234 on a floor at -60; with the revert in, minY returned to -60.0.

The rule: release is not someone else's job. A primitive that takes the keys owns putting them back, unconditionally, on its own timeline. "Another system will overwrite it" is an assumption about that system's lifetime, and lifetimes end. If clearing would stomp another writer, the answer is to press for one fewer tick, or to record what was pressed and release exactly that — never to skip the release.

4j. WHEN THE VARIANCE LIVES BETWEEN SERIES, A REBUILT ARM MEASURES THE STAND (2026-08-11)

Rule 4b says characterise the metric before quoting a delta. This is the failure that survives doing that correctly: the metric was characterised, the arms were large, and four separate headlines still softened or vanished — because the arms were built hours apart.

mob_skeleton, the SAME build, three separate series:

0.77 arrows (n=13)      0.90 (n=26)      1.18 (n=40)

and pass rates on one build across the day: 3/6, 0/6, 2/12, 2/12, 5/13, 1/12. The spread is not within a series, it is BETWEEN them — client wear, host load, whatever the stand carries forward. So an arm measured after a rebuild is compared against a baseline taken in a different world, and the difference you read is partly the stand.

What it cost, on one course in one day:

  • a change shipped at "2.1 sigma" that pooled to 1.7 and then to nothing;
  • a change shipped at "1.4 sigma" that read 1.18 against a 1.18 baseline at n=40 — no effect at all;
  • a pass rate reported as ~38% from a 5/13 series that the next twelve runs read as 1/12.

Each was measured carefully. Each was wrong, and the care went into the wrong place.

The rule:

  1. Put the thing under test behind a TungstenConfig flag, and A/B it with --pin. The runner already supports it and its own help says "use this for A/B runs". Both arms then run back-to-back on the SAME clients, in one invocation each, with no rebuild between them.
  2. Prove the pin reaches the behaviour — read it back from the run, do not assume. A flag that silently fails to apply produces a clean-looking A/B in which both arms are the same build. That is the identical failure mode as a fix that never reached the keys, and it looks like a refutation.
  3. A single arm is not a result, however large. Report the pair or report nothing.
  4. If a rebuild between arms is unavoidable, say so and treat the comparison as provisional whatever the sigma says.

The general form: rule 4c warns against a statistic taken over a sample selected by something other than the thing being studied. Here the selector is when the run happened.

4i. THE GATE'S STATISTIC IS RARELY THE MEASUREMENT'S STATISTIC (2026-08-10)

A criterion answers "did this run pass". A comparison answers "is this build different". They want different numbers, and using the gate's number for the comparison is how this session produced three confident claims and withdrew all three.

The duels are judged on kills minus deaths. About 27 exchanges decide a run, so that margin carries a standard deviation near 2.7 — six healthy narrow_bridge_duel runs read +3, −2, −3, +1, −4, +1. At n=6 the resolution is about ±2.2, and every mechanism proposed for that course has been smaller than its own spread. No amount of care in the fix rescues a measurement taken with a ruler that coarse; the answer is a finer ruler, not a fourth hypothesis.

The rule:

  1. Before running a series, work out the spread of the statistic you intend to quote, from the data you already have. If the effect you are hunting is smaller than it, the series will not answer the question no matter how many runs it gets — n scales as the square of that ratio.
  2. Prefer a statistic that counts events over one that counts outcomes. The same duel that yields ~13 deaths yields ~50 blows a side; blows resolve what the margin cannot, for free, on the same runs.
  3. Look for the instrument that already exists before adding one. DamageWatch counts from the client tick, so it had been running on the opponent all along — both fighters run this mod — and no probe had ever read it. That is the fourth time this session the instrument was already in the repo.
  4. A biased instrument is still usable as a ratio between two sides measured the same way. DamageWatch's total does not reconcile with deaths × 20, but it undercounts identically on both fighters, and a shared bias cancels in a ratio while it does not in a total.
  5. Keep the gate as it is. It answers its own question correctly; it just must not be the only number the comparison gets.

4n. ⛔ A GATE THAT TESTS FOR AN ABSENCE CAN BE SATISFIED BY THINGS THAT ARE NOT SUCCESS (2026-08-12)

mob_skeleton gated on "the skeleton is dead", implemented — like its early_stop — as is the entity gone. Both readings are correct. Neither measures the bot.

Landed swings against the verdict, one series:

FAIL min_hp=13   3 swings landed        FAIL min_hp=12   3 landed
PASS min_hp=20   2 landed               FAIL min_hp=20   1 landed, never observed alive

A skeleton has 20 HP and does not die to one swing. The runs scoring PERFECTLY were the runs where the bot barely fought. The target was leaving on its own — over the rim of a floating arena, and after that was fixed, by despawning past 32 blocks while it retreated. Both removals also stop it shooting, so "the bot took no damage" and "the bot did nothing" produced identical scores. The course had been reporting its best possible result for the fight not happening.

The rule, for any criterion phrased as an absence — entity gone, counter at zero, no errors in the log, queue empty:

  1. Enumerate every way the observable can occur WITHOUT the behaviour under test. For a dead mob in Minecraft that is at minimum: killed, fell out of the world, despawned, burned in daylight, killed by another mob, never spawned. Each one needs either a gate or a mechanism that makes it impossible.
  2. Prefer removing the alternative to detecting it. PersistenceRequired:1b and a wider field are worth more than any amount of after-the-fact classification, because they turn "gone" back into "killed" by construction.
  3. Pair the absence with a positive counter that must agree. Swings landed against the target's HP is what exposed this; the gate alone never could.
  4. A criterion must fail on missing data. Spelled not fell, the void check passed four runs out of four on last_seen=None. Spelled "was seen alive on the arena", absence of evidence fails, which is the honest verdict. Same defect as the awake half that could not fail (4m).

4o. ⛔ YOUR CHECK CYCLE IS SECONDS. A BENCH COURSE IS MINUTES. DO NOT POLL PER CYCLE (2026-08-12)

Measured on this machine, not assumed: one pvp course takes 2-3 minutes, a --repeat 6 series 12-20. A status check costs seconds. So checking once per turn shows the SAME state five to ten times in a row, and the transcript fills with 4/12 — chase_flat while nothing happens.

This is what produced the loop the user caught and called out — a run of turns that were all STEP 2 plus a status ping, with no work between them. It also wastes the context that the actual analysis needs later, which is the expensive part.

The rule:

  1. A backgrounded run notifies you when it finishes. That notification is the signal. Do not re-check for it every turn; the loop is not making it arrive sooner.
  2. When you catch yourself about to write "still running" for the second time, the correct move is different work, not another check — reading sources for the next pass, closing a non-defect, writing the release notes for what already measured.
  3. If there is genuinely nothing to do that does not disturb the bench, say so in one line and stop reporting deltas that are not deltas.
  4. Judge "is it stuck" by file mtimes, not by your own impatience — several of your turns fit inside one course. An unchanged course name after three checks means three checks were too many, not that the suite hung.

4p. ⛔ WAITING IS NOT STOPPING — BLOCK INSIDE THE TURN, DO NOT END IT (user 2026-08-12)

The user's instruction, verbatim in spirit: "if you are WAITING for a task, you are not STOPPING — you are setting a WAIT for N minutes, so the stop-hook doesn't clutter up the context".

Ending a turn to wait is what makes the Stop hook fire, and every firing costs a full re-injection of the checklist directive plus a status line that says nothing. A single bench suite produced dozens of summary=0 turns this way. That is not diligence, it is noise, and it burns the context the analysis needs later.

The rule: while waiting for a run, do not end the turn. Block in it.

# foreground, with a timeout just under the tool's ceiling — ONE turn, no hook firings
until [ -s "$OUT" ]; do sleep 30; done; echo "done"
  1. Pick the timeout from what you are waiting for: a course is 2-3 minutes, a suite 20-60, a two-arm A/B over an hour. Wait in blocks of several minutes, not in single ticks.
  2. If the block times out and the run is still going, wait again. Re-entering a wait is one turn; polling the same state twenty times is twenty.
  3. Never report "still running" more than once between real events. The absence of a delta is not news, and rule 4o already says the check cycle is seconds while the work is minutes.
  4. This does NOT weaken rule 8's stop conditions. Blocking is not stopping — the turn stays alive and the work continues the moment the condition is met.

4u. A COUNTER GATED ON ITS OWN FLAG READS ZERO IN THE CONTROL ARM BY CONSTRUCTION (2026-08-15)

Made THREE TIMES IN ONE DAY, by someone who had already corrected it twice that same day. It is the most repeatable mistake in this file, so it gets its own rule rather than a third apology.

The shape is always this:

if (myFlag && somethingWentWrong) {      // the counter lives INSIDE the flag guard
    myMechanismCounter++;
    doTheFix();
}

Now run the A/B. The pinned arm reports the counter; the CONTROL arm reports zero -- not because the problem is absent, but because the line cannot execute. And "how often does this happen WITHOUT the fix" is the number the entire premise rests on. The series is half-blind and looks perfectly healthy.

The three instances, all 2026-08-14/15:

  • navSearchOnly -- read 0, and the hypothesis was filed as REFUTED on that basis. It could not have read anything else.
  • lockBarren -- caught before its series, corrected with the words "count always, act only when flagged".
  • findRefused -- written the broken way AGAIN, an hour after writing that sentence, and caught only because the first verdict of the series happened to be a control run.

Same family as declaring stranded a mechanism gate and never exposing it, which voided a forty-launch series by its own rule, and as dteUnsafe reading 0 for a condition that could not occur.

The rule: THE COUNTER IS AN OBSERVATION, THE FLAG IS THE BEHAVIOUR. Separate them.

if (somethingWentWrong) {
    myMechanismCounter++;                // always
    if (myFlag) { doTheFix(); }          // only the ACTION is gated
}

Two checks before any A/B, both free:

  1. Ask "what will this counter read in the CONTROL arm?" If the answer is "zero, always", the GATE is broken, not the hypothesis.
  2. A counter at zero is ambiguous until you know it CAN be non-zero. RULE ONE already says so; this is the commonest way of manufacturing exactly that ambiguity.

4t. A GATE METRIC MUST BE A RATE OR A MEDIAN, NOT A TOTAL (2026-08-13)

Four times in one day a per-run TOTAL was read as if it described intensity, and every time the confound was fight length -- a run that goes badly lasts longer and accumulates more of whatever is being counted.

  1. corr(band ticks, arrows) = +0.43 was read as "the controller is starved of band ticks". A longer fight has more band ticks AND more arrows. That reading cost a 40-launch series.
  2. corr(strafeFar, reachMean) = +0.93 looked like the circle-strafe diluting the approach. strafeFarTicks counts strafe ticks taken BEYOND reach, so per control tick it is one minus the in-reach rate: corr(strafeRate, inReachRate) came out exactly -1.00. An identity.
  3. dodgeDrive was used as a pre-registered MECHANISM GATE -- the one device meant to stop exactly this -- and inverted: 35.3 -> 108.2, apparently voiding the series. Median 21 -> 15 and per-arrow 8.79 -> 6.65 both showed the flag firing as designed. Five runs where the bot never engaged, accumulating dodge ticks for two thousand ticks each, carried the mean over the gate.

The rule: a gate or a correlation takes a RATE (per control tick, per arrow, per episode) or a MEDIAN, unless the denominator is fixed by construction. A total is only safe when every run has the same length, and on an event-terminated course no run does.

Two cheap tells, both free to check before believing anything:

  • a correlation of exactly +/-1.00 between two derived quantities is an identity, not a discovery;
  • if the mean and the median of the same counter disagree in DIRECTION between arms, the mean is being carried by a tail, and the tail is usually a different failure mode rather than more of the same one. Look at the tail before reading the mean -- it is often the more interesting find. The 9%-of-runs stall on mob_skeleton was found exactly this way, while diagnosing a broken gate.

4w. TWO WORKERS CONVERGED ON THE SAME DEFECT AND HALF THE EFFORT WAS WASTED (2026-08-16)

The break-ban leak was found, fixed and committed TWICE within minutes -- 636f7072 by one worker, 85b2a3ca by the other -- because both were reading the same failing course at the same time. The code is fine (one flagged block, no doubling), but an hour of the second worker's reading bought nothing that the first had not already bought.

Rule 4v is about the shared TREE. This is about the shared QUEUE, and it is cheaper to avoid:

  1. Claim the thread in the register before starting it, not after finishing. TODOS.md is the only channel both workers read. A one-line entry saying "taking G-1.95 now" costs nothing and is the difference between two people finding a bug and two people finding the same bug.
  2. Read the last ten commits before picking work, not only before reporting. STEP 2 of the autonomy loop already makes you run git log; the other worker's subject lines are right there, and today they said exactly what was being worked on.
  3. AND YOU CAN COLLIDE WITH YOURSELF. Launching a background series, then launching the SAME series again before the first reports, produces exactly the same refusal -- and the lock names your own invocation, which reads at a glance like the other worker holding the bench. Worse, if both redirect to one log file the second truncates it and the refusal is all you see, so the run that IS working looks like it produced nothing. Read verdict.json under the newest artifacts directory rather than the tee'd log when that happens; the verdict is authoritative and nobody else is writing it.
  4. Duplication is not always waste -- an independent confirmation of a mechanism is worth something. It is waste when it is UNKNOWING, because then neither worker treats it as replication and neither records it as such.

4v. ⛔ TWO WORKERS, ONE WORKING TREE: git add -A SWEEPS UP THE OTHER ONE (2026-08-15)

Parallel work on this repo is expected -- AGENTS.md says so, and tells you to pull often. What it does not say is that the two workers share a WORKING TREE, not just a branch.

Measured today: a change to BotBehaviour was written, built, and then found already committed -- inside f4971a89 bench: md* counters are zero BY CONSTRUCTION on the bow courses, a commit about something else entirely, made by the other worker's git add -A. Nothing was lost and the code is correct, but the history now attributes it to the wrong change and the message does not mention it.

Two consequences, both cheap to avoid:

  1. Commit narrowly when a parallel worker is live: git add <paths> for the files you actually touched, not -A. A sweep is convenient alone and lossy in company.
  2. A clean tree is not proof your commit landed. git status said clean and main...origin/main said in sync while the commit I intended did not exist. Check git log -- <path>, not the tree.

AND THE SHARPER ONE, because it can destroy a measurement rather than muddle a message:

⛔ THE BENCH LOCK DOES NOT PROTECT AGAINST A REDEPLOY. run_suite refuses to start while another suite holds the lock -- good -- but deploy/deploy_jar.sh RECREATES the tester containers and asks no one. Running it while another worker's suite is mid-flight kills their run, and the symptom on their side is a course dying for no reason. BUILD is safe during another suite; DEPLOY is not. Check the lock (%TEMP%/uctest_suite.lock, which names its holder) before deploying, exactly as you would before starting a suite.

4x. ⛔ A SCRIPT THAT PINS A SETTING AND NEVER RESTORES IT POISONS EVERY RUN AFTER IT (2026-08-31)

Settings live in tungsten.json and survive a client recreate -- this is already documented at the --pin/--pin-alt machinery in gamer_smoke.py and run_suite.py. What was not documented, and cost a whole session's worth of manual stand resets before it was named: a script that pins a setting AWAY from its shipped default, with no CLI guard and no finally, poisons that default for every single thing that runs on the stand afterward -- not just its own next invocation.

Found by auditing every runner script that sends a ";settings" chat command, after the THIRD time this session a killed sweep left fireReleaseNeedsFire=true stranded on the live stand and had to be reset by hand before the next measurement could trust its own control arm:

pvp_test.py / pvp_moving_test.py   combatMovementsEnabled  false (shipped "for months") -> true
                                    unconditionally, no CLI flag gates it, never restored
break_test.py                      driftThreshold          0.8 (shipped default) -> 1.5
                                    unconditionally, never restored

The combat case is scoped to PvP measurements, but driftThreshold is not scoped to anything -- it governs general movement precision, so a stranded 1.5 silently loosens drift tolerance for EVERY later nav/physics measurement on the stand, not just a repeat of break_test.py. This is the same shape of damage rule 4u describes for a blinded counter: the corruption is invisible from inside the run that causes it, and only visible as an unexplained shift in something unrelated, much later.

The fix, applied to all three files (b69cb74a, b8793ed7, 78841272): restore the setting to its shipped default from a top-level finally, AND convert SIGTERM into a catchable SystemExit first --

import signal
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(SystemExit(143)))
try:
    main()
finally:
    restore_the_setting()

⛔ finally DOES NOT RUN ON A PLAIN SIGTERM. Verified directly, not assumed: Python's default SIGTERM action terminates the process before any try/finally unwinds. A background sweep killed by session teardown -- the actual shape every stranded-pin incident this session hit took -- is far more likely a SIGTERM than a KeyboardInterrupt, so a bare finally with no signal handler would have caught NONE of the failures it exists for. This still cannot save a SIGKILL: nothing can, for any process, at the OS level, ever -- record that gap rather than implying it is closed.

Before adding a new script that pins ANYTHING via ";settings": does it restore the value on every exit path, including a kill? If the answer is "it inherits run_suite.py's reset_config()" or "it only ever sets what its own CLI explicitly names" (diag_pit.py, repro_stall.py's pattern), that is a legitimate different mitigation and does not need this fix. If the answer is "it hardcodes one value, unconditionally, with no counterpart resetting it back," it is this bug.

5. VIDEO

--record on the run, then:

python deploy/runner/tg_send.py --text-file report.txt clip.mp4

Send one per meaningful iteration. The clip is the evidence; a text verdict is not.

Releases and visible milestones get an EDITED report (operator 2026-09-28): before/after on one checkpoint, each clip naming its event, stat cards, rendered with HyperFrames and sent for the operator to forward -- python reports/build_report.py reports/stories/<ver>.json --send. Rules and steps: VIDEO_REPORTS.md. Keep the recordings you will cut (reports/footage/).

6. ASSESS — mandatory, never skipped

Right after the test, answer in writing:

  1. Did the score move? Was X/10, now Y/10. If unchanged — what moved in the numbers (distance, time, falls)?

  2. Which end goal did this advance, and by how much?

  3. Is this the right road? Does it match the philosophy: core over band-aids, one engine instead of three, primitives over policy, no server-specific hardcode?

  4. Are we treading water? Two iterations with no movement in the numbers means the APPROACH is wrong — not that the target is wrong.

    ⛔ CHANGING APPROACH IS NOT CHANGING TARGET. You finish what you started: the course goes green. Abandoning a half-done target and picking an easier one is forbidden — that is how a project ends up with ten things at 80%. What changes is HOW you work it: stop patching, stop guessing, go back and RE-READ the sources end to end until you can explain the mechanism, then make ONE correct fix. Three failed patches mean you do not yet understand the code — so go and read it, not write it.

If (3) is "no" — revert (git revert, never delete) and rethink.

7. REPORT — write it for someone who has never seen this code

Commit and push immediately; never leave unpushed commits.

The report is NOT a changelog. The reader does not know the class names and should not have to. Explain the SUBSTANCE, in plain words, with a concrete example.

Required structure:

1. What was broken — in plain language, with an example. Not "nextLegNeedsPhysics was never read". Instead:

The bot walks up to a gap, and at that point the walking engine is supposed to say "I can't jump, physics takes it from here". It set a flag meaning exactly that — but nobody ever looked at the flag. So the bot arrived at the edge of the gap and just stood there, because the part that performs the jump was never told to start.

2. How I know — the proof. Always a log line, a number or a run, never reasoning:

The log repeated Walker: BFS 2 wp fifteen times and then gave up. Two waypoints means the plan ended at the lip of the gap every single time.

3. What I changed — the idea, not the diff.

Now the landing cell on the far side is remembered and handed to the physics engine, which is the part that can actually jump.

4. What it gave — numbers before/after.

nav_gaps: never passed -> passes in 8.0s, 6 runs out of 6, zero falls.

5. What is still broken. Honestly, by name.

6. ASSESS (the four questions from phase 6).

7. The closing check — MANDATORY, copy it verbatim every time:

STOP CONDITION CHECK:
- Is the work actually finished?        -> no / yes
- Is the END GOAL reached?              -> no / yes  (@gamer plays the whole game on
                                           tungsten, baritone deleted)
- Did the customer say to stop?         -> no / yes
VERDICT: if ANY answer is "no", the stop condition has FAILED — work continues
         immediately and the next iteration starts at once.

Call red things red. Partial success is "the course is still red, this much moved". No victory is ever declared without runs.

Never end a turn on the word "starting". Either the work is in the turn, or the sentence does not belong in it. A report is a checkpoint, not a stopping point.

7b. CONTEXT REFRESH — when it starts feeling hard, RE-READ. Do not wind down.

⛔ A STALE OR EXHAUSTED CONTEXT IS A RE-ENTRY, NOT AN EXIT (user 2026-07-30). "My usable context is exhausted", "I can no longer read the sources in full", "this needs a fresh session", "I will hand over and stop" — every one of these is the SAME event as a summarisation, and the answer is the same: re-read AGENTS.md, this checklist, NAVIGATION.md and TODOS.md, re-open the current task's sources, check git log --oneline -10 and the last measurements, and take the next focused pass. You are the fresh session. No one else is coming. Writing .claude/autonomy_stop.flag for this reason is a process violation — the flag is for hardware failure or an entirely closed and tested TODO, nothing else.

⛔⛔ IT IS STRICTLY FORBIDDEN TO WORK A TASK WHILE TIRED. Not "wind down", not "one more small step to finish it off" — both are banned. A tired pass does not produce a smaller result, it produces a WRONG one: on 2026-07-28 the tired steps were an inverted coordinate convention (reverted), a floor check one level off, and a capability that could never fire. Every one of them cost more time than the refresh would have.

When tired you do a FULL FRESH RE-ENTRY FROM ZERO, and that re-entry IS the work: re-read the docs, RE-OPEN the source files (never from memory), re-establish the whole context out loud, then act. Compaction refreshes the session automatically — you do not need permission and you do not need a new session; you need to actually re-enter.

Signs you need this: the same course fails three iterations in a row; you are guessing instead of citing; you catch yourself writing "I am running low on context" or "the next session should…"; you avoid re-opening a file because you only half-remember it.

That feeling is NOT a reason to stop. It is the signal to re-read. Being fuzzy on something you read hours ago is normal — the fix is thirty seconds of reading, not a handover. Refusing to re-open a file you half-remember is the actual failure mode: it makes you guess, and guessing is what has cost this project entire sessions.

Do this, in order, then continue:

  1. Re-read NAVIGATION.md — which engine does what, and the log fingerprints.
  2. Re-read this checklist, phases 1 and 6.
  3. Re-read ../TODOS.md section 0 and the critical register — restate the END GOAL out loud: @gamer plays the whole game on tungsten, baritone deleted.
  4. Re-open the actual files involved in the current problem. Not from memory. Open them.
  5. Re-state, in writing: what is PROVEN, what is ASSUMED, what is UNKNOWN. Attack the UNKNOWN with one experiment — never with a code change.
  6. Write down the vectors again: where we are (score X/10), what is red, what is next.

Then keep going with a clear head. A refresh is part of the loop, not an exit from it.

8. Next iteration — immediately

Reaching a milestone is not a reason to stop. Close a root cause, take the next priority, start again from AUDIT.

Stop only on: hardware failure, or everything in TODOS.md closed and tested.


Hard bans

  • Never call something done without a stand run.
  • Never fix a symptom instead of the cause.
  • Never leave dead code, dead flags or duplicate engines.
  • Never hardcode a specific server / slot / coordinates.
  • Never put emoji in a user interface.
  • Never dress up a result — the user checks it on video.

Where things are

file what for
NAVIGATION.md engine map + log fingerprint table. Read FIRST when touching pathfinding
../TODOS.md user's goals + critical register C0-C10
ai/nav-baseline-2026-07-27.md course baseline
ai/audit-2026-07-27-tungsten-full.md full tungsten audit
deploy/runner/ stand, suites, video sending
RELEASE.md how to release

4q. ⛔ A PINNED PAIR IS NOT ENOUGH — SWAP THE ARM ORDER BEFORE BELIEVING IT (2026-08-12)

The same flag, combatHoldContactOnShooter, measured twice as a pinned same-session pair:

pair 1   flag off 1.42 arrows   flag on 1.94   -0.52,  1.02 sigma   (flag looks WORSE)
pair 2   flag off 2.42 arrows   flag on 0.50   +1.92,  3.18 sigma   (flag looks REAL)

Opposite signs, one of them above the 2-sigma bar this checklist uses to accept a result. At least one is an artefact, and the design has an obvious hole: in both pairs the baseline arm ran FIRST, right after the clients were recreated, and the pinned arm ran second. Anything that drifts over a session — client wear, world state, host load — is therefore perfectly confounded with the flag.

Two more signals said so before the replication did:

  1. The suspicious arm was the BASELINE, not the pinned one: 2.42 arrows against 1.19 and 1.42 in every other baseline that day, including a run with nine total swings.
  2. The mechanism counter moved the WRONG WAY. The flag exists to stop a ready swing maturing out of range; ready near-share went 0.205 -> 0.149, i.e. worse, while the outcome improved. A fix that improves the outcome while degrading the mechanism it targets has not been understood.

The rule:

  1. Order-swap before accepting. Run the pair again with the arms reversed. If the same arm wins both ways, the effect survives; if the winner follows the POSITION, it was the stand.
  2. Always read the mechanism counter next to the outcome. Agreement is evidence; disagreement means the causal story is wrong even when the p-value is pretty.
  3. A 3-sigma result that contradicts an earlier 1.8-sigma result of the opposite sign is not two findings — it is one unresolved question. Resolve it before it becomes a headline.

4r. ⛔ BLOCKED ARMS CONFOUND THE FLAG WITH TIME — INTERLEAVE THEM (2026-08-12)

Rule 4q said to order-swap before believing a pinned pair. Doing that produced the answer, and it is worse than a confounded order: the SAME flag measured three times, honestly, same session each time:

pair 1   A first, B second     off 0.79   on 1.40   -0.60 arrows
pair 2   A first, B second     off 2.42   on 0.50   +1.92 arrows   (3.18 sigma, "real")
pair 3   B FIRST, A second     off 2.00   on 2.31   -0.31 arrows

Three answers, two signs, a fourfold spread — and the winner does not even follow position (second arm worse in pair 1, better in pairs 2 and 3). There is no effect here; there is DRIFT BETWEEN ARMS large enough to swamp anything the flag does.

The design is what failed, and it was mine: the arms ran as BLOCKS — six runs of A, then six of B — so "which flag" is inseparable from "when in the session". Every pinned pair in this repo has been run that way, including the two that were reported today.

The rule:

  1. INTERLEAVE the arms: A, B, A, B, ... Then any drift over the session lands on both arms equally and subtracts out. Blocked arms cannot do this, however large n is.
  2. A single pair is a hypothesis, not a result. Replicate before reporting — the 3.18-sigma figure above would have shipped as a finding on any one pair.
  3. When three honest measurements disagree, the answer is not "take the best one" or "average them". It is: the instrument cannot resolve the question yet, and saying so is the result.
  4. This does NOT retire pinning. Pinning removed the build-to-build confound, which was real and worse. It removed one confound and left another standing.

4s. BACKTICKS IN A COMMIT MESSAGE ARE EXECUTED, AND THE WORDS VANISH (2026-08-12)

Writing a message with git commit -m "... gateStats' PASSED ..." where PASSED is wrapped in shell backticks makes the shell RUN it as a command and substitute its output — nothing. The message reaches history with a hole in it, no error, and the log now shows a sentence that no longer says what it meant:

That reads gateStats'  as damaging hits.        <- the identifier is gone

It happened twice in five minutes: once in the commit itself, and again while writing THIS rule, because the heredoc carrying it was still parsed by the outer shell. Same silhouette as the cp1251 print that killed an eight-run measurement — a formatting detail quietly destroying a record instead of failing loudly.

The rule:

  1. Write any message containing backticks, dollars or quotes through a FILE, not through -m and not through a heredoc nested in another command. Python writing the file, then commit -F.
  2. Do not repair a mangled message by force-pushing a shared branch. The loss is two words; a rewritten history in front of parallel work is worse. Note it and move on.
  3. If a record can be silently damaged by the tool that writes it, that is the same class of defect as a counter that silently reads zero — and this repo has now paid for both.

4y. ⛔ THE STALL DETECTOR IS NOT THE REVIEW — WATCH THE RECORDING FOR EVERY MULTI-SECOND HOLD (user 2026-09-16)

The runner's detectors (position stalled, no new rung for 150 s) fire on thresholds. A run can PASS with 97 items and still contain an 8-second hold that no threshold sees. It did: on the 2026-09-16 recording the operator found the bot at 00:12 (12x, ≈144 s real) standing still, crosshair on a stone, for eight seconds, while I had already sent the clip as "progressing well". The detector wrote nothing. dbAimWait read 0. The hold was invisible to every number this repo carried, which is exactly why the operator's rule is: you must find these yourself, from the recording, before anyone else does.

The procedure, every recorded run, before sending anything:

  1. Scan the frames, not the verdict. Extract frames from the sped-up clip at a fixed cadence (every 3–4 s of a 12x clip ≈ every 40 s real) AND at every window where position or item count is flat for ≥3 s real (t=…s pos=… items=… lines of the runner). Read the PNGs yourself. A frame of a bot standing with a tool raised and nothing breaking is a defect.
  2. Read the overlay for TWO drivers. If two task lines from different engines name different blocks — FastNavigator: at the dig — mining … at A next to DestroyBlockTask: Block in range, mining at B — that is two aimers on one camera. The block under the crosshair (getGameState().lookingAt) says whose aim is winning that tick. Vanilla resets break progress every time the crosshair leaves a block, so two aimers on alternate ticks break NEITHER block, indefinitely, on anything harder than a table.
  3. Read the counters that name a mechanism, and notice the ones that read zero. breakMissWhy=occluded/transit (transit = the executor's own aim never settled: something else is steering the camera), execYieldMiner / execDigYieldMiner (did the executor stand down for a miner that claimed the aim), dbAimWait (the miner waited for the camera), breakAim=hit/aimed/missed/…, dbBlocked=selfFloor/unclearable/noReach, dbLid. A hold in which every relevant counter is zero means the branch it sat in is UNCOUNTED. Add the counter first; a fix you cannot see fire is not a fix (4u, 4m).
  4. Reproduce the MECHANISM when the SCENE will not replay. The live scene often cannot be re-entered: chunks unloaded until the bot stands there, a cave too cramped to stand on the target, a navigator dig that only exists on one approach. Do not burn hours steering the crosshair. Isolate the mechanism on the flat stand with the agent primitives — destroyBlockAt (x,y,z) (altoclef's DestroyBlockTask, the "Block in range" miner), mineBlocks([[x,y,z]]) (tungsten's executor break queue, what "at the dig" hands over), ;goto — and measure the SAME counters. The 00:12 fight reproduced as executor + miner on two adjacent blocks: 77 transit misses in 4 s, both aimers thrashing.
  5. Fix the root, not the symptom, and prove it on the bench first. "One owner per tick" was the root: the dig path of PathExecutor.tickBreaking had no yield to minerOwnsAim(), while the walk path did; and stopOrphanRoute() cannot stop a navigator dig during "in range" because its 300 ms drive-tick gate never opens there. After the yield: transit 77 → 0, execDigYieldMiner 82, the miner breaks its block, the executor resumes. Then the nav suite. Then, and only then, the operator gets a clip — one whose frames you looked at.
  6. A fallback is not a diagnosis. "Carry the smoker so the bot need not walk back" answers nothing about why a 33-node route to the smoker stopped at node 9. The operator's standing demand: find WHY it stopped, reproduce it, fix that. Workarounds are for after the root is known and shown to be out of scope, and they get named as workarounds.
  7. Freeze the run where the trouble starts, and test from there (operator, 2026-09-16: "you might as well have been saving a CHECKPOINT then"). A sixty-minute run reaches diamonds at minute thirty and its wall at minute forty; a fix for that wall tested from an empty inventory costs thirty minutes per look. Every long gamer_smoke.py run freezes its end as the last checkpoint (deploy/runner/checkpoint.py: the whole world -- position, inventory, armour, the shafts dug, the time of day); --checkpoint-every 10 freezes the middle too; --from NAME resumes a run there. Two runs from zero in one day, each with the same first thirty minutes, is the mistake this rule records.

Clone this wiki locally