Skip to main content

ferrum_sampler/
guided.rs

1//! Regex-guided decoding — hard token masking via DFA.
2//!
3//! Given a regex pattern and a tokenizer vocab, build a DFA and at each
4//! sampling step compute which tokens can extend the currently accepted
5//! prefix without leaving the language. Invalid tokens get `-INFINITY` so
6//! the downstream sampler cannot pick them, regardless of temperature or
7//! top-k/top-p.
8//!
9//! This is the "outlines"-style approach: convert the constraint to a
10//! finite automaton, walk it byte-by-byte per token to decide validity.
11//! No schema → regex transformation here — that belongs a layer up (see
12//! `ResponseFormat::JsonSchema` handling).
13//!
14//! # Design notes
15//!
16//! * The DFA is built once at request admission — regex compilation is the
17//!   expensive step (~1-5 ms for short patterns, scales with ambiguity).
18//! * Per-step cost is O(vocab_size · avg_token_bytes). For a 150k vocab
19//!   with ~5 byte tokens that's ~750k state transitions per sampling step.
20//!   Fine for single requests; we'll add a cached (state, token) → (valid,
21//!   next_state) transition table if this becomes a bottleneck.
22//! * End-of-string: once the DFA can accept, EOS becomes a valid choice.
23//!   If the pattern is "open" (e.g. `.*`) EOS is always allowed.
24
25use std::sync::Arc;
26
27use ferrum_interfaces::sampler::{LogitsProcessor, ProcessorPriority, SamplingContext};
28use ferrum_interfaces::tokenizer::Tokenizer;
29use ferrum_types::{FerrumError, Result, TokenId};
30use parking_lot::Mutex;
31use regex_automata::{
32    dfa::{dense::DFA, Automaton, StartKind},
33    util::{primitives::StateID, start::Config as StartConfig},
34    Anchored,
35};
36
37/// Hard-mask regex constraint processor.
38///
39/// Build with `RegexGuidedProcessor::new(pattern, tokenizer, eos_token)`;
40/// use by adding as a high-priority logits processor before temperature /
41/// top-k / top-p.
42pub struct RegexGuidedProcessor {
43    dfa: DFA<Vec<u32>>,
44    /// Current DFA state — advanced lazily per `process()` call from the
45    /// generated tokens accumulated so far.
46    state: Mutex<DfaPosition>,
47    /// Precomputed per-token byte sequences. Indexed by token id.
48    token_bytes: Vec<Vec<u8>>,
49    /// Optional EOS id — always allowed once the DFA can accept.
50    eos_token: Option<TokenId>,
51    /// Number of tokens already consumed into `state`.
52    consumed: Mutex<usize>,
53}
54
55impl std::fmt::Debug for RegexGuidedProcessor {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("RegexGuidedProcessor")
58            .field("vocab_size", &self.token_bytes.len())
59            .field("consumed", &*self.consumed.lock())
60            .finish()
61    }
62}
63
64#[derive(Copy, Clone, Debug)]
65struct DfaPosition {
66    state: StateID,
67    /// Set once the DFA enters a dead (non-accepting, non-escapable) state.
68    /// At that point no token is valid and we must rely on the sampler's
69    /// fallback (we emit EOS to terminate the sequence gracefully).
70    dead: bool,
71}
72
73impl RegexGuidedProcessor {
74    /// Build a guided-decoding processor for `pattern`. `tokenizer` is used
75    /// to map each vocab entry to its byte representation.
76    pub fn new(
77        pattern: &str,
78        tokenizer: Arc<dyn Tokenizer + Send + Sync>,
79        eos_token: Option<TokenId>,
80    ) -> Result<Self> {
81        // `Anchored::Yes` only pins the match start; without `\z` the DFA
82        // happily keeps accepting after the match completes, and tokens that
83        // would produce "valid match + garbage" wouldn't get masked. Wrap
84        // the user pattern so the full generated output must match.
85        let wrapped = format!(r"(?:{pattern})\z");
86        let dfa = DFA::builder()
87            .configure(DFA::config().start_kind(StartKind::Anchored))
88            .build(&wrapped)
89            .map_err(|e| FerrumError::invalid_request(format!("regex compile: {e}")))?;
90
91        let start = dfa
92            .start_state(&StartConfig::new().anchored(Anchored::Yes))
93            .map_err(|e| FerrumError::invalid_request(format!("regex start state: {e}")))?;
94
95        // Decode every token in the vocab once. We try `token_text` first
96        // (cheap, zero-copy for tokenizers that implement it), then fall
97        // back to `decode([id])` — the byte-level-BPE tokenisers that ship
98        // with Qwen/Llama return `None` from `token_text`, so without this
99        // fallback every token would have empty bytes and the DFA mask
100        // would reject everything (force-EOS spam).
101        let vocab_size = tokenizer.vocab_size();
102        let mut token_bytes = Vec::with_capacity(vocab_size);
103        for i in 0..vocab_size {
104            let id = TokenId::new(i as u32);
105            let bytes = if let Some(s) = tokenizer.token_text(id) {
106                s.as_bytes().to_vec()
107            } else {
108                tokenizer
109                    .decode(&[id], false)
110                    .map(|s| s.into_bytes())
111                    .unwrap_or_default()
112            };
113            token_bytes.push(bytes);
114        }
115
116        Ok(Self {
117            dfa,
118            state: Mutex::new(DfaPosition {
119                state: start,
120                dead: false,
121            }),
122            token_bytes,
123            eos_token,
124            consumed: Mutex::new(0),
125        })
126    }
127
128    /// Reset for a new generation.
129    pub fn reset(&self) -> Result<()> {
130        let start = self
131            .dfa
132            .start_state(&StartConfig::new().anchored(Anchored::Yes))
133            .map_err(|e| FerrumError::internal(format!("regex start state: {e}")))?;
134        *self.state.lock() = DfaPosition {
135            state: start,
136            dead: false,
137        };
138        *self.consumed.lock() = 0;
139        Ok(())
140    }
141
142    /// Check if the pattern can currently accept (i.e. EOS is valid here).
143    pub fn can_accept(&self) -> bool {
144        let pos = *self.state.lock();
145        !pos.dead && self.dfa.is_match_state(self.dfa.next_eoi_state(pos.state))
146    }
147
148    /// Walk the DFA over `bytes` starting from `state`. Returns the new
149    /// state, or `None` if a dead state is reached partway through — i.e.
150    /// this byte sequence cannot extend the current match.
151    fn advance(&self, mut state: StateID, bytes: &[u8]) -> Option<StateID> {
152        for &b in bytes {
153            state = self.dfa.next_state(state, b);
154            if self.dfa.is_dead_state(state) {
155                return None;
156            }
157        }
158        Some(state)
159    }
160
161    /// Apply the hard mask to `logits` given the current DFA state.
162    pub fn mask_logits(&self, logits: &mut [f32]) {
163        let pos = *self.state.lock();
164        if pos.dead {
165            // Give up and force EOS — no other token can recover.
166            self.force_eos(logits);
167            return;
168        }
169
170        let pattern_done = self.dfa.is_match_state(self.dfa.next_eoi_state(pos.state));
171
172        // Mask tokens individually. `&self.token_bytes` is O(vocab) once; a
173        // cached per-state transition table would amortise further, but this
174        // keeps the hot path allocation-free for now.
175        let vocab = logits.len().min(self.token_bytes.len());
176        let mut any_allowed = false;
177        for idx in 0..vocab {
178            let is_eos = self.eos_token.map_or(false, |e| e.get() as usize == idx);
179            let bytes = &self.token_bytes[idx];
180
181            let allowed = if is_eos {
182                pattern_done
183            } else if bytes.is_empty() {
184                // Unknown / special token outside the regex alphabet — only
185                // let it through if pattern can already accept (so it can't
186                // block a valid termination).
187                pattern_done
188            } else {
189                self.advance(pos.state, bytes).is_some()
190            };
191
192            if allowed {
193                any_allowed = true;
194            } else {
195                logits[idx] = f32::NEG_INFINITY;
196            }
197        }
198
199        // No token in the vocab can extend the current match. Rather than
200        // hand the sampler a row of -inf (which produces NaN softmax and a
201        // junk argmax) terminate cleanly by forcing EOS. Happens when the
202        // tokenizer's BPE merges span byte boundaries the schema rejects.
203        if !any_allowed {
204            self.force_eos(logits);
205        }
206    }
207
208    fn force_eos(&self, logits: &mut [f32]) {
209        if let Some(eos) = self.eos_token {
210            let eos_idx = eos.get() as usize;
211            for (i, l) in logits.iter_mut().enumerate() {
212                *l = if i == eos_idx { 0.0 } else { f32::NEG_INFINITY };
213            }
214        }
215    }
216
217    /// Public wrapper around `advance_with_tokens` for direct callers
218    /// (the engine applies the mask inline rather than via
219    /// `LogitsProcessor::process`).
220    pub fn advance_with_tokens_public(&self, tokens: &[TokenId]) {
221        self.advance_with_tokens(tokens);
222    }
223
224    /// Advance the stored state by consuming tokens that were decided after
225    /// the last `process()` call. The engine calls this in `process()` with
226    /// the full generated-tokens list; we skip the prefix we've already seen.
227    fn advance_with_tokens(&self, tokens: &[TokenId]) {
228        let mut consumed = self.consumed.lock();
229        if *consumed >= tokens.len() {
230            return;
231        }
232        let mut pos = self.state.lock();
233        for &tok in &tokens[*consumed..] {
234            if pos.dead {
235                break;
236            }
237            let idx = tok.get() as usize;
238            if idx >= self.token_bytes.len() {
239                continue;
240            }
241            let bytes = &self.token_bytes[idx];
242            // EOS terminates cleanly — leave state where it is.
243            if self.eos_token.map_or(false, |e| e == tok) {
244                continue;
245            }
246            if let Some(next) = self.advance(pos.state, bytes) {
247                pos.state = next;
248            } else {
249                pos.dead = true;
250            }
251        }
252        *consumed = tokens.len();
253    }
254}
255
256impl LogitsProcessor for RegexGuidedProcessor {
257    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
258        self.advance_with_tokens(ctx.previous_tokens);
259        self.mask_logits(ctx.logits);
260        Ok(())
261    }
262
263    fn name(&self) -> &str {
264        "regex_guided"
265    }
266
267    fn priority(&self) -> ProcessorPriority {
268        // Apply before temperature / top-k / top-p — those should only see
269        // logits for *valid* tokens.
270        ProcessorPriority::High
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use ferrum_interfaces::tokenizer::{ChatMessage, TokenizerInfo, TokenizerType};
278    use ferrum_types::{SpecialTokens, TokenId};
279
280    /// Tiny tokenizer: each ASCII character 0..=255 is a single token, plus
281    /// an EOS token at 256. Matches how byte-level BPEs decompose in the
282    /// worst case, so the test is a lower bound on the real-world case.
283    struct ByteTokenizer {
284        special: SpecialTokens,
285        byte_strings: Vec<String>,
286    }
287
288    impl ByteTokenizer {
289        fn new() -> Self {
290            let mut byte_strings = Vec::with_capacity(257);
291            for b in 0u8..=255 {
292                byte_strings.push(String::from_utf8(vec![b]).unwrap_or_default());
293            }
294            byte_strings.push("</s>".to_string());
295            Self {
296                special: SpecialTokens {
297                    bos_token: None,
298                    eos_token: Some(TokenId::new(256)),
299                    unk_token: None,
300                    pad_token: None,
301                    sep_token: None,
302                    cls_token: None,
303                    mask_token: None,
304                },
305                byte_strings,
306            }
307        }
308    }
309
310    impl Tokenizer for ByteTokenizer {
311        fn encode(&self, text: &str, _add_special: bool) -> Result<Vec<TokenId>> {
312            Ok(text.bytes().map(|b| TokenId::new(b as u32)).collect())
313        }
314        fn decode(&self, tokens: &[TokenId], _skip_special: bool) -> Result<String> {
315            let mut out = String::new();
316            for t in tokens {
317                let idx = t.get() as usize;
318                if idx < 256 {
319                    out.push(idx as u8 as char);
320                }
321            }
322            Ok(out)
323        }
324        fn decode_incremental(&self, _prev: &[TokenId], next: TokenId) -> Result<String> {
325            self.decode(&[next], false)
326        }
327        fn vocab_size(&self) -> usize {
328            257
329        }
330        fn special_tokens(&self) -> &SpecialTokens {
331            &self.special
332        }
333        fn token_id(&self, text: &str) -> Option<TokenId> {
334            if text.len() == 1 {
335                Some(TokenId::new(text.bytes().next().unwrap() as u32))
336            } else {
337                None
338            }
339        }
340        fn token_text(&self, token_id: TokenId) -> Option<&str> {
341            self.byte_strings
342                .get(token_id.get() as usize)
343                .map(|s| s.as_str())
344        }
345        fn apply_chat_template(&self, _messages: &[ChatMessage]) -> Result<String> {
346            Ok(String::new())
347        }
348        fn info(&self) -> TokenizerInfo {
349            TokenizerInfo {
350                tokenizer_type: TokenizerType::Custom,
351                vocab_size: 257,
352                special_tokens: self.special.clone(),
353                supports_incremental: true,
354                supports_chat_template: false,
355                max_token_length: Some(1),
356                model_name: Some("byte-tokenizer-test".into()),
357            }
358        }
359    }
360
361    fn processor(pattern: &str) -> RegexGuidedProcessor {
362        let tok: Arc<dyn Tokenizer> = Arc::new(ByteTokenizer::new());
363        RegexGuidedProcessor::new(pattern, tok, Some(TokenId::new(256))).unwrap()
364    }
365
366    #[test]
367    fn digits_only_allows_digits_at_start() {
368        let p = processor(r"[0-9]+");
369        let mut logits = vec![0.0f32; 257];
370        p.mask_logits(&mut logits);
371        for b in 0u8..=255 {
372            let expected_allowed = b.is_ascii_digit();
373            let got = logits[b as usize].is_finite();
374            assert_eq!(
375                got, expected_allowed,
376                "byte {b:?} ({}): expected allowed={expected_allowed}, got={got}",
377                b as char
378            );
379        }
380        // EOS is NOT yet allowed (pattern requires >=1 digit).
381        assert!(logits[256].is_infinite() && logits[256].is_sign_negative());
382    }
383
384    #[test]
385    fn digits_only_allows_eos_after_a_digit() {
386        let p = processor(r"[0-9]+");
387        p.advance_with_tokens(&[TokenId::new(b'3' as u32)]);
388        let mut logits = vec![0.0f32; 257];
389        p.mask_logits(&mut logits);
390        assert!(
391            logits[256].is_finite(),
392            "EOS should be allowed after a digit"
393        );
394        assert!(
395            logits[b'7' as usize].is_finite(),
396            "another digit still allowed"
397        );
398        assert!(logits[b'a' as usize].is_infinite(), "alpha still forbidden");
399    }
400
401    #[test]
402    fn dead_state_forces_eos() {
403        let p = processor(r"[0-9]+");
404        // Feed an invalid token ("a") — DFA dies.
405        p.advance_with_tokens(&[TokenId::new(b'a' as u32)]);
406        let mut logits = vec![0.0f32; 257];
407        p.mask_logits(&mut logits);
408        assert!(logits[256].is_finite(), "EOS forced as fallback");
409        for b in 0u8..=255 {
410            assert!(
411                logits[b as usize].is_infinite(),
412                "byte {b} should be masked when DFA dead"
413            );
414        }
415    }
416
417    #[test]
418    fn reset_restores_initial_state() {
419        let p = processor(r"[0-9]+");
420        p.advance_with_tokens(&[TokenId::new(b'3' as u32)]);
421        assert!(p.can_accept());
422        p.reset().unwrap();
423        assert!(!p.can_accept(), "fresh state should not accept empty input");
424    }
425
426    #[test]
427    fn hex_prefix_pattern() {
428        let p = processor(r"0x[0-9a-f]+");
429        let mut logits = vec![0.0f32; 257];
430        p.mask_logits(&mut logits);
431        assert!(logits[b'0' as usize].is_finite(), "'0' starts the pattern");
432        assert!(logits[b'1' as usize].is_infinite(), "'1' can't start");
433        p.advance_with_tokens(&[TokenId::new(b'0' as u32), TokenId::new(b'x' as u32)]);
434        let mut logits = vec![0.0f32; 257];
435        p.mask_logits(&mut logits);
436        assert!(logits[b'a' as usize].is_finite());
437        assert!(logits[b'f' as usize].is_finite());
438        assert!(logits[b'g' as usize].is_infinite());
439        assert!(logits[b'9' as usize].is_finite());
440    }
441}