Sitelet https://github.com/mlx-node/mlx-node/pull/82
Skip to content

fix: align repetition handling with vLLM (cutoff off by default) - #82

Merged
Brooooooklyn merged 4 commits into
mainfrom
fix/repetition-cutoff-off-by-default
Jun 30, 2026
Merged

Brooooooklyn merged 4 commits into
mainfrom
fix/repetition-cutoff-off-by-default

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Qwen models generating large markdown tables (a |----------------------------------| rule, or many byte-identical rows) get silently truncated. The native check_repetition_cutoff runs every decode step and stops generation on N identical tokens in a row (Tier 1) or a repeating n-gram block (Tier 2). It fires on legitimate repeated structure — table rules, ASCII boxes, separators, repeated rows — and the "repetition" finish reason is then mapped to a clean end_turn, so the cut looks like a normal answer.

PR #81 only loosened the TS Qwen launch preset (256/8/64). Every other path — non-launch servers, Gemma4/LFM2, per-request configs that omit the fields, and the napi GenerationConfig::default() — still fell back to the tight native defaults (16/3/64), so as few as ~6 repeated tokens still truncated tables.

How vLLM handles it (the model for this change)

vLLM ships no repetition-stop heuristic by default. It shapes repetition only with logit-level penalties (repetition_penalty 1.0, frequency_penalty/presence_penalty 0.0 — all no-ops by default) and stops generation only on real conditions (EOS, stop tokens, stop strings, max_tokens). Its opt-in loop detector (repetition_detection, recent main) defaults to None, and when enabled surfaces its own distinct FINISHED_REPETITION finish reason rather than disguising the cut.

Change: fully align with vLLM — cutoff OFF by default, opt-in

  • fix(sampling) — Introduce three named constants (all 0) in sampling.rs and route every former 16/3/64 default-fallback site through them (params.rs, qwen3 / qwen3.5 / qwen3.5-MoE / gemma4 / qianfan_ocr model files, and the napi GenerationConfig::default()). With the knobs unset the detectors resolve to disabled (0 already trips the existing check_consecutive / check_ngram guards). The detection algorithm is unchanged; GRPO training (grpo/engine.rs) and paddleocr keep their explicit values. New unit tests lock default-off and prove a positive value still re-enables detection (opt-in).
  • fix(server) — Remove the now-unnecessary loosened cutoff (256/8/64) from all four Qwen launch presets; repetition is now shaped by the sampling penalties and bounded by maxOutputTokens, with per-request opt-in still winning via ChatSession.mergeConfig. Presets test flipped to assert the fields are absent while keeping the opt-in/override coverage.
  • fix(types) — Sync the build-generated packages/core/index.d.cts cutoff doc comments to match the hand-synced crates/mlx-core/index.d.cts (both now say default: 0 = disabled), and rename a stale test describe block.

Behavior change / migration

A degenerate small model now runs to max_tokens (default max_new_tokens = 2048) instead of stopping at ~16 tokens. This is intended and vLLM-aligned — max_tokens is the backstop. Operators who want the old early repetition-stop must now set maxConsecutiveTokens / maxNgramRepeats / ngramSize explicitly (per-request or via registered samplingDefaults).

Testing

  • Rust: cargo test -p mlx-core --lib repetition → 28 passing (TDD RED→GREEN; algorithm tests unchanged). Full lib suite 1878 pass; the 7 failures are pre-existing flaky f32/Metal numerical tests (banded-attn, attention-vjp, packed-affine embedding, sparse-moe gather, chunked-prefill parity) — verified to fail identically on base 73f4a89e, no path to this change.
  • TS: vp test __test__/server/presets.test.ts → 7/7.

Out of scope (noted, not fixed here)

An adversarial review flagged that /v1/responses never clamps max_output_tokens against the registry's per-model outputTokenLimit (unlike /v1/messages). Independently verified as pre-existing (byte-identical at base 73f4a89e; this branch doesn't touch responses.ts/messages.ts) and not a regression of this change (the cutoff only ever caught exact-repeat loops, never bounded long coherent output). Worth a separate server-hardening PR.

🤖 Generated with Claude Code


Note

Medium Risk
Changes default generation stopping behavior across all chat paths: legitimate long repeats no longer truncate early, but runaway loops rely on max_new_tokens unless operators opt back in via config.

Overview
Native repetition-stop heuristics are off by default, matching vLLM: unset maxConsecutiveTokens, maxNgramRepeats, and ngramSize now resolve to 0 (disabled) instead of 16/3/64. Shared DEFAULT_* constants in sampling.rs replace scattered literal fallbacks across chat param extraction and Qwen/Gemma/Qianfan generation paths; the detection logic is unchanged and positive per-request values still opt in.

Qwen launch presets drop the previous 256/8/64 “loosened” cutoff pins—repetition is left to sampling penalties and maxOutputTokens, with ChatSession.mergeConfig still letting clients override. API docs (ChatConfig / GenerationConfig) and tests (presets, params.rs unit tests, LFM2 session comment) are updated for default-off behavior.

Reviewed by Cursor Bugbot for commit 5686106. Bugbot is set up for automated code reviews on this repo. Configure here.

Brooooooklyn and others added 3 commits June 30, 2026 16:55
The native repetition-cutoff heuristic was always on (max_consecutive=16,
max_ngram_repeats=3, ngram_size=64), wrongly truncating legitimate repeated
structure like large markdown tables. Align with vLLM, which ships no
repetition-stop heuristic by default and relies on max_tokens plus logit
penalties.

Centralize the three defaults into named constants in sampling.rs
(DEFAULT_MAX_CONSECUTIVE_TOKENS / DEFAULT_MAX_NGRAM_REPEATS / DEFAULT_NGRAM_SIZE,
all 0) and reference them at every fallback site that previously hardcoded
16/3/64: params.rs extract_chat_params, the qwen3 / qwen3_5 / qwen3_5_moe /
gemma4 / qianfan_ocr per-turn resolvers, and the napi GenerationConfig default.
Zero already disables each detector via the existing check_consecutive /
check_ngram guards, so the detection algorithm is unchanged and a positive
value still re-enables it (opt-in preserved, per-request overrides still win).

Update the stale field doc comments (generation.rs, types.rs, the hand-synced
index.d.cts) and the lfm2_session test comment to state the new default. GRPO
training defaults and paddleocr are intentionally left as-is.

Add unit tests locking the new behavior: all three resolve to 0 when unset, and
an explicit positive value passes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 1 disabled the native repetition cutoff by default, so the Qwen
launch presets no longer need to loosen it (256/8/64). Remove
maxConsecutiveTokens / maxNgramRepeats / ngramSize from all four
QWEN_SAMPLING_DEFAULTS variants; repetition is now shaped by the sampling
penalties and bounded by maxOutputTokens, with per-request opt-in still
winning via ChatSession.mergeConfig. Update the rationale comment and flip
the presets test to assert the fields are absent while preserving the
opt-in/override coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + rename stale test block

Final-review follow-ups. Task 1 corrected the hand-synced
crates/mlx-core/index.d.cts but not the build-generated
packages/core/index.d.cts (the artifact downstream TS imports), leaving
the two declaration files disagreeing — one saying default 0, the other
16/3/64. Hand-sync the maxConsecutiveTokens / maxNgramRepeats / ngramSize
doc comments on both ChatConfig and GenerationConfig so published types
match runtime (it self-heals on the next native build from the already
corrected napi source comments).

Also rename the now-stale presets test describe block
"cutoff survives ChatSession.mergeConfig" to
"repetition-cutoff handling through ChatSession.mergeConfig" — its first
test now asserts no cutoff survives by default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 30, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6571daf3-f65b-4916-baec-bb7788bbe85e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/repetition-cutoff-off-by-default

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The presets test passed under vitest (types stripped) but `tsc -b`
(CI typecheck + build) rejected it: QWEN_SAMPLING_DEFAULTS is `as const`,
so after removing the cutoff fields the literal type no longer has
maxConsecutiveTokens / maxNgramRepeats / ngramSize, making the
`toBeUndefined()` accesses TS2339 errors. Annotate the loop local as
ChatConfig so they are optional-undefined accesses (the values are
genuinely undefined at runtime; the assertion still holds). Verified with
`yarn typecheck` (clean) and the vitest suite (7/7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn merged commit f65104a into main Jun 30, 2026
8 checks passed
@Brooooooklyn
Brooooooklyn deleted the fix/repetition-cutoff-off-by-default branch June 30, 2026 10:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant