Skip to content

feat(eval): traffic-light acceptance gate for planner checkpoints - #391

Open
danielsanchezaran wants to merge 4 commits into
tier4-mainfrom
feat/ego-shape-acceptance-gates
Open

danielsanchezaran wants to merge 4 commits into
tier4-mainfrom
feat/ego-shape-acceptance-gates

Conversation

@danielsanchezaran

@danielsanchezaran danielsanchezaran commented Sep 3, 2026

Copy link
Copy Markdown

A pre-deployment check on a planner checkpoint: does it plan to go on green and hold on red? Seconds of compute, no closed-loop simulation.

Why this is not just a plan-length threshold

Open-loop trajectory error detects neither failure. A model can hold a standstill plan at signalised geometry and still score normally on displacement metrics; a model that drives through stop lines scores normally too, because the recorded future it is compared against is a car that stopped for perfectly ordinary reasons.

Grading scenes taken across a light cycle with one span threshold is actively wrong: frames where standing still is correct get marked as stalls, and in the same breath a model that runs reds is scored as healthy, since running a red produces exactly the long confident plan the threshold rewards. So scenes are grouped by the traffic-light state their route carries, and each group is held to its own standard — green must commit to go, red must hold, amber is reported but never graded (easing and proceeding are both defensible).

What makes a result trustworthy

  • Both halves must be exercised. They catch opposite failures, so a set carrying only one leaves the other undetectable while still printing PASS — a green-only set cannot notice a red-runner. Such a set is refused; --allow_one_sided overrides and names the untested half in the result, which is then not a full acceptance.
  • The ego's signal is never inferred from "any red lane". route_lanes can carry a perpendicular approach that is correctly red while the ego has green. Resolving which signal governs the ego needs heading alignment and stop-line proximity, which already exists in the C++ frame filters (detect_red_light_run); rather than keep a second uncross-checked copy of that geometry, a route whose signalled lanes disagree is classified ambiguous and excluded from the verdict. Excluded counts are printed, not silent.
  • Deployable checkpoints only. A training milestone holds both raw optimizer iterates and the EMA copy, and the loader takes the raw ones. The gate refuses a milestone and prints the conversion.
  • No vacuous passes. A scene set with no signalled route fails rather than reporting PASS.

Scope

This PR is the traffic-light gate alone. Four ego-dimension diagnostics were in an earlier revision and have been removed: they were written to root-cause one specific corpus problem, and belong with that campaign's assets rather than in shared tooling nothing else exercises. The review finding against one of them (sweep spread printed but not gated) was valid and has been applied where that tool now lives.

Verification

  • 15 tests, model-free: traffic-light classification reads the scene, and the verdict is a pure function of measured spans. They cover green-only and red-only refusal, the one-sided override, that the override relaxes coverage and never thresholds, that a red-runner fails, that mixed-state routes are excluded while agreeing lanes classify normally, and that a milestone checkpoint is refused.
  • Validated against two deployed checkpoints on a 30-scene set spanning a light cycle (23 green / 5 amber / 2 red, 0 ambiguous): reproduces their recorded acceptance numbers exactly, unchanged across the scrub and every review fix.
  • radon: no function above B. ruff, ruff-format and pre-commit clean.

Pre-deployment checks for a failure open-loop metrics do not detect: the model
plans a standstill on geometry it should drive through, at some values of the
ego_shape input. When a corpus mixes platforms whose speeds also differ, the
token becomes the cheapest predictor of speed and gets read as a speed prior
rather than as geometry; the resulting model can score normally on displacement
error while being undeployable.

The traffic-light gate is the one to run per checkpoint: grading scenes that
span a light cycle with a single threshold marks red frames as stalls AND
scores a model that runs reds as healthy, so scenes are grouped by the state
their own route lanes carry. Amber is reported, not gated. The remaining tools
locate the confound in a corpus, separate token-driven stops from real ones,
and identify which input group holds the plan.

Every entry point refuses a training milestone that still carries EMA weights,
matching the convention in the recovery evals.

