fix(lang-bias): preserve YAML bias: order so priority is deterministic - #1269
Merged
Conversation
`LangBiasYamlConfig::bias` deserialized into a `HashMap`, and the resolve loop pushed that map's iteration order straight into `LangBiasSet.ordered`. `ordered` is the priority order `TokenLanguageIndex::to_token_bias` resolves conflicts with under first-language-wins, and `RandomState` randomizes iteration order per map instance, so the priority order was a fresh random permutation on every load. Han is shared by `ja`, `zh` and `ko`, so a config naming two or more CJK languages assigned a different bias to every shared Han token on every run, with no error and no warning. The shipped schema example in the doc comment is itself a three-CJK config, so copying it was enough to trigger this. The only place the order surfaced was the `languages` field of the DEBUG-level `lang_bias resolved` event, which is off by default. The same `HashMap` also collapsed repeated keys during deserialization (serde_yaml resolves them last-wins with no diagnostic), which made the `seen` / `DuplicateLanguageCode` check in the resolve loop unreachable and let the YAML path silently accept input the `--lang-bias` parser rejects. `bias:` now deserializes through a `BiasEntries` newtype that collects the block with `MapAccess` into a `Vec<(String, BiasValueStr)>`. Document order survives, and a repeated key arrives as a second entry that the existing `seen` check rejects, so the YAML path and the `--lang-bias` path now agree on both ordering and rejection. No new dependency, and the accepted YAML syntax is unchanged: `bias:` is still a plain mapping, a sequence-shaped block is still a parse error, an absent or empty block still resolves to an empty set, and `--lang-bias` still replaces the YAML set entirely. `CliError::DuplicateLanguageCode`'s message no longer claims the duplicate came from `--lang-bias`, since both entry points now raise it. Four new looping regression tests in `src/lang_bias.rs`, each running 32 full `resolve()` calls because a single resolve over three languages passes by luck often enough to prove nothing. Verified load-bearing: with the `bias` field reverted to `Option<HashMap<String, BiasValueStr>>`, all four fail (order test at iteration 0 with `[(Zh, -10.0), (Ko, 5.0), (Ja, -inf)]`, the `to_token_bias` test at iteration 2 with the shared Han token taking `zh`'s `-10.0` instead of `ja`'s `-inf`, and the duplicate test resolving `ja: -1.0 / zh: -2.0 / ja: -3.0` to `[(Zh, -2.0), (Ja, -3.0)]` with no error). Three distinct permutations appeared within that one process run, confirming the randomization is per map instance rather than per process. `yaml_well_formed_parses` also now asserts order rather than map membership, which is what let this stay hidden. Verified with `cargo fmt --all -- --check`, `cargo clippy --lib --tests --features cuda -- -D warnings`, `cargo check --bins --features cuda`, and `cargo test --profile test-fast --features cuda --lib lang_bias` (30 passed). Closes #1267
Member
Author
|
That is +6 against
|
Bilingual report for the YAML lang-bias ordering fix, recorded before the merge because this repository carries the `TECHNICAL_REPORTS/.keep-reports` marker. Force-added since `TECHNICAL_REPORTS/` is gitignored. Refs #1267
This was referenced Aug 22, 2026
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
This was referenced Aug 22, 2026
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.
Summary
A
--lang-bias-configYAML file naming two or more CJK languages steered differently on every run.LangBiasYamlConfig::biasdeserialized into aHashMap, and the resolve loop pushed that map's iteration order straight intoLangBiasSet.ordered, which is the priority orderTokenLanguageIndex::to_token_biasresolves conflicts with under first-language-wins.RandomStaterandomizes iteration order per map instance, so the priority order was a fresh random permutation on every load. Han is shared byja,zhandko, so every token they share got a different bias on every run, with no error and no warning. The schema example in theLangBiasYamlConfigdoc comment is itself a three-CJK config, so copying the shipped example was enough to trigger it.The same
HashMapcollapsed repeated keys during deserialization (serde_yaml resolves them last-wins with no diagnostic), which made theseen/DuplicateLanguageCodecheck in the resolve loop unreachable and let the YAML path silently accept input the--lang-biasparser has always rejected.The accepted YAML schema does not change
bias:is still a plain mapping. Existing config files keep parsing unchanged, a sequence-shapedbias:block is still a parse error (pinned byyaml_bias_block_rejects_a_sequence), an absent or empty block still resolves to an emptyLangBiasSetrather than an error,#[serde(deny_unknown_fields)]stays, and CLI--lang-biasstill replaces the YAML set entirely.Two behavior changes are visible to users, both recorded in
CHANGELOG.md. A file that happened to rely on the random ordering now steers consistently toward its first-listed language, which is the documented intent. And a file repeating a language code now errors instead of silently taking the last occurrence.Approach
Candidate 1 from the issue's implementation notes: a hand-written
Deserializecollecting the block throughMapAccess.biasbecomesOption<BiasEntries>, whereBiasEntriesis a newtype overVec<(String, BiasValueStr)>. Document order survives, and a repeated key arrives as a genuine second entry, so the existingseencheck starts firing and the YAML path reachesCliError::DuplicateLanguageCode(the same variant, not a parallel validation path). No new dependency.serde_yaml::Mappingwas the alternative. It preserves order and rejects duplicates, but with serde_yaml's own error rather than the repo's, so the two entry points would still disagree on what a duplicate looks like.indexmapas the field type was rejected outright: it preserves order but resolves duplicates last-wins, so the dead check would have stayed dead.The
seen/orderedloop,BiasValueStr,parse_bias_f32, and theUnknownLanguageCodemapping with its"{code_str}: (from YAML)"context are all untouched.LangBiasSetandto_token_biasare untouched: they were already correct, only the YAML producer was wrong. Nothing outsidesrc/lang_bias.rsnamesLangBiasYamlConfig, so the CLIgeneratepath and the servermodel_workerpath consume the resolvedLangBiasSetexactly as before, and thelanguagesfield of the DEBUGlang_bias resolvedevent now prints the configured order.Proof the regression tests are load-bearing
The four new tests were run against the pre-fix production path (the
biasfield reverted toOption<HashMap<String, BiasValueStr>>, tests unchanged). All four fail:Three distinct permutations of the same three-key file appeared inside that single process run, which is the per-map-instance randomization the issue measured. Each ordering test performs 32 full
resolve()calls rather than one, because a single resolve over three languages comes out right often enough to prove nothing; the observed failures landed at iteration 0 and iteration 2.What changed
src/lang_bias.rs: newBiasEntriesnewtype overVec<(String, BiasValueStr)>with aMapAccess-basedDeserialize,IntoIterator, andas_slice/len/is_empty. The doc comment carries why aHashMapwas wrong on both counts.src/lang_bias.rs:LangBiasYamlConfig::biasis nowOption<BiasEntries>. The schema doc comment gains a paragraph stating thatbias:order is the priority order and that a repeated code is rejected.src/lang_bias.rs:CliError::DuplicateLanguageCode's message no longer says the duplicate came from--lang-bias, since both entry points now raise it. The variant and itscodefield are unchanged.src/lang_bias.rs: the resolve loop is unchanged apart from a comment; it now receives entries in document order with repeats intact.src/lang_bias.rstests:yaml_well_formed_parsesasserts order instead of map membership, which is the blind spot that hid this. Six new tests: three looping order/parity/to_token_biastests, a duplicate-rejection test, an empty-block test, and a sequence-rejection test pinning the schema.CHANGELOG.md: an## [Unreleased]entry covering both user-visible changes.Test plan
cargo fmt --all -- --check(exit 0)cargo clippy --lib --tests --features cuda -- -D warnings(exit 0)cargo check --lib --tests --features cuda(exit 0)cargo check --bins --features cuda(exit 0)cargo test --profile test-fast --features cuda --lib lang_bias(exit 0, 30 passed / 0 failed)biasfield type: 26 passed / 4 failed, output above.Not run here: the full
make verify-test-cudaworkspace gate.Closes #1267