Skip to content

refactor(tokenizer): use strongly typed enum instead of hardcoded sentinel string - #8022

Merged
biswapanda merged 6 commits into
mainfrom
bis/decode-res-state
Apr 14, 2026
Merged

refactor(tokenizer): use strongly typed enum instead of hardcoded sentinel string#8022
biswapanda merged 6 commits into
mainfrom
bis/decode-res-state

Conversation

@biswapanda

@biswapanda biswapanda commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Overview:

Address a previous comment: #6996 (comment)

Introduce a DecodeResult enum (Complete | Partial) to the Decoder trait, replacing hardcoded U+FFFD string checks in the incremental detokenization path. This makes partial-decode state explicit in the type system and fixes a pre-existing edge case where legitimate U+FFFD vocabulary tokens were incorrectly suppressed.

Details:

DecodeResult enum (lib/llm/src/tokenizers.rs)

  • Complete(String) -- fully valid output (may contain interior U+FFFD from mid-stream invalid bytes, but no trailing incomplete sequences)
  • Partial(String) -- output ends with incomplete trailing multi-byte bytes that may be completed by subsequent tokens
  • Derives strum::EnumIs for auto-generated is_partial() / is_complete() methods
  • Bidirectional From conversions: From<String> for DecodeResult (via from_decoded heuristic) and From<DecodeResult> for String
  • from_decoded() constructor classifies based on trailing U+FFFD; as_str() for borrowing

Decoder trait returns Result<DecodeResult> instead of Result<String>

  • DecodeStream::step() uses is_partial() instead of ends_with("\u{FFFD}")
  • Sequence::append_token_id() uses is_partial() instead of ends_with("\u{FFFD}")

TikToken fix (lib/llm/src/tokenizers/tiktoken.rs)

  • Tries String::from_utf8() first on raw bytes -- if valid UTF-8, returns Complete directly with zero extra allocation (takes ownership of the Vec<u8>)
  • Only falls back to from_utf8_lossy + trailing-FFFD heuristic (from_decoded) when bytes are genuinely invalid UTF-8
  • This fixes the legitimate U+FFFD bug: a vocabulary token whose raw bytes are EF BF BD (valid UTF-8 encoding of U+FFFD) was previously misclassified as Partial by from_utf8_lossy + ends_with('\u{FFFD}'), causing the incremental decoder to suppress it
  • The happy path (valid UTF-8) is strictly faster than before -- from_utf8 validates and takes ownership vs from_utf8_lossy which always allocates a new String
  • Includes a regression test (test_decode_legitimate_replacement_char_token_is_complete) with a U+FFFD vocab token asserting Complete status

HuggingFace / FastTokenizer -- use From<String> for DecodeResult (which delegates to from_decoded, a post-hoc trailing-FFFD heuristic on the output string, since raw bytes are inaccessible through the HF library API)

Where should the reviewer start?

  1. lib/llm/src/tokenizers.rs -- DecodeResult enum definition, From impls, and updated DecodeStream::step() / Sequence::append_token_id()
  2. lib/llm/src/tokenizers/tiktoken.rs -- String::from_utf8 fast path with lossy fallback, and the new regression test for legitimate U+FFFD tokens

Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to)

@biswapanda biswapanda self-assigned this Apr 9, 2026
@biswapanda
biswapanda requested a review from a team April 9, 2026 03:24
@copy-pr-bot

