Files
rustytorch/crates/production/rtx-inference/src/tokenizer.rs
T
osobhandClaude Fable 5 733b02cd8b
GPU Tests / Check GPU Availability (push) Successful in 1s
CI / Build (ubuntu-latest) (push) Failing after 6s
CI / Build (macos-latest) (push) Failing after 11s
Documentation / Build User Guide (push) Successful in 7s
CI / Clippy Check (push) Failing after 21s
CI / Format Check (push) Failing after 6s
GPU Tests / CUDA Tests (11.8) (push) Has been skipped
GPU Tests / CUDA Tests (12.1) (push) Has been skipped
CI / Build CPU-Only (Explicit) (push) Failing after 6s
GPU Tests / Metal Tests (push) Has been skipped
CI / Test (macos-latest) (push) Has been skipped
CI / Test (ubuntu-latest) (push) Has been skipped
CI / Python Bindings (maturin) (macos-latest) (push) Has been skipped
CI / Python Bindings (maturin) (ubuntu-latest) (push) Has been skipped
CI / WASM Build + Size Check (push) Has been skipped
CI / Distributed Training Tests (push) Has been skipped
CI / CI Success (push) Failing after 0s
Performance Benchmarks / Run Benchmarks (push) Successful in 28s
Documentation / Build API Documentation (push) Failing after 25s
feat(inference): concrete EAGLE draft model + real tokenizer at the serving boundary
EAGLE (rtx-inference/src/eagle.rs, ~610 lines, mirrors medusa.rs
conventions): EagleDraftHead autoregressive FFN with Concat/Add/
Attention feature fusion, EagleHeads draft model with draft/
draft_steps (per-step top-k for candidate trees) and teacher-forced
training_loss; implements the speculative::EagleDraftModel trait so it
plugs into the orchestration layer. 38 unit tests.

Tokenizer (rtx-inference/src/tokenizer.rs): ServingTokenizer enum —
Vocab (HuggingFace tokenizers, loadable from tokenizer.json) or
ByteLevel fallback preserving previous behavior. rtx-serving-api's
AppState and rtx-streaming's token generator now encode/decode through
it (with_engine_and_tokenizer / set_tokenizer added; existing
signatures unchanged). Also fixes two pre-existing compile errors in
rtx-streaming (missing import, stray .await) that blocked its lib
tests entirely.

Tests: rtx-inference 328 pass, rtx-serving-api 193 pass, rtx-streaming
53 pass (2 pre-existing mock-server connection failures unrelated to
these changes).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-09 21:54:05 -07:00

149 lines
5.2 KiB
Rust

//! Serving-boundary tokenization.
//!
//! Historically the HTTP/streaming serving layers tokenized text by mapping
//! each UTF-8 byte to a token id (`bytes().map(i32::from)`) and reversed it
//! with `String::from_utf8_lossy`. That byte-level scheme is lossless and
//! reversible but not vocabulary-aware, so it wastes context and produces
//! nonsense token counts for anything downstream that expects real
//! sub-word tokens.
//!
//! [`ServingTokenizer`] makes tokenization pluggable: load a real
//! `tokenizers`-crate vocabulary (`tokenizer.json`) via
//! [`ServingTokenizer::from_file`], or fall back to the original byte-level
//! behavior via [`ServingTokenizer::byte_level`] (the default).
use std::path::Path;
/// Tokenizer used at the serving boundary (HTTP inference, streaming).
///
/// Defaults to byte-level encoding for backward compatibility; construct a
/// [`ServingTokenizer::Vocab`] from a `tokenizer.json` file to use a real
/// vocabulary tokenizer instead.
#[derive(Debug, Clone)]
pub enum ServingTokenizer {
/// Real vocabulary tokenizer backed by the `tokenizers` crate.
Vocab(Box<tokenizers::Tokenizer>),
/// Byte-level fallback: each UTF-8 byte is one token id. Lossless and
/// reversible, but not vocabulary-aware.
ByteLevel,
}
impl Default for ServingTokenizer {
fn default() -> Self {
Self::ByteLevel
}
}
impl ServingTokenizer {
/// Byte-level tokenizer: each UTF-8 byte is one token id.
#[must_use]
pub fn byte_level() -> Self {
Self::ByteLevel
}
/// Load a real vocabulary tokenizer from a `tokenizer.json` file.
///
/// # Errors
///
/// Returns an error if the file cannot be read or does not describe a
/// valid `tokenizers` configuration.
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, String> {
let tokenizer = tokenizers::Tokenizer::from_file(path.as_ref())
.map_err(|e| format!("failed to load tokenizer from {:?}: {e}", path.as_ref()))?;
Ok(Self::Vocab(Box::new(tokenizer)))
}
/// Encode text into token ids.
///
/// The `ByteLevel` variant maps each UTF-8 byte to its numeric value,
/// matching the historical serving-boundary behavior exactly.
#[must_use]
pub fn encode(&self, text: &str) -> Vec<i32> {
match self {
Self::ByteLevel => text.bytes().map(i32::from).collect(),
Self::Vocab(tokenizer) => match tokenizer.encode(text, false) {
Ok(encoding) => encoding.get_ids().iter().map(|&id| id as i32).collect(),
Err(_) => Vec::new(),
},
}
}
/// Decode token ids back into text.
///
/// The `ByteLevel` variant clamps each id to a `u8` and reconstructs the
/// string with `String::from_utf8_lossy`, matching the historical
/// serving-boundary behavior exactly.
#[must_use]
pub fn decode(&self, ids: &[i32]) -> String {
match self {
Self::ByteLevel => {
let bytes: Vec<u8> = ids.iter().map(|&t| t.clamp(0, 255) as u8).collect();
String::from_utf8_lossy(&bytes).into_owned()
}
Self::Vocab(tokenizer) => {
let ids: Vec<u32> = ids.iter().map(|&id| id.max(0) as u32).collect();
tokenizer.decode(&ids, true).unwrap_or_default()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byte_level_round_trip_ascii() {
let tok = ServingTokenizer::byte_level();
let ids = tok.encode("Hello, world!");
assert_eq!(tok.decode(&ids), "Hello, world!");
}
#[test]
fn byte_level_round_trip_multibyte_utf8() {
let tok = ServingTokenizer::byte_level();
let text = "héllo wörld — 日本語";
let ids = tok.encode(text);
assert_eq!(tok.decode(&ids), text);
}
#[test]
fn byte_level_default_matches_byte_level_constructor() {
let default_tok = ServingTokenizer::default();
assert!(matches!(default_tok, ServingTokenizer::ByteLevel));
}
/// Builds a tiny in-memory WordLevel tokenizer over a 5-word vocab and
/// verifies encode/decode round-trips for a sentence made purely of
/// vocab words.
#[test]
fn vocab_tokenizer_round_trip() {
use std::collections::HashMap;
use tokenizers::models::wordlevel::WordLevel;
use tokenizers::pre_tokenizers::whitespace::Whitespace;
let mut vocab: HashMap<String, u32> = HashMap::new();
vocab.insert("[UNK]".to_string(), 0);
vocab.insert("the".to_string(), 1);
vocab.insert("quick".to_string(), 2);
vocab.insert("brown".to_string(), 3);
vocab.insert("fox".to_string(), 4);
let model = WordLevel::builder()
.vocab(vocab)
.unk_token("[UNK]".to_string())
.build()
.expect("valid wordlevel model");
let mut tokenizer = tokenizers::Tokenizer::new(model);
tokenizer.with_pre_tokenizer(Some(Whitespace));
let serving = ServingTokenizer::Vocab(Box::new(tokenizer));
let text = "the quick brown fox";
let ids = serving.encode(text);
assert_eq!(ids, vec![1, 2, 3, 4]);
assert_eq!(serving.decode(&ids), text);
}
}