fix(nav): stop entering chained speed jumps from a standstill - #14
Merged
Conversation
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>
Contributor
Author
|
Claude (on behalf of Xerial): Withdrawing this. The measured effect is real at the two links it targets, but Posted by Claude via @Xerialen. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
maindirectly.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_reqsits under 320 therefore reads as already-satisfied at band 0 to a botstanding dead still. dm3 has exactly two: 34342 (
v_req304) 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 thatthe bot's real speed cannot carry. Folded into
LinkCosts::penaltiesfor oneA* 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 becomescurrent. 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 iscommitted 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_holdjust holds it there until the generic 0.7s stuck watchdog wins.rtx_bot_chain_entry_gate(bool, default true). Diversionsdeliberately do not call
note_stall— nothing was attempted, so nothingfailed.
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 afresh build before the series and recorded in each envelope) — the only
difference between arms is the cvar.
Totals
The distributions do not overlap: the worst ON run beats the best OFF run.
The links that move
v_req304)v_req296)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.
v_req419, has its own run-up). All 92 ofits gate-on stalls are
prestrafe_deficit. Diverting ring traffic sends morebots past it, and a pre-existing approach bug there fires proportionally more
often. This branch does not touch that bug.
air_commit_off— combat pre-empting the jump, which iscorrect 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:
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 newunit tests, one per guard, each reproducing the specific hole its guard closes.
What is wrong with this branch
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 beenchased further.
2c6e008's commit message contains numbers measured against a stalebinary. The build was never deployed to the rig before those runs, so they
re-tested
89b2278against itself. Caught by comparing thedigest_md5recorded in each evidence envelope.
a84836bdocuments the correction; thewrong numbers are left in the history rather than rewritten.
v_reqthreshold is coarse and deliberately far below the ~1.05×SJ_MARGINthe 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 fmtwas not run; rustfmt is not installed for the toolchain onthe measuring host and installing it was out of scope. Style was matched by
hand at 120 columns.
events, one cell, generic 0.7s watchdog cadence). It reads as a bot wedged
against geometry, pre-existing, but it was not proven unrelated.
Reverting is one cvar:
rtx_bot_chain_entry_gate 0.Posted by Claude via @Xerialen.