copy-pr-bot Bot commented Apr 9, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions Bot added feat frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` labels Apr 9, 2026
Replace hardcoded U+FFFD replacement character string checks with a
typed DecodeResult enum (Complete | Partial) in the Decoder trait.
This makes partial-decode state explicit in the type system, eliminating
brittle `ends_with("U+FFFD")` checks in DecodeStream::step() and
Sequence::append_token_id().

Addresses: #6996 (comment)
- Complete variant may still contain interior U+FFFD from mid-stream
  invalid byte sequences; only trailing status is tracked.
- Soften Decoder trait doc to not mandate a specific mechanism
  (from_utf8_lossy), since HF uses ByteFallback internally.
@biswapanda
biswapanda force-pushed the bis/decode-res-state branch from f5c6230 to 2849022 Compare April 9, 2026 03:25
@coderabbitai

coderabbitai Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This pull request introduces a new DecodeResult enum to the tokenizer trait system that represents whether decoded text is complete or partial based on the presence of the Unicode replacement character. The Decoder trait's decode method signature is updated to return Result<DecodeResult> instead of Result<String>, with all implementations and call sites adjusted accordingly.

Changes

Cohort / File(s) Summary
Decoder Trait Definition
lib/llm/src/tokenizers.rs
Added DecodeResult enum with Complete(String) and Partial(String) variants. Updated Decoder trait method to return Result<DecodeResult>. Modified DecodeStream and Sequence methods to work with the new return type, replacing manual replacement-character checks with DecodeResult::is_partial().
Decoder Implementations
lib/llm/src/tokenizers/fastokens.rs, lib/llm/src/tokenizers/hf.rs, lib/llm/src/tokenizers/tiktoken.rs
Updated all three tokenizer Decoder implementations to return Result<DecodeResult> instead of Result<String>. Implementations now classify decoded text via DecodeResult::classify(text) before returning.
Test Decoders
lib/llm/src/backend.rs, lib/llm/tests/test_stop_behavior.rs
Updated mock and test decoder implementations to return Result<DecodeResult>. Both now construct the decoded string and wrap it in DecodeResult::classify().
Test Assertions
lib/llm/tests/tokenizers.rs
Updated decode assertions to call .into_string() on DecodeResult before performing string comparisons and operations in roundtrip and skip-special-tokens tests.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title accurately describes the main change: introducing a strongly typed enum (DecodeResult) to replace hardcoded sentinel string checks (U+FFFD) in the tokenizer's decoder logic.
Description check ✅ Passed The pull request description provides comprehensive coverage of all template sections with detailed technical context and implementation decisions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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 and usage tips.

@biswapanda
biswapanda enabled auto-merge (squash) April 9, 2026 03:27
@biswapanda biswapanda changed the title feat: Decoder clean-up for handling incomplete multi-byte sequence feat(frontend): use strongly typed enum instead of hardcoded sentinel string Apr 9, 2026
@rmccorm4 rmccorm4 changed the title feat(frontend): use strongly typed enum instead of hardcoded sentinel string refactor(tokenizer): use strongly typed enum instead of hardcoded sentinel string Apr 9, 2026
@github-actions github-actions Bot added refactor and removed feat labels Apr 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixes a pre-existing edge case where legitimate U+FFFD vocabulary tokens were incorrectly suppressed

Can you add a test case that would've failed and caught this before this fix?

Comment thread lib/llm/src/tokenizers/tiktoken.rs Outdated
Comment thread lib/llm/src/tokenizers.rs Outdated
Comment thread lib/llm/src/tokenizers.rs Outdated
Comment thread lib/llm/src/tokenizers.rs Outdated
@biswapanda
biswapanda requested review from GuanLuo and rmccorm4 April 13, 2026 22:27
@biswapanda
biswapanda merged commit 2cabf44 into main Apr 14, 2026
89 checks passed
@biswapanda
biswapanda deleted the bis/decode-res-state branch April 14, 2026 00:27
biswapanda added a commit that referenced this pull request Apr 21, 2026
…eResult

Main commit 2cabf44 (feat: Decoder clean-up for incomplete multi-byte
sequences, #8022) changed Decoder::decode() to return Result<DecodeResult>
instead of Result<String>. The three backend tokenizer impls were updated,
but two spots were missed:

- lib/llm/src/tokenizers.rs: the default convert_ids_to_tokens trait impl
  .collect()s an iterator whose items are now DecodeResult, not String.
  Map via String::from (existing `impl From<DecodeResult> for String`)
  before collecting.

- lib/llm/src/http/service/openai.rs: the /v1/detokenize handler passes
  DecodeResult directly into DetokenizeResponse { prompt: String }. Extract
  the inner string via .into() before constructing the response.

Verified with `cargo check -p dynamo-py3 --features kv-indexer --lib`
(the crate maturin builds in the container).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

frontend `python -m dynamo.frontend` and `dynamo-run in=http|text|grpc` refactor size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants