Skip to content

fix(rt-detr-v2): make needs_sanitize independent of HashMap order - #1281

Merged
inureyes merged 2 commits into
mainfrom
fix/issue-1276-needs-sanitize-order
Aug 22, 2026
Merged

fix(rt-detr-v2): make needs_sanitize independent of HashMap order#1281
inureyes merged 2 commits into
mainfrom
fix/issue-1276-needs-sanitize-order

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

needs_sanitize returned from inside its weights.keys() walk as soon as either marker family was hit, so for any weight map carrying both families the verdict belonged to whichever key the randomized HashMap walk reached first. WeightMap is a std::collections::HashMap (src/lib/mlxcel-core/src/weights.rs:29) and RandomState is seeded per map instance, not per process, so the same checkpoint could sanitize on one run and not the next. This scans the full key set before deciding and then applies an explicit precedence, making the verdict a pure function of the key set.

The wrong direction is the expensive one. The module doc in the same file notes that MLX-layout checkpoints must skip the pipeline because re-running it double-transposes conv weights, so a spurious true corrupts the weights while the model still loads and still emits detections, just wrong ones.

Design decision: silent-but-deterministic, plus a warning

The issue asked whether a map carrying both families should be rejected loudly instead. It resolves toward MLX silently, with a tracing::warn!, for three reasons.

MLX-wins is the only recoverable direction, so it does not actually need an error to stay safe. If the map really was raw HF, skipping the pipeline fails loudly a moment later: RtDetrV2Model::from_weights looks up vision.backbone.* names the raw map does not have and returns Err. Running the pipeline over already-MLX weights has no such backstop, since a double-transposed conv is a shape-valid tensor that nothing downstream can flag.

Two of the six HF markers, .convolution. and .normalization., are unanchored substring tests, and the issue puts anchoring them out of scope. Making the mixed case fatal would build a hard load failure on top of a detector that is known to be over-broad, so any future false positive would refuse a checkpoint that loads correctly today. An imprecise heuristic should fail soft.

needs_sanitize is pub and returns a bare bool. Moving it to Result<bool, String> and threading the error through RtDetrV2Model::load is real API churn for a case the issue itself describes as latent rather than observed.

The tracing::warn! recovers most of what a hard error would have bought: it names one offending key from each family, which is what the issue asked a loud variant to report, and it needs no call-site change. It picks the lexicographically first match of each family rather than the first the walk meets, so the message is reproducible for the same checkpoint instead of reintroducing the ordering nondeterminism in the diagnostic. If a mixed-layout checkpoint ever shows up in practice, that warning is the evidence needed to justify promoting it to an error.

What changed

src/vision/detection/rt_detr_v2/sanitize.rs

  • needs_sanitize now accumulates has_mlx_marker / has_hf_marker across every key and decides afterwards. MLX takes precedence over HF; HF wins when only HF markers are present; a map with no markers is unchanged at false.
  • The marker tests moved into is_mlx_marker / is_hf_marker so the scan loop is visibly accumulate-only. The six HF marker strings and the two MLX prefixes are byte-for-byte unchanged.
  • New warn_mixed_layout emits the both-families diagnostic.
  • The // Early out once both decisions are unambiguous. comment is gone; it described a guard the code never implemented.
  • The trailing has_hf_marker return is now an explicit false. At that point it could only ever have been false, but it read as though it could return true.
  • New test needs_sanitize_mixed_markers_is_order_independent: 64 iterations, each building a fresh 7-key map holding both families, asserting a stable false. The map is rebuilt per iteration because RandomState varies per instance, so a single call cannot distinguish a fixed verdict from a lucky one.
  • New test needs_sanitize_marker_free_maps_are_no_ops: pins the empty-map and unrecognized-key edge cases from the issue.

No change to src/vision/detection/rt_detr_v2/model.rs: the signature stays bool, so the call site at lines 110-115 is untouched. The rename rules, the transpose, and the drop rules are untouched.

Regression test evidence

The new test was written and run against the unfixed function first. It failed as expected:

running 14 tests
...
test vision::detection::rt_detr_v2::sanitize::tests::needs_sanitize_detects_layout ... ok
test vision::detection::rt_detr_v2::sanitize::tests::needs_sanitize_mixed_markers_is_order_independent ... FAILED

failures:

---- vision::detection::rt_detr_v2::sanitize::tests::needs_sanitize_mixed_markers_is_order_independent stdout ----

thread '...needs_sanitize_mixed_markers_is_order_independent' panicked at src/vision/detection/rt_detr_v2/sanitize.rs:363:9:
assertion `left == right` failed: needs_sanitize said `true` for 27 of 64 mixed-marker maps freshly built from the same key set; the verdict must be a stable `false` and must not depend on HashMap iteration order. Observed: [false, false, false, false, false, true, false, false, true, true, false, false, false, true, false, false, false, false, false, true, true, false, false, true, true, false, false, true, false, false, true, false, false, true, true, false, true, false, true, true, false, true, true, false, false, true, true, true, false, true, true, false, false, false, true, true, false, false, true, false, true, false, true, false]
  left: 27
 right: 0

test result: FAILED. 13 passed; 1 failed; 0 ignored; 0 measured; 5859 filtered out; finished in 0.37s

27 of 64 is close to the 3/7 predicted by the key mix, which is what a first-key-wins walk over that map should produce. Note that needs_sanitize_detects_layout passed in the same run: its two single-key maps cannot express the mixed case, so its green status was never evidence about this defect.

After the fix the same filter is green, and repeating it five times in a row (320 independently built maps in total) never observed a differing verdict.

Test plan

  • cargo test --profile test-fast --features cuda --lib vision::detection::rt_detr_v2::sanitize (exit 0, 14 passed)
  • Same command repeated 5 more times back to back (exit 0 each, 14 passed each)
  • cargo test --profile test-fast --features cuda --lib vision::detection::rt_detr_v2 (exit 0, 32 passed)
  • cargo fmt --all -- --check (exit 0)
  • cargo check --lib --tests --features cuda (exit 0)
  • cargo clippy --lib --tests --features cuda -- -D warnings -A clippy::err_expect (exit 0, no findings in the touched file)

One note on that last line. Plain cargo clippy --lib --tests --features cuda -- -D warnings exits 101 on main at ca114220, before this branch, on clippy::err_expect at src/multimodal/host_preprocessor_tests.rs:416. That file is byte-identical to origin/main here and this branch does not touch it. The repo's own lint gate is make verify-clippy, which runs --features metal,accelerate, and the Makefile notes there is deliberately no verify-clippy-cuda yet, so the CUDA lint path is currently ungated. Allowing only that one pre-existing lint is what makes the run above meaningful about this change; it is not a suppression added to the code.

The full make verify-test-cuda merge gate has not been run here and is left to the maintainer.

Closes #1276

`needs_sanitize` walked `weights.keys()` and returned from inside the loop as soon as either marker family was hit. `WeightMap` is a `std::collections::HashMap` and `RandomState` is seeded per map instance, so for any map carrying both families the verdict went to whichever key the randomized walk reached first, and the same checkpoint could sanitize on one run and not the next. The comment claiming an "Early out once both decisions are unambiguous" guard described a check the code never made.

The wrong direction is the expensive one: a spurious `true` runs the rename and transpose pipeline over already-transposed conv weights, and the model still loads and still emits detections, they are just wrong.

Accumulate both flags over every key first, then apply the precedence. MLX wins the mixed case, because that is the only recoverable direction: skipping the pipeline on something that was really raw HF fails loudly a moment later when `from_weights` looks up MLX names the map does not have, while running it over already-MLX weights corrupts silently with no downstream backstop. The mixed case now also emits a `tracing::warn!` naming the lexicographically first offending key of each family, so the ambiguity is visible to an operator without turning a heuristic that is deliberately over-broad (`.convolution.` and `.normalization.` are unanchored substring tests) into a hard load failure. The marker set, the caller, and the `bool` signature are unchanged.

The trailing `has_hf_marker` return is now an explicit `false`, which is what it always evaluated to at that point.

`needs_sanitize_mixed_markers_is_order_independent` builds a fresh 7-key map holding both families on each of 64 iterations, since a single call cannot distinguish a fixed verdict from a lucky one, and asserts a stable `false`. Against the unfixed function it reported `true` on 27 of 64 maps. `needs_sanitize_marker_free_maps_are_no_ops` pins the empty-map and unknown-key edge cases.

Verified with `cargo test --profile test-fast --features cuda --lib vision::detection::rt_detr_v2` (32 passed), the narrower `::sanitize` filter repeated six times back to back, `cargo check --lib --tests --features cuda`, `cargo fmt --all -- --check`, and `cargo clippy --lib --tests --features cuda -- -D warnings -A clippy::err_expect`. That one allow covers a pre-existing failure at `src/multimodal/host_preprocessor_tests.rs:416` that is present on main and untouched here.

Refs #1276
@inureyes inureyes added type:bug Bug fixes, error corrections, or issue resolutions priority:medium Medium priority area:models Model architectures, weights, loading, metadata status:review Under review labels Aug 22, 2026
@inureyes

Copy link
Copy Markdown
Member Author

Gate run notes, since the branch was cut before two commits landed on main.

The branch base (ca114220) is behind main (bf69b83e), which has since taken #1278 (documentation and benchmark scripts) and #1280 (code plus tests). To gate the tree that will actually merge rather than the older base, the commit was rebased onto bf69b83e locally and the gate was run there. The rebase was clean, and after it the branch's diff against main is a single file, src/vision/detection/rt_detr_v2/sanitize.rs (+134 / -18), which no commit on main touches. The rebase was not pushed, so this PR still shows its original commit; a squash merge onto current main produces the same tree that was gated.

make verify-test-cuda on GB10 (CUDA sm_121, Linux aarch64), rebased onto bf69b83e: 8229 passed, 0 failed, 311 ignored, 101 suites, exit 0. No link or compile errors in the log.

No cross-run delta is quoted on purpose. The previous full gate this session ran at ca114220 (8194 passed) and main has gained tests since, so the two totals are not comparable. What is checkable is this branch's own contribution: the diff adds exactly 2 #[test] functions and removes none.

cargo fmt --all -- --check and cargo check --lib --tests --features cuda are clean on the same tree.

Bilingual report for the RT-DETRv2 layout-verdict ordering fix, recorded before
the merge because this repository carries the `TECHNICAL_REPORTS/.keep-reports`
marker. Force-added since `TECHNICAL_REPORTS/` is gitignored.

Refs #1276
@inureyes inureyes added status:done Completed and removed status:review Under review labels Aug 22, 2026
@inureyes
inureyes merged commit 930fd83 into main Aug 22, 2026
8 checks passed
@inureyes
inureyes deleted the fix/issue-1276-needs-sanitize-order branch August 22, 2026 02:15
inureyes added a commit that referenced this pull request Aug 22, 2026
)

## Summary

One review sweep between 2026-08-20 and 2026-08-22 turned up seven independent instances of a single defect class across five modules, and no part of the toolchain flagged any of them. The class is a `HashMap` iteration result becoming ordered state, or feeding a consumer that is sensitive to order. This writes the class down in `docs/code-guidelines.md`, as a sibling to "JIT Kernel Cache Keys" and in the same shape, and records a measured decision to decline the static check that #1287 left open.

## What changed

- `docs/code-guidelines.md`: new "HashMap Iteration Order" section, additive, with the existing three sections untouched. It states the rule with a wrong/right example drawn from the actual code, then a `**Why this matters:**` block, three subsections, and an `**Enforcement:**` block.
- `docs/README.md`: entry 20 extended so the docs index names the new section, matching how it already enumerates the file's other sections.

The section covers:

