fix(rt-detr-v2): make needs_sanitize independent of HashMap order - #1281
Conversation
`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
|
Gate run notes, since the branch was cut before two commits landed on The branch base (
No cross-run delta is quoted on purpose. The previous full gate this session ran at
|
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
) ## 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
Summary
needs_sanitizereturned from inside itsweights.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 randomizedHashMapwalk reached first.WeightMapis astd::collections::HashMap(src/lib/mlxcel-core/src/weights.rs:29) andRandomStateis 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
truecorrupts 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_weightslooks upvision.backbone.*names the raw map does not have and returnsErr. 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_sanitizeispuband returns a barebool. Moving it toResult<bool, String>and threading the error throughRtDetrV2Model::loadis 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.rsneeds_sanitizenow accumulateshas_mlx_marker/has_hf_markeracross 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 atfalse.is_mlx_marker/is_hf_markerso the scan loop is visibly accumulate-only. The six HF marker strings and the two MLX prefixes are byte-for-byte unchanged.warn_mixed_layoutemits the both-families diagnostic.// Early out once both decisions are unambiguous.comment is gone; it described a guard the code never implemented.has_hf_markerreturn is now an explicitfalse. At that point it could only ever have beenfalse, but it read as though it could returntrue.needs_sanitize_mixed_markers_is_order_independent: 64 iterations, each building a fresh 7-key map holding both families, asserting a stablefalse. The map is rebuilt per iteration becauseRandomStatevaries per instance, so a single call cannot distinguish a fixed verdict from a lucky one.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 staysbool, 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:
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_layoutpassed 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)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 warningsexits 101 onmainatca114220, before this branch, onclippy::err_expectatsrc/multimodal/host_preprocessor_tests.rs:416. That file is byte-identical toorigin/mainhere and this branch does not touch it. The repo's own lint gate ismake verify-clippy, which runs--features metal,accelerate, and the Makefile notes there is deliberately noverify-clippy-cudayet, 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-cudamerge gate has not been run here and is left to the maintainer.Closes #1276