Repository navigation
fix(server): wire --dry-sequence-breaker through to the sampler - #1128
Merged
Merged
Conversation
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
…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
9 of 14 tasks
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
Both server binaries expose
--dry-sequence-breaker, and the value flowed from the CLI intoServerStartupConfigand stopped there. There was no tokenization step, nodefault_dry_sequence_breakersonServerConfig, no fallback inbuild_server_generate_options, and no/propsentry. 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_lengrow 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 privateunescape_breaker. Modelled onsrc/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;
SamplingConfigand the HTTP field take token IDs. The conversion needs the tokenizer, which is not loaded whenbuild_server_configruns, so it cannot live there.build_server_configleaves the new field empty with a comment stating why, andrun_serverfills it immediately afterload_tokenizerreturns, 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.jsoncarries aPrepend "▁"normalizer, usually in aSequencewithReplace " " -> "▁". 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,/propsreports a plausible id, and the operator silently gets a breaker they never asked for.encode_breakertherefore encodesanchor + breakerand 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 aVec<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, mirroringparse_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 isis_empty(), deliberately nottrim().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\andn, 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\nhappens to encode to.\n,\t,\rand\\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()becomesunwrap_or_else(|| config.default_dry_sequence_breakers.clone()), matching theunwrap_or(config.default_*)shape of every neighbouring DRY field.src/server/routes/props.rs. The payload gainsdry_sequence_breakers, reported as resolved token IDs (what the sampler compares against, and what a per-request value overrides). The payload construction moves intodefault_generation_settings(&ServerConfig)so the reported field set can be asserted without standing up anAppState.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-breakerprose 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 warningscleancargo fmt --all -- --checkcleancargo test --workspace --profile test-fastandcargo deny checknot run here; no dependency changes.On the test fixture
MlxcelTokenizer::stub_with_byte_fallback()turned out unusable here: with no BPE merges,Hellotokenizes 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_assumepins 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
encodeis 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 thePrepend "▁"/Replace " " -> "▁"pair with merges that make▁aand▁▁real entries, andprepend_normalizer_fixture_reproduces_both_bare_encoding_defectsasserts 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/propspayload'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_disabledfails when run alongside its siblings and passes in isolation. Its siblingmuse_glimmer_startup_rejects_xla_backend_selectionsetsMLXCEL_BACKEND=xlaand 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.