@danielsanchezaran danielsanchezaran left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking review: the implementation is clean mechanically, but the two acceptance gates can still report PASS without establishing their stated invariants. I reproduced both cases below. Otherwise, all 11 focused tests passed on the PR head and GitHub merge ref; Ruff, formatting, compilation, CLI imports, and pre-commit also passed. There are no GitHub checks attached to this head.


def verdict(spans: dict[str, list[float]], min_green_m: float, max_red_m: float) -> list[str]:
"""Gate failures, empty when the checkpoint passes."""
if not spans["green"] and not spans["red"]:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Require both green and red coverage before reporting PASS. This guard only rejects the input when both buckets are empty. I reproduced that a green-only set and a red-only set each make verdict() return no failures, so main() prints PASS even though half of the stated safety contract was never tested. In particular, a green-only set cannot detect a checkpoint that runs red lights. Please require both buckets, or make a deliberately one-sided mode explicit in the CLI and result.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a497bc3 — you're right, and this was the failure the gate exists to catch. verdict() only refused when BOTH buckets were empty, so a green-only set printed PASS with the red half never exercised, and a green-only set is precisely what cannot detect a red-runner. Both halves are now required by default; --allow_one_sided grades what is present, names the untested half in the printed result (PASS (red half NOT tested — not a full acceptance)), and relaxes coverage only, never the thresholds. Four regression tests cover green-only, red-only, the override, and that a red-runner still fails under the override.


def report_scene(path: str, wheelbases: list[float], sweep: list[float], min_span: float) -> bool:
"""Print one scene's sweep; returns True when it fails the gate."""
bad = [s for s in sweep if s < min_span]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Include the sweep spread in the gate verdict. bad only tracks the absolute span floor. For example, report_scene(..., wheelbases=[2.75, 4.76], sweep=[16.0, 40.0], min_span=15.0) prints a 24 m spread followed by pass and returns False. That accepts a strong geometry-dependent speed change even though this command's stated invariant is that planned distance must not depend on the dimension token. Please add a configurable maximum-spread criterion, or rename/document this as a standstill-only gate.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and accepted — the docstring promised span independence while the verdict only checked the floor, so a 24 m spread passed. This file is no longer in the PR: the four ego-dimension diagnostics were written to root-cause one specific corpus problem and are being kept with that campaign's assets rather than in shared tooling nothing else exercises. The PR is now the traffic-light gate alone. The spread criterion is being applied to that tool where it lives, so the finding is not lost.

Review P1: verdict() only refused a scene set when BOTH buckets were empty, so a
green-only set reported PASS while the red half went untested — and a green-only
set is exactly what cannot catch a checkpoint that runs red lights. Both halves
are now required; --allow_one_sided grades what is present and names the untested
half in the result, which is then not a full acceptance. Four regression tests
cover green-only, red-only, the override, and that the override relaxes coverage
rather than thresholds.

The four ego-dimension diagnostics are dropped from this PR. They were written to
root-cause one specific corpus problem and belong with that campaign's assets, not
in shared tooling where nothing else exercises them. Review P2 (sweep spread not
part of that gate's verdict) is valid and is fixed there rather than here.

@danielsanchezaran danielsanchezaran left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up verdict: one blocker remains on the narrowed traffic-light gate. The earlier missing-phase issue is fixed and the four removed diagnostics leave no stale code or README references. All 15 focused tests passed on both the current head and GitHub merge ref; Ruff, formatting, compilation, CLI help, and pre-commit also passed. There are no GitHub checks attached. Separately, please update the PR title and description: they still advertise five diagnostics and 11 tests, while this head contains only the traffic-light gate and 15 tests.

if not hot.any():
return "none"
present = {_CLASSES.get(int(c)) for c in onehot[hot].argmax(axis=1)}
for state in ("red", "amber", "green"):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Resolve the controlling signal instead of prioritizing any red segment. This precedence is not valid for the standard route tensor. The existing converter/filter code explicitly notes that route_lanes can include perpendicular red approaches while ego has green (see frame_filters.hpp). I reproduced a route with one valid green segment and one valid red segment being classified as red; measure() will therefore put that ego-green scene in the red bucket and flag a normal plan over 2 m as running the light. Please select the ego-relevant signal using heading/proximity, as the existing detectors do, or reject mixed-state scenes as ambiguous.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14bd622 — thank you for the pointer to frame_filters.hpp, that comment states the problem better than I could have. The red-wins precedence was my own design choice and it was wrong: a perpendicular red would put an ego-green scene in the red bucket, where a normal plan is then reported as running a light the ego never faced.

I did not port the heading/proximity resolution. That geometry already exists in detect_red_light_run, and a second Python copy with nothing cross-checking it is how the two drift apart — so I took your other option. A route whose signalled lanes disagree is now classified ambiguous and excluded from the verdict, alongside scenes with no signalled lane; both counts are printed so the excluded set is visible rather than silent. Fewer graded scenes, no invented answer.

The test that asserted the old precedence encoded the bug, so it now asserts exclusion and that agreeing lanes still classify normally. Bucketing reads only route_lanes, so this is model-independent: on the 30-scene reference set it yields 0 ambiguous and the split is unchanged at 23/5/2, meaning the previously reported acceptance numbers are unaffected — the defect would have bitten at multi-approach intersections, which that set does not contain.

PR title and description updated to match this head (traffic-light gate only, 15 tests).

Review P1: route_lanes is not one signal. At an intersection it can carry a
perpendicular approach that is correctly red while the ego's own approach is
green, so red-wins precedence put ordinary green scenes in the red bucket, where
a normal plan is then reported as running a light the ego never faced.

Resolving which signal governs the ego needs heading alignment and stop-line
proximity, which already exists in the C++ frame filters (detect_red_light_run).
A second, uncross-checked Python copy of that geometry is how the two drift
apart, so this takes the other option offered in review: a route whose signalled
lanes disagree is classified ambiguous and excluded from the verdict, alongside
scenes with no signalled lane. Both counts are printed so the excluded set is
visible rather than silent.

The test that asserted the old precedence encoded the bug; it now asserts
exclusion, and that agreeing lanes still classify normally. Bucketing reads only
route_lanes, so this is model-independent: on the 30-scene reference set it
yields 0 ambiguous and the split is unchanged at 23 green / 5 amber / 2 red.
@danielsanchezaran danielsanchezaran changed the title feat(eval): ego-dimension acceptance gates feat(eval): traffic-light acceptance gate for planner checkpoints Sep 4, 2026

@danielsanchezaran danielsanchezaran left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up approval on 14bd6228d: both previously reported blockers are resolved. The gate now requires green and red coverage by default, and mixed route-lane signal states are classified as ambiguous instead of assigning an unrelated red approach to the ego. The removed campaign-specific diagnostics left no stale shared-tool references, and the PR title/description now match the narrowed traffic-light-gate scope.

I found no remaining correctness issues. All 15 focused tests passed on both this exact head and the GitHub merge ref; Ruff, formatting, compilation, CLI help, and pre-commit also passed. There are no GitHub checks attached to this head. Looks good to merge from code review.

Posting as a comment because GitHub does not allow approving your own PR.

A mean is not enough to read this gate. Two cases it hides:

A class whose scenes all return the same span is one situation sampled many
times, not many independent samples -- its n overstates the evidence. And a
class that splits into 'drives' and 'stands still' can average out above the
threshold while a chunk of its scenes are collapsed: 18 scenes at 20 m and 5 at
1 m means 15.87 m, which clears a 15 m floor with 5 failures inside it.

Both are visible in min/median/max and invisible in the mean, so print all four,
and flag a class whose spread is under a centimetre. Compared with a tolerance
rather than for equality: repeated measurements of one situation still differ in
the last decimals, so an equality test never fires on real data.

Verdicts are unchanged -- the gate still grades on the mean, so existing results
reproduce exactly. This only makes the evidence behind them legible.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant