rlx_text/detokenize.rs
1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Incremental detokenization for streaming generation.
17//!
18//! `TokenizerHandle::decode` is batch-only. Decoding tokens one at a time
19//! and concatenating breaks on two fronts:
20//! - **Byte-level BPE** (GPT-2/Qwen) splits a single multi-byte UTF-8
21//! char across two tokens; decoding the first alone yields the U+FFFD
22//! replacement char, which must not be emitted to the client.
23//! - **SentencePiece** decides leading spaces from context, so per-token
24//! decode mis-spaces word boundaries.
25//!
26//! [`StreamingDetokenizer`] re-decodes the full id sequence each step and
27//! emits only the newly *stable* suffix, holding back a trailing run of
28//! replacement chars until the next token completes them. This is O(T²)
29//! over a generation (one decode per token); fine for chat-length outputs.
30//! A per-tokenizer byte-buffer fast path (O(T)) is a future optimization.
31
32use crate::tokenizer::TokenizerHandle;
33use anyhow::Result;
34
35/// Streaming detokenizer over a borrowed [`TokenizerHandle`].
36///
37/// ```ignore
38/// let mut detok = StreamingDetokenizer::new(&tok, true);
39/// for id in decoded_token_stream {
40/// let delta = detok.push(id)?; // emit to the client as it stabilizes
41/// if !delta.is_empty() { send(delta); }
42/// }
43/// send(detok.finish()?); // flush any held-back tail
44/// ```
45pub struct StreamingDetokenizer<'t> {
46 tok: &'t TokenizerHandle,
47 skip_special: bool,
48 ids: Vec<u32>,
49 /// Byte offset into the full decode that has already been emitted.
50 emitted: usize,
51}
52
53impl<'t> StreamingDetokenizer<'t> {
54 pub fn new(tok: &'t TokenizerHandle, skip_special: bool) -> Self {
55 Self {
56 tok,
57 skip_special,
58 ids: Vec::new(),
59 emitted: 0,
60 }
61 }
62
63 /// Append a token and return any newly-stable text (may be empty when
64 /// the token only extends an incomplete multi-byte char).
65 pub fn push(&mut self, id: u32) -> Result<String> {
66 self.ids.push(id);
67 let full = self.tok.decode(&self.ids, self.skip_special)?;
68 let (delta, emitted) = advance(&full, self.emitted);
69 self.emitted = emitted;
70 Ok(delta.to_string())
71 }
72
73 /// Flush everything decoded so far, including a held-back tail. Call
74 /// once at the end of generation (after the last `push`).
75 pub fn finish(&mut self) -> Result<String> {
76 let full = self.tok.decode(&self.ids, self.skip_special)?;
77 let out = if self.emitted < full.len() {
78 full[self.emitted..].to_string()
79 } else {
80 String::new()
81 };
82 self.emitted = full.len();
83 Ok(out)
84 }
85
86 /// The full text decoded so far (including any not-yet-emitted tail).
87 pub fn text(&self) -> Result<String> {
88 self.tok.decode(&self.ids, self.skip_special)
89 }
90
91 /// Tokens accumulated so far.
92 pub fn ids(&self) -> &[u32] {
93 &self.ids
94 }
95}
96
97/// Core incremental-emit step, decoupled from the tokenizer for testing:
98/// given the full decode `full` and the byte offset `emitted` already
99/// returned, yield the next stable slice and the updated offset. A trailing
100/// run of U+FFFD (incomplete multi-byte bytes) is held back.
101/// Stateless incremental detokenization: decode `ids` and return the next
102/// *stable* text beyond byte offset `emitted`, plus the new offset. Lets a
103/// caller (e.g. a continuous-batching scheduler) keep per-sequence `(ids,
104/// emitted)` state without holding a borrow of the tokenizer.
105pub fn incremental_emit(
106 tok: &TokenizerHandle,
107 ids: &[u32],
108 emitted: usize,
109 skip_special: bool,
110) -> Result<(String, usize)> {
111 let full = tok.decode(ids, skip_special)?;
112 let (delta, new_emitted) = advance(&full, emitted);
113 Ok((delta.to_string(), new_emitted))
114}
115
116fn advance(full: &str, emitted: usize) -> (&str, usize) {
117 let stable = full.trim_end_matches('\u{FFFD}').len();
118 // Invariant: append-only generation keeps `full[..emitted]` fixed, so
119 // `emitted` stays a char boundary. Guard defensively anyway.
120 if stable <= emitted || emitted > full.len() || !full.is_char_boundary(emitted) {
121 return ("", emitted.min(full.len()));
122 }
123 (&full[emitted..stable], stable)
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn emits_growing_suffix() {
132 // First step emits "Hello".
133 let (d, e) = advance("Hello", 0);
134 assert_eq!(d, "Hello");
135 assert_eq!(e, 5);
136 // Next decode extends it; emit only " world".
137 let (d, e) = advance("Hello world", e);
138 assert_eq!(d, " world");
139 assert_eq!(e, 11);
140 }
141
142 #[test]
143 fn holds_back_incomplete_multibyte() {
144 // A byte-level-BPE first half of a 2-byte char decodes to U+FFFD;
145 // it must be held back, not emitted.
146 let (d, e) = advance("hi\u{FFFD}", 2);
147 assert_eq!(d, "");
148 assert_eq!(e, 2);
149 // Next token completes the char (é); now it's stable.
150 let (d, e) = advance("hié", 2);
151 assert_eq!(d, "é");
152 assert_eq!(e, "hié".len());
153 }
154
155 #[test]
156 fn no_change_emits_nothing() {
157 let (d, e) = advance("abc", 3);
158 assert_eq!(d, "");
159 assert_eq!(e, 3);
160 }
161
162 #[test]
163 fn trailing_replacement_run_held_back() {
164 // Multiple incomplete bytes pending.
165 let (d, e) = advance("ok\u{FFFD}\u{FFFD}", 2);
166 assert_eq!(d, "");
167 assert_eq!(e, 2);
168 }
169}