Skip to content

fix(lang-bias): preserve YAML bias: order so priority is deterministic - #1269

Merged
inureyes merged 2 commits into
mainfrom
fix/issue-1267-yaml-bias-order
Aug 22, 2026
Merged

fix(lang-bias): preserve YAML bias: order so priority is deterministic#1269
inureyes merged 2 commits into
mainfrom
fix/issue-1267-yaml-bias-order

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

A --lang-bias-config YAML file naming two or more CJK languages steered differently on every run. LangBiasYamlConfig::bias deserialized into a HashMap, and the resolve loop pushed that map's iteration order straight into LangBiasSet.ordered, which is the priority order TokenLanguageIndex::to_token_bias resolves conflicts with under first-language-wins. 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 every token they share got a different bias on every run, with no error and no warning. The schema example in the LangBiasYamlConfig doc comment is itself a three-CJK config, so copying the shipped example was enough to trigger it.

The same HashMap 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 has always rejected.

The accepted YAML schema does not change

bias: is still a plain mapping. Existing config files keep parsing unchanged, a sequence-shaped bias: block is still a parse error (pinned by yaml_bias_block_rejects_a_sequence), an absent or empty block still resolves to an empty LangBiasSet rather than an error, #[serde(deny_unknown_fields)] stays, and CLI --lang-bias still 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 Deserialize collecting the block through MapAccess. bias becomes Option<BiasEntries>, where BiasEntries is a newtype over Vec<(String, BiasValueStr)>. Document order survives, and a repeated key arrives as a genuine second entry, so the existing seen check starts firing and the YAML path reaches CliError::DuplicateLanguageCode (the same variant, not a parallel validation path). No new dependency.

serde_yaml::Mapping was 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. indexmap as the field type was rejected outright: it preserves order but resolves duplicates last-wins, so the dead check would have stayed dead.

The seen / ordered loop, BiasValueStr, parse_bias_f32, and the UnknownLanguageCode mapping with its "{code_str}: (from YAML)" context are all untouched. LangBiasSet and to_token_bias are untouched: they were already correct, only the YAML producer was wrong. Nothing outside src/lang_bias.rs names LangBiasYamlConfig, so the CLI generate path and the server model_worker path consume the resolved LangBiasSet exactly as before, and the languages field of the DEBUG lang_bias resolved event 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 bias field reverted to Option<HashMap<String, BiasValueStr>>, tests unchanged). All four fail:

test lang_bias::tests::yaml_and_cli_paths_agree_on_multi_cjk_order ... FAILED
test lang_bias::tests::yaml_multi_cjk_bias_keeps_document_order_across_repeated_resolves ... FAILED
test lang_bias::tests::yaml_multi_cjk_first_language_wins_on_shared_han_tokens ... FAILED
test lang_bias::tests::yaml_duplicate_language_code_is_rejected ... FAILED

---- yaml_multi_cjk_bias_keeps_document_order_across_repeated_resolves stdout ----
assertion `left == right` failed: iteration 0: entry 0 should be Ja but the resolved order was [(Zh, -10.0), (Ko, 5.0), (Ja, -inf)]; YAML bias: order must be the priority order
  left: Zh
 right: Ja

---- yaml_multi_cjk_first_language_wins_on_shared_han_tokens stdout ----
assertion `left == right` failed: iteration 2: the shared Han token must take the first-listed language's bias (ja = -inf), resolved order was [(Zh, -10.0), (Ja, -inf), (Ko, 5.0)]
  left: Some(-10.0)
 right: Some(-inf)

---- yaml_and_cli_paths_agree_on_multi_cjk_order stdout ----
assertion `left == right` failed: iteration 0: the YAML path and the --lang-bias path must resolve equivalent input to the same LangBiasSet
  left: [(Ja, -inf), (Ko, 5.0), (Zh, -10.0)]
 right: [(Ja, -inf), (Zh, -10.0), (Ko, 5.0)]

---- yaml_duplicate_language_code_is_rejected stdout ----
a repeated language code in a YAML bias: block must be rejected: Some(LangBiasConfig { bias_set: LangBiasSet { ordered: [(Zh, -2.0), (Ja, -3.0)] }, ... })

test result: FAILED. 26 passed; 4 failed

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: new BiasEntries newtype over Vec<(String, BiasValueStr)> with a MapAccess-based Deserialize, IntoIterator, and as_slice / len / is_empty. The doc comment carries why a HashMap was wrong on both counts.
  • src/lang_bias.rs: LangBiasYamlConfig::bias is now Option<BiasEntries>. The schema doc comment gains a paragraph stating that bias: 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 its code field 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.rs tests: yaml_well_formed_parses asserts order instead of map membership, which is the blind spot that hid this. Six new tests: three looping order/parity/to_token_bias tests, 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)
  • The same test run against the pre-fix bias field type: 26 passed / 4 failed, output above.

Not run here: the full make verify-test-cuda workspace gate.

Closes #1267

`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
@inureyes inureyes added status:review Under review type:bug Bug fixes, error corrections, or issue resolutions priority:high High priority area:cli Command-line interface / CLI flags labels Aug 20, 2026
@inureyes

Copy link
Copy Markdown
Member Author

make verify-test-cuda on GB10 (CUDA sm_121, Linux aarch64) at 6f934905: 8194 passed, 0 failed, 310 ignored, 101 suites, run to completion through the doc-test units.

That is +6 against main at fdc19666 (8188 passed), and the diff adds exactly 6 #[test] functions and removes none, so the totals reconcile.

cargo fmt --all -- --check, cargo clippy --lib --tests --features cuda -- -D warnings, cargo check --lib --tests --features cuda and cargo check --bins --features cuda are clean on the same tree.

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
@inureyes inureyes added status:done Completed and removed status:review Under review labels Aug 22, 2026
@inureyes
inureyes merged commit ca11422 into main Aug 22, 2026
8 checks passed
@inureyes
inureyes deleted the fix/issue-1267-yaml-bias-order branch August 22, 2026 01:12
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:cli Command-line interface / CLI flags priority:high High 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(lang-bias): the YAML bias map is a HashMap, so language priority is randomized per process

1 participant