Skip to content

fix(nav): stop entering chained speed jumps from a standstill - #14

Merged
dsvensson merged 17 commits into
qw-ctf:mainfrom
Xerialen:chain-entry-gate
Aug 1, 2026
Merged

fix(nav): stop entering chained speed jumps from a standstill#14
dsvensson merged 17 commits into
qw-ctf:mainfrom
Xerialen:chain-entry-gate

Conversation

@Xerialen

Copy link
Copy Markdown
Contributor

Claude (on behalf of Xerial):

Chained speed jumps get entered from a standstill and can never succeed. Three
commits close it; a balanced eight-arm A/B on dm3 puts the map's total stall
firings down 17%. It also makes two other links stall more, which is
explained below and not hidden.

Depends on #13 — this branch is built on that PR's head, so it currently
carries 17 commits. Only the top three are this change. If #13 lands first
this reduces to those three; if you would rather review them alone, say so
and I will rebase onto main directly.

The bug

The banded planner seeds every fresh search's start band at BAND_FLOOR[0] == MAX_SPEED (320) regardless of what the bot is actually doing. There is no
"standing still" band. A chained speed jump — one with no run-up of its own,
which is only traversable on speed carried in from the leg before it — whose
v_req sits under 320 therefore reads as already-satisfied at band 0 to a bot
standing dead still. dm3 has exactly two: 34342 (v_req 304) and 34356 (296),
both on the ring approach to RA.

The consequence is a stall loop. The bot commits to a jump it cannot make, the
attempt fails, the watchdog fires after its timeout, the repath reselects the
identical infeasible link, repeat. Controlled trials: 0% success from a stand
start on both links, 80–100% with any carried speed. A 600s raw event capture
attributed 87% of 34342's live stalls and 83% of 34356's to attempts starting
under 150 ups (median under 60).

The bug is general — nothing about it is dm3-specific — but it has only been
measured on dm3, where the two qualifying links happen to sit in the same
corner. Other maps are unverified either way.

The change

  • chain_entry_exclusions (rtx-nav): chained speed jumps leaving a cell that
    the bot's real speed cannot carry. Folded into LinkCosts::penalties for one
    A* call in steer's repath block, then replanned. Never persisted, so a bot
    arriving later with speed is unaffected.
  • chain_entry_leg_ok: the same check at the frame a speed-jump leg becomes
    current. The plan-time gate alone is blind to a route shaped [leg 0: walk
    onto the ledge, leg 1: the chained jump] — at plan time leg 1 was not
    adjacent to the bot's cell. This was 52 of 58 residual stalls in the
    measurement that caught it.
  • chain_entry_hold_expired: an every-tick check while a speed-jump leg is
    committed and grounded, past a 0.3s settling grace. A commit passed at
    borderline speed has, by definition, no runway to build the rest on, so
    sj_hold just holds it there until the generic 0.7s stuck watchdog wins.
  • All three behind rtx_bot_chain_entry_gate (bool, default true). Diversions
    deliberately do not call note_stall — nothing was attempted, so nothing
    failed.

Evidence

Eight 600s free-play runs on dm3, alternating gate on/off so drift hits both
sides equally. Same binary in every arm (md5 d2a8402e, verified against a
fresh build before the series and recorded in each envelope) — the only
difference between arms is the cvar.

Totals

gate ON gate OFF
stall firings per 600s 262, 311, 257, 274 (mean 276) 329, 374, 319, 315 (mean 334)

The distributions do not overlap: the worst ON run beats the best OFF run.

The links that move

link gate ON (4 arms) gate OFF (4 arms) mean ON → OFF
34342 (chained, v_req 304) 1, 11, 6, 0 36, 12, 42, 25 4.5 ← 28.8
34356 (chained, v_req 296) 0, 0, 0, 0 7, 2, 13, 2 0.0 ← 6.0
9692 (jump, under the bridge) 10, 16, 10, 12 15, 18, 14, 14 12.0 ← 15.2
10085 (jump, under the bridge) 9, 3, 8, 6 13, 9, 9, 4 6.5 ← 8.8
35738 (speedjump, ring→quad) 30, 20, 20, 20 11, 10, 8, 19 22.5 ← 12.0
9333 (jump, under the bridge) 7, 7, 6, 6 0, 0, 4, 2 6.5 ← 1.5

Every other link is inside its own arm-to-arm spread; single runs on this rig
routinely differ by 15+ firings on one link, so nothing smaller than this is
claimed.

The two that get worse

Both are real, not noise — 35738's arms barely overlap (ON min 20 vs OFF max
19), 9333's not at all.

  • 35738 is not a chained link (v_req 419, has its own run-up). All 92 of
    its gate-on stalls are prestrafe_deficit. Diverting ring traffic sends more
    bots past it, and a pre-existing approach bug there fires proportionally more
    often. This branch does not touch that bug.
  • 9333 is 94% air_commit_off — combat pre-empting the jump, which is
    correct bot behaviour being counted as a stall. More traffic under the
    bridge, more of it.

Net: −24 on 34342, −6 on 34356, −3 and −2 on two bridge links, +10.5 on 35738,
+5 on 9333. −58 firings per run.

Regression check

T1's 21 movement drills, run on the same binary with the gate on and off:

gate ON gate OFF
drills passing cell_503_194, cell_724_503, ring_to_ratop, sng_mega identical
verdict changes

Two failing drills differ in how many attempts arrived (hex_quad_to_sng 5 vs 3,
spawn_ra_tunnel_to_lg 3 vs 4). Both already vary run to run and neither changes
verdict. This branch improves no T1 drill. 4 of 21 pass before and after —
none of the failing ones route through a chained link from a standstill, which
is exactly why this bug survived so long in a suite that runs daily.

cargo test --workspace --release: green (rtx-game 469, rtx-nav 121). Three new
unit tests, one per guard, each reproducing the specific hole its guard closes.

What is wrong with this branch

  • 34342 is reduced, not fixed. Mean 4.5 per run, not 0. One earlier run did
    hit 0 and I reported that number before it had been replicated — the four-arm
    mean is the honest figure. The residue is all displacement; it has not been
    chased further.
  • 2c6e008's commit message contains numbers measured against a stale
    binary.
    The build was never deployed to the rig before those runs, so they
    re-tested 89b2278 against itself. Caught by comparing the digest_md5
    recorded in each evidence envelope. a84836b documents the correction; the
    wrong numbers are left in the history rather than rewritten.
  • The 0.5×v_req threshold is coarse and deliberately far below the ~1.05×
    SJ_MARGIN the physics need. It catches "not carrying this at all", not
    "carrying slightly too little" — the banded feasibility check still owns that
    case. A bot between the two bars is unchanged by this branch.
  • cargo fmt was not run; rustfmt is not installed for the toolchain on
    the measuring host and installing it was out of scope. Style was matched by
    hand at 120 columns.
  • One 8s single-bot stuck episode appeared in an early acceptance run (11
    events, one cell, generic 0.7s watchdog cadence). It reads as a bot wedged
    against geometry, pre-existing, but it was not proven unrelated.
  • dm3 only. No other map was measured, with or without the gate.

Reverting is one cvar: rtx_bot_chain_entry_gate 0.

Posted by Claude via @Xerialen.

Xerialen and others added 17 commits July 31, 2026 09:37
Half the drills on dm3 have walkable ground on another floor inside their
own arrive square — the RA targets have floor 344 units below them — because
the box was tested on X and Y only. A full T1 run put the bot in that square,
on that floor, twelve times: ra_climb five, hex_ssg_to_ratop five,
spawn_sngspawn_to_ring_to_ratop twice, all between 320 and 344 units down.
None was credited, but nothing here prevented it. What stood in the way was
the engine's `arrived` not landing in the same instant, plus one drill's own
fall gate.

Arrival now requires the height too, through `run.arrive_z` (default 48,
zero restores the old behaviour for a drill that asks about a place rather
than a floor). Giving up deliberately does not: both give-up tests keep
asking the height-blind `inside_column`, because a bot at the right place
and the wrong height is still trying. The impossibility bound already had
that exemption and said why; the wedge test did not, so a bot standing on
the target could be declared wedged. It has it now.

Measured on the rig against the same six drills before and after: same six
verdicts. sng_mega is the case the tolerance had to clear — it is credited
24 units above its declared target on every attempt and stays PASS 10/10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
What the bot carries decides what the planner will consider: a rocket jump is
priced away for a bot that cannot fly one. Left to the map that is whatever it
picked up earlier in the run, and it moves the answer a long way. Measured on
one drill, same rig, same build, with a repeat arm to rule out drift:

  100 rockets   8.68-11.50 s   54-262 rocket-jump links in the route, fires
    0 rockets  14.27-25.55 s   no links, the phase never leaves Idle
  100 again     9.22-13.64 s   fires again

That drill was the one number in T1 that matched the owner's own time (0.99x).
It matched it by taking a jump the rules do not allow on that route, and it
had the rockets to do so because it had been running for twenty minutes.

Every attempt now starts from a stated loadout via `Cmd::Prep` — which the
engine has had since the jump work, and which was unreachable only because
`_parse_verb` in our own client did not know the verb. Its refusal read like
the engine's. `run.prep_rockets` doubles as the permission: a drill handed
none is a drill where the jump is not sanctioned, which is every route on dm3
except the pent jump.

Starting empty is not staying empty — dm3 hands out rocket boxes. `rj_phase`
leaves `Idle` the moment the bot commits to a jump, a field this loop already
fetched on every poll and nothing had ever read. An attempt that jumps anyway
is `rocketjump`: not an arrival, not a failure to arrive, but void, because it
answered a different question. Verified by handing a bot rockets two seconds
into a drill that was given none — 3/3 void, against slow/timeout without.

Measured after: spawn_sngspawn_to_ring_to_ratop falls from 2 passes at 8.78 s
to slow at 14.66 s, spawn_rl_to_ratop_xer loses its only arrival, and sng_mega
is unchanged at 10/10 — its one rocket had been its slowest attempt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
rj_pent_to_lifts_to_window_to_quad times out 5/5 on main, unchanged when the
launcher is handed 100 rockets. The route runs through a rocket-jump link out
of the pent lift, and that link is planted by the dm3 navpatch — it is not in
the base graph. Main has no navpatch at all: no patch.rs, no data/navpatches/,
no rtx_rj_cost_scale. So the drill was reporting the absence of a route as a
bot that could not walk one, and it was one of the FAILs in T1's column.

A drill now names what its route needs and how to tell whether the build has
it. The witness is a cvar that ships with the capability, read off the engine
binary rather than asked of the server, for the reason engine_declares already
documents: the control layer's Get answers for names no build ever registered.
Absence is the direction that probe establishes reliably and the only one that
changes anything here — present and unknown both run the drill, and which of
the two it was is recorded rather than acted on. Withholding a drill because
the binary could not be read would turn a rig problem into a silence about the
bot.

The capability is named explicitly rather than derived from a route that turns
out to have no links. A missing capability and a bot that cannot use one it has
are different findings, and only the first is the harness's fault.

A withheld drill carries no verdict, no attempts and no times, counts toward
neither the level's verdict nor the dashboard's denominator, and its name goes
into capabilities.unavailable as `t1:<name>` — the same channel the missing
telemetry uses, because it is the same statement. The two have to agree, and
the validator enforces both directions: a drill withheld in silence would leave
the column reading `5/8 drillar` with nothing to say the eighth was never
asked, and a declaration naming a drill that ran would explain away a number
the run produced.

Also fixes something the loadout commit left behind. generate_from_routes.py
rewrites its drills whole, so regenerating would have silently wiped the
prep_rockets that commit hand-added — and would have wiped this requirement
too. Anything the route manifest does not describe now lives in the generator's
ROUTE_RUN and ROUTE_REQUIRES tables; regeneration is a zero diff. The same
commit left `rocketjump` undocumented in SCHEMA.md and the loadout fields
undocumented in the README; both are caught up here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
rj_pent_window and rj_pent_to_lifts_to_window_to_quad are the same route:
start [958, 788, -296] against [957.8, 787.8, -296.0], target [978, 302, 56]
against [978.5, 301.5, 56.0]. One of them carries the owner's time and grades
against it. The other has required = 8 and no max_time_s at all, so arriving
is the whole test — and the bot passes it 3/3 in 18.9 s by walking, on a build
where the jump the drill is named after does not exist. It was one of T1's
five PASSes.

Its own comment says the owner's run "took 8.7 s end to end". That is the
length of the demo, not the time on the route: 10.689 minus 2.026. His actual
time is 5.42 s, which is what the surviving drill is anchored on. A limit
derived from 8.7 would have been half again too generous, which is how an
untimed drill turns into a wrongly timed one.

Removing it leaves dm3 with no drill that exercises the pent jump on a build
without the navpatch, which is correct: there is no such route on that build,
and the surviving drill now says so rather than walking around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
spawn_lift_to_pent_to_pentmega passed every attempt at roughly half the owner's
own time by stepping off the pent ledge and freefalling most of the descent.
The endpoint was right and the run was worthless. Every goto drill had that
hole, not a chosen few: a drill said where the bot ended up and nothing about
how it got there.

Drills now carry ordered waypoints the attempt has to pass through before an
arrival counts. The waypoints are points the owner actually occupied, read out
of his own demos of each route — never derived from the navmesh or from
geometry, because a gate built from the thing it is meant to gate gates
nothing. An arrival with waypoints still unmet is `offroute`: it reached the
target, so it is not a failure to arrive; it did not take the route, so it is
not an arrival either. Void, the same shape as `rocketjump`.

The gates do not replace fail.fall_gate or fail.crossing. Those end an attempt
early and give it an honest name of its own; the waypoints decide whether an
arrival counts. Eighteen of the twenty-three drills have gates — the other five
have no demo of the owner running them, and inventing waypoints for those would
be asserting a route nobody has walked.

Three things this had to survive, none of which a passing test suite would have
caught:

The waypoints advance greedily within one poll. The loop looks at the bot
roughly fourteen times a second and a bot at full speed covers sixty units
between samples, so one sample can sit inside two consecutive waypoints.
Advancing one per poll would have called a bot that took the route exactly
`offroute`.

The gates were checked at the rate the runner actually polls, not the rate the
demos sample. His demos carry a point every 0.02 s and the runner sees roughly
every 0.07 s; a gate that only holds at demo density would have failed every
route at once, live, and read as the bot having stopped taking the routes. All
eighteen hold down to 0.16 s.

The box is one width for every waypoint, and 96 is a measured choice rather
than a tidy one. Per-route widths derived from a straight-line shortcut came
out as small as 32, and at 32 the gate called a bot that missed by 47 units
off-route — that is movement, not a shortcut. The straight line turned out to
be a weak measure: the owner's waypoints sit so far off it that anything up to
256 passes. What pins the number is the band where all three measures hold
steady at once — 80 to 112 — and 96 sits in the middle of it, comfortably above
the sixty units a poll can miss.

Replayed against the recorded run on main, five drills change and every one of
them is a bot that went somewhere else: 200 to 717 units off the waypoint it
skipped. spawn_lift_to_pent_to_pentmega goes from three passes to three void
attempts, which was the finding that started this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
`timeout` covered three different statements: the clock running out, the wedge
test ending an attempt that had stopped moving, and the impossibility bound
cutting short an attempt that was still travelling the moment arriving *in
time* became unreachable. The third is the weakest claim of the three and it
read in the column as the strongest — a bot that never got there.

Across the whole evidence archive, 444 attempts are recorded as `timeout` and
415 of them are that third case. Of those, 182 could still have arrived within
one second of the limit. "The bot cannot walk this route" and "the bot is a
second too slow" are different findings, and until now they were the same
symbol on the page.

So the bound's own outcome is `abandoned`, and it carries `min_possible_s`, the
time it could not have beaten. The validator now requires the two together in
both directions: an abandoned attempt without its bound has thrown away the one
thing it knew, and a bound on any other status is a number nothing produced.
The dashboard puts the bound on the face of the cell — `>=8.4s` — rather than
in a tooltip, because that number is the entire difference between this and a
timeout.

The give-up logic itself is untouched. It works as specified; this only stops
it from lying about what it did. The wedge case keeps the plain `timeout` and
keeps the trade that comes with it: an attempt cut there is still
indistinguishable from one that would never have arrived, and `give_up_grace_s`
is still how you buy that back.

`abandoned` is deliberately not grouped with `rocketjump` and `offroute`. Those
are void — they answered a different question. This one answered the right
question and we stopped listening, so it stays a failure to arrive and counts
as one.

Two consequences worth stating rather than discovering later. Existing evidence
on disk no longer satisfies the tightened rule, since it pairs `timeout` with a
bound; nothing revalidates those files — the dashboard reads the contract
without importing the validator and the sweep reads raw JSON — so they keep
rendering, but a fresh validation of an old envelope would now reject it. And
nine fixtures used that same pairing as filler, so their filler moved to
`abandoned`; each broken fixture was re-checked to confirm it still fails on its
own intended bug and not on the new rule.

Also fixes a fixture that had quietly stopped testing its own name:
`scenario_bad_major.toml` was rejected for missing `category` and `place`,
fields added to the schema after it was written, so it never reached the
major-version check it exists to exercise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
The suite measured navigation without ever checking there was a navmesh to
navigate. The control layer has answered this all along — status carries
navmesh, cells, links and rj_links — and T1 read status exactly once, at
connect, and used it only for the engine digest.

What the rig actually does, measured rather than assumed: a control port with
no bots requested reports navmesh "none" with all three counts at zero,
because rtx-game builds the graph lazily — bot/population.rs calls
ensure_navmesh only once bots are wanted. Asking for a bot is what starts the
build. On dm3 that build takes 19 s, and for all 19 of them the server answers
with zeros; the first bot appears in the same instant the graph does, because
a bot cannot spawn without one.

So the old code worked by accident. The 40 s bot wait covered the 19 s build
with room to spare, and when it did not, it said "server did not expose 1 live
bot(s)" — a true sentence about the wrong thing. Nothing anywhere recorded
which graph the numbers had been measured against, so two runs against
different map knowledge were indistinguishable afterwards.

nav_preflight polls status about once a second until the graph is ready and
the map is the one we asked for, and only the deadline is a verdict.
Everything short of ready is "not yet": "building" is the obvious case, and
"none" means no build is in flight yet, not that none ever will be. Reading
status once and judging the answer is the trap the whole function exists to
avoid, and the zeros it returns are exactly what a broken build would look
like. The wait is 120 s, twice what the engine's own MCP helper gives the
identical condition, because a lab rig sharing a box is slower than the laptop
that number was tuned on. It sits after the bot count is set and before the
bot is waited for, which is the only placement that works and also the one
that makes a failure legible.

The result is stamped into the envelope as nav, beside build: the map, the
state, the three counts, and how long the poll waited. cells and links must be
positive — a ready graph with zero cells is not a graph, and that is the
concrete case the "a value that could not be measured is null, never 0" rule
exists for. rj_links may be zero, because a build with no rocket-jump links is
a legitimate build. waited_s is provenance rather than measurement: it says
whether the rig was hot or cold when the numbers were taken.

The block is required on every complete T1 or T2 envelope and rejected on
every other tier. Not because the others never connect — T3 and T4 both hold
control connections — but because the stamp is singular and they are
two-sided: each side runs its own client build with its own graph, and one
block beside one build could not say which side it described. Rather than
stamp an ambiguous graph they stamp none.

T1's dash is gated but deliberately not stamped. It runs on its own map after
a map change, and that change drops the graph entirely — the fixed 5 s settle
there was a guess about a background build that takes four times as long. The
preflight is the floor under that guess. A second graph identity inside an
envelope whose map field names one map would only invite the reader to
attribute one to the other.

Two consequences worth stating rather than discovering. Every T1 and T2
envelope already on disk now fails the tightened rule, since none of them
carry a stamp; nothing revalidates those files, so they keep rendering, and
the dashboard shows old runs exactly as before. And every existing T1/T2
fixture had to gain a block — a broken fixture without one would have started
failing on the missing stamp instead of on the bug it was written to catch,
with the suite green and testing nothing. The rejection message of all 26
broken fixtures was captured before the change and diffed after: all 26 are
byte-identical, and the five new ones each fail for the reason their name
promises.

Measured on main, on the rig: the run stamps cells 4634, links 36956,
rj_links 2021, waited 0.02 s on a graph left warm by the previous run, and
comes out at 5 of 22 drills with one withheld. Against the last recorded run
exactly two drills differ, and both were already known — the untimed pent
duplicate that this branch deleted, and spawn_lift_to_pent_to_pentmega, which
the route gates turned from three passes into three void attempts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
Eighteen of the twenty-three drills got waypoints from the owner's own demos.
The remaining five had none, and the reason given was that he had no recording
of them — which was true of the demo *files* and false of the routes. He
resolved all five in one pass, and searching his eighteen demos on his answers
turned up what the first search had missed: a drill can be a leg of a longer
route he ran, not only a run of its own.

hexagon_sod_tur is dropped at his instruction. Worth recording because nothing
else would have: it was the only drill that planted runtime links, and those
four links stayed in the graph for every drill that ran after it in name order
— a dozen of them, none of which asked for them. That coupling is gone with it.

ra_climb is dropped as a duplicate. He asked whether there was a
ra-bottom-to-ra-top demo; there is, and it already anchors ralow_to_ratop with
his own 7.78 s and an 8.71 s limit. ra_climb was the same climb to the same
platform — the targets sit twenty-seven units apart — from a bottom he never
ran, and graded on nothing but "ten arrivals of ten". That is the pair
rj_pent_window formed with its own twin, and it ends the same way.

The two cell probes keep no waypoints, and the exemption is now written in
their files instead of being an unexplained blank. A cell probe *is* its pair
of cells; there is no path to take a different version of, and a gate built
from the cells would be built out of the thing it exists to check.

sng_mega is gated, and so his statement that a demo already existed for it was
correct. Two of his runs cover the leg; the waypoints come from the one that
passes the drill's own start closest, twenty units off it and twenty-five off
the target. Both were checked the way the eighteen were: his path clears the
gate down to a sixth of the sampling rate the runner has, and a straight line
from start to target does not.

Twenty-one goto drills remain, nineteen gated, and the only two without a gate
say why in their own text.

Measured on main after the change: three of twenty graded, one withheld.
Against the run before it, exactly one drill moved that was not deleted —
ring_to_ratop, from pass to fail. That one is not yet attributable: it is one
of the drills that has been flapping between a pass and a slow arrival all
along, and it is also the first drill in name order to lose the four planted
links described above. One run cannot separate those two, and this commit does
not claim to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
T2 already keeps `reasons` per zone — which watchdog fired — but dropped the
LinkKind of the route leg the bot held when it did. Those are different
questions: `air_commit_off` says a jump was committed and missed; only the kind
says it was a JumpGap rather than a SpeedJump runway.

The measurement that earned this field its place: pricing jump links by
envelope share cut JumpGap stalls 296 -> 57 across three ten-minute runs per
column, and that number could not be produced by the suite. The probe had to be
written by hand, outside it, because the envelope threw the kind away at the
door. A pricing change on one link kind was invisible to the very tier built
to watch the bot roam.

Each zone now carries `kinds` next to `reasons`, with "offroute" naming the
empty kind the engine sends when the bot held no leg — a name, because an
empty-string key reads as a serializer accident rather than a state. The
writer-side invariant extends to it: firings == sum(reasons) == sum(kinds),
with its own message and its own broken fixture.

The dashboard is deliberately untouched: normalize_cells whitelists its
fields, so the new one flows through the envelope without rendering. Drawing
it belongs with the map view, not squeezed into this commit.

Old T2 envelopes on disk no longer validate. Nothing revalidates them, so they
render as before — the same stance every schema-tightening commit on this
branch has taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
…-cut gate

Two findings from reading the suite's verdicts back off the demos they were
measured on, each fixed where it belongs.

ring_to_ratop's passing times sit in two bands — 7.50-7.91 and 8.09-8.19
against a limit of 8.42 — and it abandons intermittently, 2-3 of 15 in both
arms of the planted-links A/B (which also showed the links explain nothing:
12/15 with, 11/15 without). Three attempts with a required of two turns that
variance into a verdict that flips between runs, and it has, all along. Drills
may now pin their own quick count, and this one pins five: the quick cut
exists to save rig time, not to grade a drill on less evidence than its
verdict needs. Quick stays a cut — the schema rejects a quick count above the
full one.

spawn_rl_to_ratop_xer's first waypoint gets its own width. The bot corner-cuts
'bridge låg' at 97.4-103.7 units on the worst axis in all six recorded
attempts while clearing the three later waypoints by 13-26 — and the ordered
chain means a first-waypoint miss silences the rest. It has never changed a
verdict (those attempts all fell to stall, rocketjump or the abandon bound
first), but a gate that would void a route-following bot by one unit is a trap
armed. 128 covers the corner-cut; the straight line passes 311 from this
waypoint, so the gate keeps a 2.4x rejection margin, and the owner's own run
goes through at 0.1. Via entries can carry a per-waypoint width for exactly
this case, reason in a comment beside the entry.

Both drills are generator-owned, so both changes live in generate_from_routes
and the regenerated files differ by exactly the two intended lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
Upstream grew Cmd::Teleport a vel field without a serde default, so an engine
past fed5e36 times out every frame this runner sent — T1 died on its first
teleport while T2, which never places a bot, measured happily on the same
socket. Zero is the engine own documented plain placement, and engines from
before the field ignore unknown fields — verified live against both 817849a
and the new main on the lab rig. Optional trailing coords pass a real
velocity through, for reproducing a moving start rather than a position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
Everything else in the suite runs deterministically from one command, but two
pieces of today lived outside it. tools/cargo_summary.py turns cargo test
output into the summary t0-import expects — without it, T0 needs a person to
hand-build the JSON, which is the one step an unattended run could not do.
dashboard/verify_against_evidence.py reads the RUNS JSON back out of the built
page and compares it field by field against the raw envelopes: never trust
the pipeline, read the output. It accepts exactly two presentation
transforms — the Swedish verdict labels and the ladder padding its unplayed
rungs — and everything else must match, 120 fields on the current column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
…, and every broken fixture is held to its promise

Three findings from the independent review of this branch, each fixed with
the fixture that was missing when it slipped through.

The blocker: the validator still said a quick run has exactly three attempts
per drill, written before drills could pin their own quick count — so the
first --quick run to reach ring_to_ratop (pinned to five) would have had its
complete envelope demoted to failed at write time, aborting the tier. The
rule is now the floor alone: quick never grades a drill on fewer than three.
The exact count stopped being checkable the moment pins moved into scenario
files the envelope deliberately does not embed; what remains is `of` equal to
the attempt count (already enforced) and the schema rejecting a pin above the
full count at load. No fixture exercised the quick regime at all, which is
how this survived — t1_quick_pinned.json is surgery on a real quick envelope
from the rig, ring_to_ratop extended to its five-attempt pin with every
dependent field recomputed, and t1_quick_two_attempts.json proves the floor
still fires. Verified live as well: a --quick run against the current column
completes, ring_to_ratop on five attempts, envelope validates.

The hole: an abandoned attempt with min_possible_s present but null passed
validation — only the absent key was rejected. An abandoned attempt exists
because a bound was computed; null is the same thrown-away knowledge wearing
the key. Rejected now, with its own fixture.

The rot guard: the selftest accepted any rejection of a broken fixture, so a
validator edit could make a fixture fail on an earlier, unrelated check and
stay green. schema/fixtures/broken/expected.json pins each fixture to a
fragment of its promised message; a fixture failing for the wrong reason now
fails the suite, and so does a pin without a fixture.

Review: independent session on a different model tier, read-only, ran the
offline gates itself; findings verified by mutation before being reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsgHA2RQVwyQ9ArJquyNJi
Define measured, complete, green, and published outcomes; require semantic gates for every tier; and document pinning, evidence, retraction, remote verification, and restoration.

Add an independent T2 powerup watcher that rejects dirty preflight state and disagrees loudly with overwritten envelope values. Teach dashboard readback to verify failed tiers, floors, and metric sources.

Co-Authored-By: OpenAI GPT-5.6 <noreply@openai.com>
dm3 links 34342 (v_req 304) and 34356 (v_req 296) are chained speed jumps
that need carried speed from the leg before them -- they have no runway
of their own. Both v_req values sit under MAX_SPEED (320), and the banded
planner's start band always floors at BAND_FLOOR[0] == MAX_SPEED
regardless of the bot's real speed, so a genuinely stationary bot reads
as already satisfying either jump's SJ_MARGIN threshold. Measured: 0%
success flying either from a stand start, 80-100% with any carried
speed, and a live 600s capture attributed 37 of the map's ~300 BotStall
firings to these two links -- every failed attempt re-fires the stall
watchdog into a repath that reselects the same infeasible link.

Add NavGraph::chain_entry_exclusions: for a cell a route search is about
to start (or restart) from, yield the chained speed-jump links leaving
it whose v_req the bot's actual current speed can't come close to
(under half). rtx-game's steer loop folds the result into LinkCosts's
existing failed-link-penalty surcharge for that one A* call only, then
replans -- a bot who reaches the same cell later carrying real speed is
untouched, since the surcharge is never persisted past the search that
built it.

Gated on rtx_bot_chain_entry_gate (bool, default true).

Verified live on dm3 (control port 27994, rtx-jumpcost-server): with the
gate on, 20 stationary trials on each link selected the tested link as
the first leg 0/20 times (both), with 18/20 and 17/20 still arriving via
an alternate route; a 600s live BotStall capture collapsed the two
links' stalls from 37 (baseline) to 2, with total map-wide firings not
up (304 to 289) and no new stall hotspot. Carried-speed trials still fly
34342 at 100% (20/20) and 34356 at its baseline rate. With the cvar
forced off, stationary trials reproduce the original bug exactly (the
tested link chosen as the first leg, ending in displacement or
speedjump_stall).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The plan-time gate (89b2278) only ever sees a chained speed jump when it
is a route's *first* leg from the bot's cell at the moment the route was
planned. A route shaped [leg0: ordinary walk/step into the ledge cell,
leg1: the chained jump] sails straight through it untouched -- at plan
time the walk hadn't happened yet, so the chained link was never
adjacent to the bot's then-current cell. `sj_active` then engages the
instant leg 1 becomes current ("committed bhop run-up + leap") with no
check that the bot actually arrived carrying speed, so a bot that merely
walked onto the ledge commits anyway and only the existing 4s stall
watchdog ever notices -- four seconds too late.

A dual-instrument live capture (passive raw listener + the T2 runner,
same physical 600s window, counting identically) attributed 58 ring
stalls (links 34342/34356) to a gated T2 run: 53 at speed <148, and 52
specifically at route_pos == 1 (38 displacement + 14 speedjump_stall).
T2's synced item-rush free-play triggers this shape far more than
organic play (bots queue at the ledge, walk on, and try the chained leg
from whatever speed the walk left them with), which is also why the
first pass's 600s stall_capture (~2 ring stalls) and the T2 runs (36-54)
disagreed so sharply -- they were measuring different traffic patterns
of the same underlying bug.

Add NavGraph::chain_entry_blocked (the shared predicate, now factored
out of chain_entry_exclusions) and NavGraph::chain_entry_leg_ok, which
rtx-game's steer loop calls once, at the exact frame `bot.sj.map(|c|
c.leg) != cur_leg` -- i.e. the instant a speed-jump leg becomes current,
not on every frame of an already-engaged run (which would also catch a
leg mid-flight, the carried-speed traffic this must leave alone). A
blocked transition reuses the existing penalize_leg + route.clear() +
immediate repath mechanism the watchdogs already use, without
note_stall -- nothing was attempted here to fail; diverting before the
takeoff is the fix, not a stall to log. Still gated on
rtx_bot_chain_entry_gate.

New unit test (rtx-nav): a synthetic two-leg route [walk into the ledge,
chained SpeedJump] confirms chain_entry_exclusions is blind to leg 1 (it
was never adjacent to the route's start cell) while chain_entry_leg_ok
catches it at the walk-in speed, and that a bot carrying real speed at
the same transition is left untouched. cargo test --workspace --release:
all green (rtx-game 468, rtx-nav 121, no failures anywhere).

Verified live on dm3 in T2 conditions (control port 27994,
rtx-jumpcost-server, testflow.py --config config-chaingate.toml t2,
600s each, clean server restart + 12s wait before each run):

  ring stalls (34342+34356)   total stall_firings
  v1 gate-on (89b2278, 4 arms): 36, 44, 51, 54     278, 294, 326, 332
  gate off (2 arms):             15, 39                  (unlogged)
  v2 (this commit, 2 arms):      14, 27                  303, 290

Both v2 runs land comfortably under the 40 target, below every v1
gate-on arm, and at or below the gate-off baseline -- v1's gate-on arms
were, measurably, no better than gate off; v2's are. Total stall_firings
stayed within the same 278-332 range the prior arms occupied (not up),
and neither run produced a new top-link hotspot (35537, 771/1032,
35738/36280/35311 are all pre-existing, unrelated to chained speed
jumps). Carried-speed trials (link_trial.py --from-upstream 1, 15 each)
still fly the chain: 34342 100% (15/15) direct success; 34356 53% direct
+ 40% via its documented sibling-link detour = 93% arrival, consistent
with its pre-existing approach-geometry flakiness, unrelated to this
guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The v2 leg-transition guard (2c6e008) only checks once, at the frame a
speed-jump leg becomes current. A chained jump committed at a borderline
speed -- above the loose CHAIN_ENTRY_FRAC bar (half of v_req), nowhere
near the ~1.05x SJ_MARGIN a real takeoff needs -- passes that one check
and then has, by definition, no runway to build the rest on: it has no
self-contained run-up, so `sj_hold` just holds it on the ground
indefinitely. Nothing re-checks it after commit, so it sits there until
an existing watchdog eventually notices.

Mapping the execution flow (as asked) turned up which watchdog gets
there first, and why: `STUCK_TIME` is 0.7s -- the generic stuck/
displacement watchdog, which doesn't care about leg kind at all -- while
the sj-specific stall watchdog waits a full 4.0s. A chained hold never
survives to the 4s mark; the 0.7s one wins every time, which is why a
second dual-instrument capture against the v2 guard (deployed correctly
this time -- see the note on the prior turn's mistake below) still found
21 ring stalls, 20 of them under half of v_req, 13 specifically at
route_pos == 1, mostly `displacement` rather than `speedjump_stall`.

Other paths considered and ruled out as live contributors: `hook_active`/
`rj_active` can independently force `sj_active` false even while `kind`
is SpeedJump (they're OR'd with a separate Objective flag, not derived
solely from the leg kind) -- but in that state the bot isn't attempting
the chained jump at all, so it isn't a source of ring-attributed
low-speed stalls. The stuck watchdog's `force_jump` unstick path (seen
in the v1 data) can land a bot onto the ledge from an unpredictable
trajectory, but however it arrives, `cur_leg` becoming the chained leg
still runs through the same guards -- no separate gap there either.

Add `chain_entry_hold_expired` (pure timing/state predicate, split from
the graph lookup the same way `chain_entry_leg_ok` splits from
`chain_entry_exclusions`, so it's directly testable) and call it every
tick while `sj_active`: committed, grounded (never mid-leap -- diverting
an airborne bot is meaningless), past a 0.3s settling grace (comfortably
under the 0.7s stuck watchdog it has to beat), and still blocked at the
bot's real speed. Same penalize+repath mechanism, no `note_stall` --
still a diversion before a takeoff was attempted, not a failure.

On the repath-after-effects question (can the very next repath just
reselect the same link?): `chain_entry_exclusions` already runs at
plan time from the bot's actual current cell, which for this divert is
the chained link's own `from` cell -- so as long as speed is still low
at the next repath, the full-strength exclusion (not just the smaller
persistent failed_links surcharge) applies again immediately. Live data
confirms this isn't a live leak: of 495 combined stall events across the
two acceptance runs below, only one bot in one ~8s episode (11 events,
all route_pos 0, all one bot standing at one cell circle-strafing with
wildly swinging speed readings -- 4.6 to 200.9 ups within the same
frozen position) showed anything resembling a loop, and it reads as a
pre-existing "wedged against geometry" stuck-bot case the generic 0.7s
watchdog was already handling on its own terms, not a chain-entry-gate
regression.

New unit test (rtx-game): `chain_entry_hold_expired` fires only when
grounded, past grace, and blocked -- not before grace, not while
airborne, not once the bot has actually built the speed, and never with
no commit at all. cargo test --workspace --release: all green
(rtx-game 469, rtx-nav 121).

Process note on the prior turn's verification: the T2 x2 numbers
reported for 2c6e008 were measured against a stale, undeployed binary --
`qwprogs.so` was never re-copied after that build, so those runs
actually re-tested 89b2278 against itself. The digest_md5 recorded in
every one of those evidence files (14a46edf) matches all four v1 arms
exactly, which is how this was caught. This commit's own verification
confirmed the deployed binary's md5 (d2a8402e) against a fresh build of
this exact tree before every run below.

Verified live on dm3 (control port 27994, rtx-jumpcost-server) with the
dual-instrument method (a passive raw BotStall listener plus
testflow.py's own T2 aggregate, same physical 600s window, counting
independently), clean server restart + navmesh-ready wait before each,
listener started ~3s ahead of the runner:

  run  ring total  ring <148  route_pos mix        total stall_firings
  1    11          9          {0: 11}               295
  2    0           0          {}                     252 (evidence: 251/252, off-by-one instrument boundary)

Run 1's 11 are the single stuck-bot episode described above (not a
systemic chain-entry failure); run 2 is clean. Both comfortably read as
"~0, isolated ok" against the acceptance bar, both totals sit in or just
under the reference 260-335 range (the lower one is not a concern -- it
reads as ordinary run-to-run T2 variance, which the coordinator's own
prior message already flagged as larger than the effect being measured
here), and neither run produced a new top-link hotspot -- every link in
both top-10s (35537, 35738, 9692, 35344, 10778, 10454, 35627, 36022,
36017, 36280, 10085, 35311, 1032, 771) is a pre-existing, unrelated
entry from earlier captures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dsvensson
dsvensson merged commit 13c726b into qw-ctf:main Aug 1, 2026
4 checks passed
@Xerialen

Xerialen commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Claude (on behalf of Xerial):

Withdrawing this. The measured effect is real at the two links it targets, but
the evidence does not carry the rest of the claim: most of the map-wide drop is
unattributed, no test drill improves, and the underlying planner behaviour is
worked around rather than fixed. Reopening only if it comes back with evidence
that holds.

Posted by Claude via @Xerialen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants