Skip to content

fix(server): wire --dry-sequence-breaker through to the sampler - #1128

Merged
inureyes merged 4 commits into
mainfrom
fix/issue-1103-wire-dry-sequence-breakers
Aug 13, 2026
Merged

inureyes merged 4 commits into
mainfrom
fix/issue-1103-wire-dry-sequence-breakers

Conversation

@inureyes

@inureyes inureyes commented Aug 13, 2026 •

Copy link
Copy Markdown
Member

Summary

Both server binaries expose --dry-sequence-breaker, and the value flowed from the CLI into ServerStartupConfig and stopped there. There was no tokenization step, no default_dry_sequence_breakers on ServerConfig, no fallback in build_server_generate_options, and no /props entry. An operator got a clean startup, a flag accepted without warning, and DRY matching that ran straight through the breakers they configured.

Related issues

Closes #1103

Type of change

  • fix (bug fix)

Why wiring it rather than removing it

The issue offered both. Wiring wins because llama.cpp compatibility is a stated goal and the flag sits in the help text of two binaries, and because the failure was silent in both directions: nothing told the operator the flag was inert, and nothing told them the penalty was stronger than they configured. That second half is the part worth stressing. The breakers are the DRY backward match's termination condition, not a switch, so running DRY without them lets match_len grow past the intended boundary and the penalty comes out at or above what was asked for (the same mechanism as #1102, from the other end).

What changed

src/server/dry_breakers.rs (new, 136 lines). resolve_dry_sequence_breakers(tokenizer, raw) -> Result<Vec<i32>>, plus the private unescape_breaker. Modelled on src/server/cors.rs, which is the existing precedent in this tree for "validate an operator string at startup and name the offending value".

The resolution point. The flag takes token strings; SamplingConfig and the HTTP field take token IDs. The conversion needs the tokenizer, which is not loaded when build_server_config runs, so it cannot live there. build_server_config leaves the new field empty with a comment stating why, and run_server fills it immediately after load_tokenizer returns, then logs the resolved IDs at INFO alongside the strings they came from.

The resolver discounts the tokenizer's normalizer. Encoding a breaker on its own is the obvious implementation and is wrong for every SentencePiece-derived checkpoint: their tokenizer.json carries a Prepend "▁" normalizer, usually in a Sequence with Replace " " -> "▁". Eleven of the 162 checkpoints in the local model store have it (Mixtral, three Phi-3 variants, two MiniCPM, two LLaVA, Idefics2, InternLM3, ERNIE 4.5). On Mixtral, encode("\n") returns two tokens even though the newline is a single vocabulary entry at id 13, so startup would have refused to boot on the example in this flag's own help text. Worse, encode(" ") normalizes to "▁▁" and returns ONE token, the double-space entry at id 259: it passes the length check, startup succeeds, /props reports a plausible id, and the operator silently gets a breaker they never asked for. encode_breaker therefore encodes anchor + breaker and subtracts the anchor's own encoding, so what is measured is the breaker's contribution rather than the normalizer's fixed prefix. When the anchor merges with the breaker or encodes to nothing, the bare encoding is used.

Fail closed on anything unrepresentable. A breaker that does not encode to exactly one token fails startup with a message naming it and reporting the count it got. Single-token is not a policy choice: the sampler's check is config.dry_sequence_breakers.contains(&window[p1]) over a Vec<i32>, so a multi-token breaker has no representation at all. A string that encodes to nothing is the same class of failure and is rejected identically. A flag that was set but produced nothing usable is an error rather than a silent no-op, mirroring parse_allowed_origins.

Empty entries are skipped, blank ones are not. --dry-sequence-breaker 'a,' splits to ["a", ""] on the clap delimiter, so the trailing empty is a typo rather than a breaker. The test is is_empty(), deliberately not trim().is_empty(): a single space is a legitimate single-token breaker in most vocabularies, and trimming would silently discard it.

Escape handling, without which the flag's own documentation would fail startup. --dry-sequence-breaker '\n' reaches the process as the two characters \ and n, because no common shell expands an escape inside quotes, and the help text has advertised "\n" and "\t" as its examples since the flag was added. Reading the value literally would reject the documented usage, or worse accept it as whatever unrelated single token \n happens to encode to. \n, \t, \r and \\ are interpreted; every other backslash sequence is preserved exactly as typed, so a breaker that genuinely contains a backslash is never silently rewritten, and \\ is the escape hatch.

src/server/request_options.rs. unwrap_or_default() becomes unwrap_or_else(|| config.default_dry_sequence_breakers.clone()), matching the unwrap_or(config.default_*) shape of every neighbouring DRY field.

src/server/routes/props.rs. The payload gains dry_sequence_breakers, reported as resolved token IDs (what the sampler compares against, and what a per-request value overrides). The payload construction moves into default_generation_settings(&ServerConfig) so the reported field set can be asserted without standing up an AppState.

Diagnostics, since a resolved id cannot be checked by eye. The rejection message renders each token as "piece"=id, which is what makes a normalizer artifact visible, and no longer suggests "\n" as the remedy, since that may be exactly the value that failed. Startup logs the decoded pieces alongside the ids. The "no usable breaker" error explains that the flag splits on commas, so an operator who passed a bare comma is not told their entries were empty.

Help text on both binaries, kept byte-identical so #1109's description-parity assertion stays green. It now states that the flag sets a server-wide default a request can override, that each value must encode to one token or startup fails, and which escapes are interpreted.

Test plan

  • cargo test --profile test-fast --features cuda --lib server::dry_breakers (15 passed)
  • cargo test --profile test-fast --features cuda --lib server::routes::props (4 passed)
  • cargo test --profile test-fast --features cuda --lib server::request_options (39 passed, up from 35)
  • cargo test --profile test-fast --features cuda --test cli_help_consistency (25 passed, confirming the --dry-sequence-breaker prose stayed byte-identical on both binaries after the help-text edits)
  • cargo test --profile test-fast --features cuda --lib server::cli_input (93 passed)
  • cargo test --profile test-fast --features cuda --lib execution::sampling (3 passed)
  • cargo clippy --profile test-fast --features cuda --lib --bins --tests -- -D warnings clean
  • cargo fmt --all -- --check clean
  • cargo test --workspace --profile test-fast and cargo deny check not run here; no dependency changes.
  • No real-checkpoint validation. The resolver is exercised against a purpose-built vocabulary instead (see below); the acceptance criteria are all at the configuration boundary.

On the test fixture

MlxcelTokenizer::stub_with_byte_fallback() turned out unusable here: with no BPE merges, Hello tokenizes to nothing rather than to its vocabulary entry, so it cannot express "exactly one token". The tests build a small tokenizer whose vocabulary is single characters (a, b, newline, tab, space), which makes all three outcomes the resolver must distinguish reachable without a checkpoint: one token, more than one, and none. fixture_tokenizer_behaves_as_the_tests_assume pins those four encodings, so a fixture change fails there with a clear message instead of making the real assertions look wrong.

That fixture was necessary and not sufficient, which is worth recording. Its encode is a pure per-character vocabulary lookup, precisely the tokenizer shape in which the normalizer defect above cannot occur. Every test passed, none of them vacuously, and the resolver was still wrong for eleven local checkpoints. A second fixture now carries the Prepend "▁" / Replace " " -> "▁" pair with merges that make ▁a and ▁▁ real entries, and prepend_normalizer_fixture_reproduces_both_bare_encoding_defects asserts the bare encoding still exhibits both defects, so the two tests guarding the fix are demonstrably guarding something.

Coverage: the escape handling at the string level and end to end through tokenization ('\n' resolving to the newline token id); the prepend-normalizer cases in both directions (a newline that must resolve rather than fail, and a space that must not resolve to the double-space token); the server default applying when a request omits the field; a per-request value overriding it; an explicitly empty request list disabling it; a serde-level assertion that an absent field and an explicit [] deserialize differently, which is the layer where they could actually collapse; the startup rejection naming the offending string, its token count, and its decoded pieces; a whitespace-only breaker surviving; and the /props payload's full key set.

Pre-existing failure, not from this PR

server::startup::muse_glimmer_startup_guard_tests::muse_glimmer_startup_allows_baseline_and_keeps_video_disabled fails when run alongside its siblings and passes in isolation. Its sibling muse_glimmer_startup_rejects_xla_backend_selection sets MLXCEL_BACKEND=xla and restores it under a crate-wide env lock the baseline test does not take, so the baseline test can observe the variable mid-window. The file was last touched by #1101 and is not in this diff.

Both server binaries expose the flag, and the value flowed from the CLI into `ServerCliInput`, into `ServerStartupConfig`, and stopped. There was no tokenization step, no `default_dry_sequence_breakers` on `ServerConfig`, no fallback in `build_server_generate_options` (the field resolved through `unwrap_or_default()` where every neighbouring DRY field uses `unwrap_or(config.default_*)`), and no `/props` entry. An operator got a clean startup, a flag accepted without warning, and DRY matching that ran straight through the breakers they configured.

The failure was silent in both directions, which is why it is worth fixing rather than removing. Nothing said the flag was inert, and because the breakers terminate the backward match rather than enable anything, running DRY without them makes the penalty stronger than configured, not weaker. Removing the flag was the documented alternative; wiring it is preferred since llama.cpp compatibility is a stated goal and the flag is in the help text of two binaries.

The wiring has to bridge a type mismatch: the CLI takes `Vec<String>` (token strings, matching llama.cpp) while `SamplingConfig` and the HTTP request field take `Vec<i32>` (token IDs). The conversion needs the tokenizer, so it cannot live in `build_server_config`, which runs before the model is loaded. `build_server_config` leaves the new `ServerConfig::default_dry_sequence_breakers` empty with a comment saying why, and `run_server` fills it immediately after `load_tokenizer` returns.

A breaker that does not encode to exactly one token fails startup with a message naming it, matching the posture `--allowed-origins` takes for a malformed origin. Single-token is not a policy choice: the sampler's check is `dry_sequence_breakers.contains(&window[p1])` over a `Vec<i32>`, so a multi-token breaker has no representation at all. A string that encodes to nothing is the same class of failure and is rejected the same way. An empty entry is skipped so a stray trailing comma is not an error, but the test is `is_empty()` rather than `trim().is_empty()`, because a single space is a legitimate breaker in most vocabularies. A flag that was set and produced nothing usable is an error rather than a silent no-op.

`--dry-sequence-breaker '\n'` reaches the process as the two characters `\` and `n`, since no common shell expands an escape inside quotes, and the flag's help text has advertised `"\n"` and `"\t"` as its examples since it was added. Reading the value literally would therefore reject the documented usage at startup, or accept it as whatever unrelated token `\n` encodes to. `\n`, `\t`, `\r` and `\\` are interpreted; every other backslash sequence is preserved exactly as typed, so a breaker that genuinely contains a backslash is never silently rewritten.

`/props` gains `dry_sequence_breakers`, reported as resolved token IDs, which is what the sampler compares against and what a per-request value overrides. The payload construction moves into `default_generation_settings` so the reported field set can be asserted without standing up an `AppState`.

Tests cover the resolver against a purpose-built single-character vocabulary that makes all three outcomes reachable without a checkpoint (exactly one token, more than one, none), the escape handling both at the string level and end to end through tokenization, the server default applying when a request omits the field, a per-request value overriding it, an explicitly empty request list disabling it (`Some(vec![])` and `None` must not collapse), the startup rejection naming the offending string, and the `/props` field set.

Validated with `cargo test --profile test-fast --features cuda --lib` on `server::dry_breakers` (11 passed), `server::routes::props` (3), `server::request_options` (38), `server::cli_input` (93), plus `execution::sampling`, `cargo clippy --profile test-fast --features cuda --lib --bins --tests -- -D warnings`, and `cargo fmt --all --check`.

Closes #1103
@inureyes inureyes added type:bug Bug fixes, error corrections, or issue resolutions priority:medium Medium priority status:review Under review labels Aug 13, 2026
…akers

Review follow-up for PR #1128, fixing a defect that would have shipped. The resolver encoded each breaker on its own and required exactly one token. That is wrong for every SentencePiece-derived checkpoint, because their `tokenizer.json` carries a `Prepend "\u2581"` normalizer, usually in a `Sequence` with `Replace " " -> "\u2581"`, so the text handed to the model is not the text passed in. Eleven of the 162 checkpoints in the local model store have it: Mixtral, three Phi-3 variants, two MiniCPM, two LLaVA, Idefics2, InternLM3 and ERNIE 4.5.

The failure had two shapes, and the second is the one that matters. On Mixtral, `encode("\n")` returns two tokens even though the newline is a single vocabulary entry at id 13, so startup would have refused to boot on the example in this flag's own help text. Worse, `encode(" ")` normalizes to two space markers and returns ONE token, the double-space entry at id 259: it passes the length check, startup succeeds, `/props` reports a plausible id, and the operator gets a breaker they never asked for with no observable surface showing it. That is the exact failure class this PR exists to remove, reproduced inside the fix.

`encode_breaker` now encodes `anchor + breaker` and subtracts the anchor's own encoding, so what is measured is the breaker's contribution rather than the normalizer's fixed prefix. When the anchor does not survive as a prefix, having merged with the breaker, or encodes to nothing, there is nothing to subtract and the bare encoding is used.

The test fixture was the reason this got as far as it did: its `encode` is a pure per-character vocabulary lookup, which is precisely the tokenizer shape where the defect cannot occur. Every test passed, none vacuously, and the resolver was still wrong. A second fixture now carries the same normalizer pair with merges that make the prefixed forms real entries, and `prepend_normalizer_fixture_reproduces_both_bare_encoding_defects` asserts the bare encoding still exhibits both defects, so the two tests guarding the fix are demonstrably guarding something.

Diagnostics, since a resolved id cannot be checked by eye. The rejection message now renders each token as `"piece"=id`, which is what makes a normalizer artifact visible, and no longer suggests `"\n"` as the remedy, since that may be exactly the value that failed. Startup logs the decoded pieces alongside the ids.

Smaller review findings: the "no usable breaker" error now explains that the flag splits on commas, so an operator who passed a bare comma is not told their entries were empty, and the help text on both binaries says the same (kept byte-identical, as #1109's parity assertion requires). A serde-level test pins that an absent `dry_sequence_breakers` and an explicit `[]` deserialize differently, which is the layer where they could collapse and where nothing was asserting. `/props` now asserts its full key set, which is what extracting `default_generation_settings` was for. Three comments named a `run_server` function that does not exist; it is `start_server`. The intra-doc link from a public field into the private `dry_breakers` module is now prose. A test comment claimed unrecognised escapes make a Windows path safe, which is not true of `C:\temp`; it now states the narrower guarantee it actually has.

Validated with `cargo test --profile test-fast --features cuda --lib` on `server::dry_breakers` (15 passed), `server::routes::props` (4), `server::request_options` (39), plus `--test cli_help_consistency` (25 passed, confirming the help prose stayed byte-identical across both binaries), `cargo clippy --profile test-fast --features cuda --lib --bins --tests -- -D warnings`, and `cargo fmt --all --check`.

Refs #1103
@inureyes inureyes added status:done Completed and removed status:review Under review labels Aug 13, 2026
@inureyes
inureyes merged commit 5d08289 into main Aug 13, 2026
8 checks passed
@inureyes
inureyes deleted the fix/issue-1103-wire-dry-sequence-breakers branch August 13, 2026 17:19
@inureyes inureyes self-assigned this Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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(server): --dry-sequence-breakers is parsed and stored but never reaches the sampler

1 participant