- **Per-instance, not per-process.** `RandomState` seeds each map instance, so a fixed binary on a fixed machine does not see a fixed order. Re-derived here with `rustc -O` 1.97.1: ten `HashMap<&str, u32>` built from the same five keys inside one process, over 200 processes, gave 10 distinct orders out of 10 in 127 processes, 9 in 62, and 8 in 11. Not one process in 200 had the ten maps agree. (The issue's figure was 133/52/15; the split moves between measurement runs because the experiment is itself random, and the invariant that the ten never agree does not.)
- **What counts as an order-sensitive consumer.** Positional indexing, `min_by_key` / `max_by_key` (both return the FIRST extremum, so they tie-break on input order), a prefix via `take` or an early `break` or `return`, a loop-carried accumulator such as a seed advanced once per element, and anything documented as a priority order.
- **The stable-sort subtlety**, which gets the most space. `slice::sort_by_key` and `slice::sort_by` are stable, so a sort does not make a `HashMap`-derived list deterministic unless the key is a total order over the elements. `sort_unstable_by` is not a remedy either; it substitutes a different arbitrary tie order. Three of the seven instances (#1286, PR #1288) sorted and were still nondeterministic. Three correct total-order remedies are given, taken from the real fixes.
- **The regression-testing rule**, because every fix in this family needed it: a fresh map per iteration and at least 32 iterations; a deliberately constructed tie, since distinct keys already give a total order and such a test passes without the fix; and equal timestamps written in by hand, since `Instant::now()` never collides on its own at nanosecond resolution. PR #1281's pre-fix run failed on 27 of 64 freshly built maps, which is exactly why a single-shot test would have passed and shipped nothing.
- **The `BTreeMap` tradeoff**, and why #1277 kept its `HashMap` and sorted in the accessors instead.
- **The seven instances as evidence**: #1265 (PR #1266), #1267 (PR #1269), #1276 (PR #1281), #1277 (PR #1284), and the three in #1286 (PR #1288).

## Part 2: the static check is declined, with the numbers

Two candidates were prototyped in the shape of `scripts/ci/check_kernel_dtype_keys.py` and run over all 1,248 `.rs` files under `src/`, against the current tree and against the reconstructed pre-fix tree (`git archive <fix>^ src`) of each of the five fixes.

**`min_by_key` / `max_by_key` on an unordered-map receiver.** 4 flags on the current tree, 0 false positives: `src/server/prompt_cache/store.rs:315` and `:336`, `src/server/responses_store.rs:243`, `src/server/conversation_store.rs:151`. Correctly excludes `src/distributed/pipeline/partition_balance.rs:126` and the other slice receivers. But its catch rate against the seven known instances is **0 of 7**: all five pre-fix trees produced those same four flags and nothing else, because none of the seven used `min_by_key` or `max_by_key`. Its four hits are also lint true positives and not live defects: every key is a `std::time::Instant` stamped once per call, so at nanosecond resolution the tie is not reachable. Gating on it would mean suppressing 4 of 4 findings on the day it landed, and a suppression comment does not re-arm on the change that would make the pattern real (the key becoming coarser), which is the only scenario in which it pays.

**A `Vec` built from an unordered-map view and consumed order-sensitively.** 22 flags on the current tree. It flags all three #1286 sites *after* PR #1288 fixed them, because the fix keeps the shape and changes only whether the sort key is total, which is a distinction no regex can draw. Its 3-of-7 catch rate held only against pre-fix trees; on a tree where the class has been fixed those three become permanent false positives, so it can never be a gate. It reaches none of the four separately filed instances, each for a different structural reason.

A tree-wide scan for `type X = HashMap<...>` does remove the type-alias blind spot the issue names as the biggest recall limit (it resolves `WeightMap`, `Memo` and `DetachedMap`), so that one limit is fixable. The other three are not at this level of analysis, and the general form is undecidable from source alone because the consumer is often in another module. Both numbers and the reasoning are recorded in the Enforcement block so the next person does not re-derive them.

## Test plan

- [x] `make verify-fmt` exits 0 (docs-only change; no Rust or build file is touched, and no script was added, so no other gate applies).
- [x] `grep -n '^## ' docs/code-guidelines.md` shows a fourth section, `HashMap Iteration Order` at line 109, with the existing three unmoved.
- [x] `grep -n 'code-guidelines' docs/README.md CONTRIBUTING.md .github/PULL_REQUEST_TEMPLATE.md` exits 0; the index entry at `docs/README.md:36` now names the new section.
- [x] The relative link `../scripts/ci/check_kernel_dtype_keys.py` resolves, matching the JIT section's existing link.
- [x] Every issue and PR number, accessor name, test-loop constant, `Makefile:604-616` line range and the 27-of-64 figure cited in the section was checked against the tree or the PR body rather than copied from the issue.

## Note on the issue body

The issue describes #1286 as open and its three instances as "still unfixed at `main`". They were fixed by PR #1288, merged after the body's last edit, and `main` at `9f4f6516` is that merge. The section is written against the fixed state, and that is what makes the second candidate rule's limitation visible.

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

Labels

area:models Model architectures, weights, loading, metadata priority:medium Medium priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(rt-detr-v2): needs_sanitize returns on the first marker found in HashMap order, so its verdict is order-dependent

1 participant