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. The regex constrains the
96        // generated text, so this must use the tokenizer's decoded surface
97        // form rather than the raw vocab entry (for example byte-level/BPE
98        // vocab entries may contain internal markers that are not emitted).
99        let vocab_size = tokenizer.vocab_size();
100        let mut token_bytes = Vec::with_capacity(vocab_size);
101        for i in 0..vocab_size {
102            let id = TokenId::new(i as u32);
103            let bytes = tokenizer
104                .decode(&[id], false)
105                .ok()
106                .or_else(|| tokenizer.token_text(id).map(str::to_string))
107                .map(String::into_bytes)
108                .unwrap_or_default();
109            token_bytes.push(bytes);
110        }
111
112        Ok(Self {
113            dfa,
114            state: Mutex::new(DfaPosition {
115                state: start,
116                dead: false,
117            }),
118            token_bytes,
119            eos_token,
120            consumed: Mutex::new(0),
121        })
122    }
123
124    /// Reset for a new generation.
125    pub fn reset(&self) -> Result<()> {
126        let start = self
127            .dfa
128            .start_state(&StartConfig::new().anchored(Anchored::Yes))
129            .map_err(|e| FerrumError::internal(format!("regex start state: {e}")))?;
130        *self.state.lock() = DfaPosition {
131            state: start,
132            dead: false,
133        };
134        *self.consumed.lock() = 0;
135        Ok(())
136    }
137
138    /// Check if the pattern can currently accept (i.e. EOS is valid here).
139    pub fn can_accept(&self) -> bool {
140        let pos = *self.state.lock();
141        !pos.dead && self.dfa.is_match_state(self.dfa.next_eoi_state(pos.state))
142    }
143
144    /// Walk the DFA over `bytes` starting from `state`. Returns the new
145    /// state, or `None` if a dead state is reached partway through — i.e.
146    /// this byte sequence cannot extend the current match.
147    fn advance(&self, mut state: StateID, bytes: &[u8]) -> Option<StateID> {
148        for &b in bytes {
149            state = self.dfa.next_state(state, b);
150            if self.dfa.is_dead_state(state) {
151                return None;
152            }
153        }
154        Some(state)
155    }
156
157    /// Apply the hard mask to `logits` given the current DFA state.
158    pub fn mask_logits(&self, logits: &mut [f32]) {
159        let pos = *self.state.lock();
160        if pos.dead {
161            // Give up and force EOS — no other token can recover.
162            self.force_eos(logits);
163            return;
164        }
165
166        let pattern_done = self.dfa.is_match_state(self.dfa.next_eoi_state(pos.state));
167
168        // Mask tokens individually. `&self.token_bytes` is O(vocab) once; a
169        // cached per-state transition table would amortise further, but this
170        // keeps the hot path allocation-free for now.
171        let vocab = logits.len().min(self.token_bytes.len());
172        let mut any_allowed = false;
173        for idx in 0..vocab {
174            let is_eos = self.eos_token.map_or(false, |e| e.get() as usize == idx);
175            let bytes = &self.token_bytes[idx];
176
177            let allowed = if is_eos {
178                pattern_done
179            } else if bytes.is_empty() {
180                // Unknown / special token outside the regex alphabet — only
181                // let it through if pattern can already accept (so it can't
182                // block a valid termination).
183                pattern_done
184            } else {
185                self.advance(pos.state, bytes).is_some()
186            };
187
188            if allowed {
189                any_allowed = true;
190            } else {
191                logits[idx] = f32::NEG_INFINITY;
192            }
193        }
194
195        // No token in the vocab can extend the current match. Rather than
196        // hand the sampler a row of -inf (which produces NaN softmax and a
197        // junk argmax) terminate cleanly by forcing EOS. Happens when the
198        // tokenizer's BPE merges span byte boundaries the schema rejects.
199        if !any_allowed {
200            self.force_eos(logits);
201        }
202    }
203
204    fn force_eos(&self, logits: &mut [f32]) {
205        if let Some(eos) = self.eos_token {
206            let eos_idx = eos.get() as usize;
207            for (i, l) in logits.iter_mut().enumerate() {
208                *l = if i == eos_idx { 0.0 } else { f32::NEG_INFINITY };
209            }
210        }
211    }
212
213    /// Public wrapper around `advance_with_tokens` for direct callers
214    /// (the engine applies the mask inline rather than via
215    /// `LogitsProcessor::process`).
216    pub fn advance_with_tokens_public(&self, tokens: &[TokenId]) {
217        self.advance_with_tokens(tokens);
218    }
219
220    /// Advance the stored state by consuming tokens that were decided after
221    /// the last `process()` call. The engine calls this in `process()` with
222    /// the full generated-tokens list; we skip the prefix we've already seen.
223    fn advance_with_tokens(&self, tokens: &[TokenId]) {
224        let mut consumed = self.consumed.lock();
225        if *consumed >= tokens.len() {
226            return;
227        }
228        let mut pos = self.state.lock();
229        for &tok in &tokens[*consumed..] {
230            if pos.dead {
231                break;
232            }
233            let idx = tok.get() as usize;
234            if idx >= self.token_bytes.len() {
235                continue;
236            }
237            let bytes = &self.token_bytes[idx];
238            // EOS terminates cleanly — leave state where it is.
239            if self.eos_token.map_or(false, |e| e == tok) {
240                continue;
241            }
242            if let Some(next) = self.advance(pos.state, bytes) {
243                pos.state = next;
244            } else {
245                pos.dead = true;
246            }
247        }
248        *consumed = tokens.len();
249    }
250}
251
252impl LogitsProcessor for RegexGuidedProcessor {
253    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
254        self.advance_with_tokens(ctx.previous_tokens);
255        self.mask_logits(ctx.logits);
256        Ok(())
257    }
258
259    fn name(&self) -> &str {
260        "regex_guided"
261    }
262
263    fn priority(&self) -> ProcessorPriority {
264        // Apply before temperature / top-k / top-p — those should only see
265        // logits for *valid* tokens.
266        ProcessorPriority::High
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use ferrum_interfaces::tokenizer::{ChatMessage, TokenizerInfo, TokenizerType};
274    use ferrum_types::{SpecialTokens, TokenId};
275
276    /// Tiny tokenizer: each ASCII character 0..=255 is a single token, plus
277    /// an EOS token at 256. Matches how byte-level BPEs decompose in the
278    /// worst case, so the test is a lower bound on the real-world case.
279    struct ByteTokenizer {
280        special: SpecialTokens,
281        byte_strings: Vec<String>,
282    }
283
284    impl ByteTokenizer {
285        fn new() -> Self {
286            let mut byte_strings = Vec::with_capacity(257);
287            for b in 0u8..=255 {
288                byte_strings.push(String::from_utf8(vec![b]).unwrap_or_default());
289            }
290            byte_strings.push("</s>".to_string());
291            Self {
292                special: SpecialTokens {
293                    bos_token: None,
294                    eos_token: Some(TokenId::new(256)),
295                    unk_token: None,
296                    pad_token: None,
297                    sep_token: None,
298                    cls_token: None,
299                    mask_token: None,
300                },
301                byte_strings,
302            }
303        }
304    }
305
306    impl Tokenizer for ByteTokenizer {
307        fn encode(&self, text: &str, _add_special: bool) -> Result<Vec<TokenId>> {
308            Ok(text.bytes().map(|b| TokenId::new(b as u32)).collect())
309        }
310        fn decode(&self, tokens: &[TokenId], _skip_special: bool) -> Result<String> {
311            let mut out = String::new();
312            for t in tokens {
313                let idx = t.get() as usize;
314                if idx < 256 {
315                    out.push(idx as u8 as char);
316                }
317            }
318            Ok(out)
319        }
320        fn decode_incremental(&self, _prev: &[TokenId], next: TokenId) -> Result<String> {
321            self.decode(&[next], false)
322        }
323        fn vocab_size(&self) -> usize {
324            257
325        }
326        fn special_tokens(&self) -> &SpecialTokens {
327            &self.special
328        }
329        fn token_id(&self, text: &str) -> Option<TokenId> {
330            if text.len() == 1 {
331                Some(TokenId::new(text.bytes().next().unwrap() as u32))
332            } else {
333                None
334            }
335        }
336        fn token_text(&self, token_id: TokenId) -> Option<&str> {
337            self.byte_strings
338                .get(token_id.get() as usize)
339                .map(|s| s.as_str())
340        }
341        fn apply_chat_template(&self, _messages: &[ChatMessage]) -> Result<String> {
342            Ok(String::new())
343        }
344        fn info(&self) -> TokenizerInfo {
345            TokenizerInfo {
346                tokenizer_type: TokenizerType::Custom,
347                vocab_size: 257,
348                special_tokens: self.special.clone(),
349                supports_incremental: true,
350                supports_chat_template: false,
351                max_token_length: Some(1),
352                model_name: Some("byte-tokenizer-test".into()),
353            }
354        }
355    }
356
357    fn processor(pattern: &str) -> RegexGuidedProcessor {
358        let tok: Arc<dyn Tokenizer> = Arc::new(ByteTokenizer::new());
359        RegexGuidedProcessor::new(pattern, tok, Some(TokenId::new(256))).unwrap()
360    }
361
362    #[test]
363    fn digits_only_allows_digits_at_start() {
364        let p = processor(r"[0-9]+");
365        let mut logits = vec![0.0f32; 257];
366        p.mask_logits(&mut logits);
367        for b in 0u8..=255 {
368            let expected_allowed = b.is_ascii_digit();
369            let got = logits[b as usize].is_finite();
370            assert_eq!(
371                got, expected_allowed,
372                "byte {b:?} ({}): expected allowed={expected_allowed}, got={got}",
373                b as char
374            );
375        }
376        // EOS is NOT yet allowed (pattern requires >=1 digit).
377        assert!(logits[256].is_infinite() && logits[256].is_sign_negative());
378    }
379
380    #[test]
381    fn digits_only_allows_eos_after_a_digit() {
382        let p = processor(r"[0-9]+");
383        p.advance_with_tokens(&[TokenId::new(b'3' as u32)]);
384        let mut logits = vec![0.0f32; 257];
385        p.mask_logits(&mut logits);
386        assert!(
387            logits[256].is_finite(),
388            "EOS should be allowed after a digit"
389        );
390        assert!(
391            logits[b'7' as usize].is_finite(),
392            "another digit still allowed"
393        );
394        assert!(logits[b'a' as usize].is_infinite(), "alpha still forbidden");
395    }
396
397    #[test]
398    fn dead_state_forces_eos() {
399        let p = processor(r"[0-9]+");
400        // Feed an invalid token ("a") — DFA dies.
401        p.advance_with_tokens(&[TokenId::new(b'a' as u32)]);
402        let mut logits = vec![0.0f32; 257];
403        p.mask_logits(&mut logits);
404        assert!(logits[256].is_finite(), "EOS forced as fallback");
405        for b in 0u8..=255 {
406            assert!(
407                logits[b as usize].is_infinite(),
408                "byte {b} should be masked when DFA dead"
409            );
410        }
411    }
412
413    #[test]
414    fn reset_restores_initial_state() {
415        let p = processor(r"[0-9]+");
416        p.advance_with_tokens(&[TokenId::new(b'3' as u32)]);
417        assert!(p.can_accept());
418        p.reset().unwrap();
419        assert!(!p.can_accept(), "fresh state should not accept empty input");
420    }
421
422    #[test]
423    fn hex_prefix_pattern() {
424        let p = processor(r"0x[0-9a-f]+");
425        let mut logits = vec![0.0f32; 257];
426        p.mask_logits(&mut logits);
427        assert!(logits[b'0' as usize].is_finite(), "'0' starts the pattern");
428        assert!(logits[b'1' as usize].is_infinite(), "'1' can't start");
429        p.advance_with_tokens(&[TokenId::new(b'0' as u32), TokenId::new(b'x' as u32)]);
430        let mut logits = vec![0.0f32; 257];
431        p.mask_logits(&mut logits);
432        assert!(logits[b'a' as usize].is_finite());
433        assert!(logits[b'f' as usize].is_finite());
434        assert!(logits[b'g' as usize].is_infinite());
435        assert!(logits[b'9' as usize].is_finite());
436    }
437}