Skip to main content

ferrox_models/
grammar_sampler.rs

1//! The sampler hook a [`Grammar`] hangs on: a live grammar plus the
2//! vocabulary it constrains.
3//!
4//! [`crate::grammar`] deliberately knows nothing about a vocabulary --
5//! [`reject_candidates`] answers "which of these pieces is impossible?"
6//! and stops there. Two of the rules in llama.cpp's
7//! `llama_grammar_apply_impl` are therefore missing from it, because they
8//! are questions about the *vocabulary* rather than the grammar, and this
9//! is where they live:
10//!
11//! - an end-of-generation token is masked unless [`Grammar::allows_eog`];
12//! - a token whose piece is empty, or starts with a NUL byte, is masked
13//!   unconditionally -- it would advance the grammar by nothing and the
14//!   decode loop by one token, which is how a constrained generation
15//!   spins to `max_tokens` emitting nothing.
16//!
17//! # Why the two halves are one type
18//!
19//! Masking before the sample and accepting after it are not two features.
20//! A loop that masks and forgets to accept produces text that satisfies
21//! the grammar's FIRST token over and over; a loop that accepts and
22//! forgets to mask produces unconstrained text and then dies on the first
23//! token that does not parse. Both halves are private to
24//! [`GrammarSampler`] -- [`GrammarSampler::mask_logits`] and
25//! [`GrammarSampler::accept`] -- so a caller that holds one holds the
26//! other, and the server keeps that pairing in exactly one function
27//! (`ferrox_server::sample_step::sample_next`).
28//!
29//! # The vocabulary is snapshotted once
30//!
31//! Detokenizing every vocabulary entry costs a real amount, and it costs
32//! the same on every token step because the vocabulary does not change.
33//! [`GrammarSampler::new`] takes the snapshot once per request; the
34//! per-step cost is then the shared-prefix walk in
35//! [`reject_candidates`] and nothing else.
36
37use crate::grammar::{reject_candidates, Candidate, Grammar, GrammarError};
38
39/// A constrained-sampling failure. Kept separate from [`GrammarError`],
40/// which is about a grammar, because every variant here is about the
41/// grammar's fit to a *vocabulary* or to a caller's logits.
42#[derive(Debug, thiserror::Error)]
43pub enum ConstraintError {
44    /// The logits handed to the mask are not vocabulary-shaped.
45    ///
46    /// The live cause is a backend that folded `lm_head + argmax` onto
47    /// the device and returned a one-element vector holding a token id
48    /// (`ferrox_server::generate::greedy_gpu_fold_allowed`). Masking that
49    /// would zero a token id rather than a logit, so it is refused rather
50    /// than performed on the wrong thing.
51    #[error(
52        "grammar-constrained sampling needs one logit per vocabulary entry, \
53         but was handed {got} for a vocabulary of {expected}"
54    )]
55    VocabMismatch { got: usize, expected: usize },
56
57    /// The grammar forbids every token in the vocabulary, and the parse
58    /// is NOT complete.
59    ///
60    /// Not a bug in the grammar engine: it is a grammar this vocabulary
61    /// cannot spell (a rule requiring a character no token piece
62    /// contains), and the only alternative to refusing is to sample from
63    /// an all-`-inf` distribution, i.e. to emit an arbitrary token and
64    /// call it constrained output.
65    ///
66    /// The complete case is [`MaskOutcome::Complete`] and is not an
67    /// error: nothing left to say, having said everything, is an answer.
68    #[error(
69        "grammar allows no token in this vocabulary after {accepted} accepted token(s), \
70         and the parse is incomplete; the grammar requires something this tokenizer \
71         cannot spell"
72    )]
73    NoAllowedToken { accepted: usize },
74
75    /// A token id outside the snapshotted vocabulary was accepted.
76    #[error(
77        "token id {token} is outside the vocabulary of {vocab_size} the grammar was built over"
78    )]
79    TokenOutOfVocab { token: usize, vocab_size: usize },
80
81    /// The grammar itself refused.
82    #[error(transparent)]
83    Grammar(#[from] GrammarError),
84}
85
86/// What a mask left behind.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum MaskOutcome {
89    /// At least one token survived; sample normally.
90    Allowed,
91    /// Nothing survived, and the parse is COMPLETE.
92    ///
93    /// Reached when a satisfied grammar can be continued by nothing and
94    /// the vocabulary has no end-of-generation token to end on -- which
95    /// is otherwise the ordinary way a constrained generation stops,
96    /// since a satisfied grammar leaves EOG unmasked and it is the only
97    /// thing left to sample. The generation is finished, and the token
98    /// that comes back out of an all-`-inf` distribution is not a
99    /// choice and must be discarded.
100    Complete,
101}
102
103/// A grammar being applied to one generation, over one vocabulary.
104pub struct GrammarSampler {
105    grammar: Grammar,
106    /// Token pieces as BYTES, by token id. Not `String`: a BPE piece can
107    /// hold a fragment of a multi-byte character, and the grammar's
108    /// decoder carries that fragment to the next piece rather than
109    /// rejecting it.
110    pieces: Vec<Vec<u8>>,
111    /// End-of-generation flags, by token id.
112    eog: Vec<bool>,
113    /// Tokens accepted so far; reported when a grammar dead-ends, since
114    /// "where" is the first thing anyone debugging one asks.
115    accepted: usize,
116}
117
118impl GrammarSampler {
119    /// Snapshot `n_vocab` token pieces and their end-of-generation flags,
120    /// and start applying `grammar` over them.
121    pub fn new(
122        grammar: Grammar,
123        n_vocab: usize,
124        piece_of: impl Fn(usize) -> Vec<u8>,
125        is_eog: impl Fn(usize) -> bool,
126    ) -> Self {
127        let mut pieces = Vec::with_capacity(n_vocab);
128        let mut eog = Vec::with_capacity(n_vocab);
129        for id in 0..n_vocab {
130            pieces.push(piece_of(id));
131            eog.push(is_eog(id));
132        }
133        Self {
134            grammar,
135            pieces,
136            eog,
137            accepted: 0,
138        }
139    }
140
141    /// The vocabulary size this was built over.
142    pub fn vocab_size(&self) -> usize {
143        self.pieces.len()
144    }
145
146    /// The live grammar, for a caller that wants to ask it something.
147    pub fn grammar(&self) -> &Grammar {
148        &self.grammar
149    }
150
151    /// Set every logit the grammar forbids to `-inf`, in place.
152    ///
153    /// `llama_grammar_apply_impl`. A logit already at `-inf` is left
154    /// alone and never offered to the grammar: it is forbidden whatever
155    /// the grammar thinks, and the walk in [`reject_candidates`] is
156    /// linear in the candidates it is given.
157    pub fn mask_logits(&self, logits: &mut [f32]) -> Result<MaskOutcome, ConstraintError> {
158        if logits.len() != self.pieces.len() {
159            return Err(ConstraintError::VocabMismatch {
160                got: logits.len(),
161                expected: self.pieces.len(),
162            });
163        }
164
165        // A lazy grammar that has not triggered masks NOTHING -- not even
166        // the end-of-generation and empty-piece tokens the two vocabulary
167        // rules above would otherwise take out. `llama_grammar_apply_impl`
168        // returns before all of it. The shape check stays above this: the
169        // trigger can fire on any token, so the caller must be handing
170        // over real logits from the first one.
171        //
172        // The one exception is this repo's own
173        // [`LazyTriggers::mandatory`](crate::grammar::LazyTriggers::mandatory),
174        // which forbids ENDING before the trigger fires and nothing else.
175        // Everything the model might say on the way there is still free.
176        if self.grammar.is_awaiting_trigger() {
177            if self.grammar.allows_eog() {
178                return Ok(MaskOutcome::Allowed);
179            }
180            return self.mask_eog_only(logits);
181        }
182
183        let allow_eog = self.grammar.allows_eog();
184        let mut candidates: Vec<Candidate<'_>> = Vec::with_capacity(logits.len());
185        // Tokens left allowed that the grammar was never asked about:
186        // the end-of-generation tokens, when the grammar is satisfied.
187        let mut allowed_eog = 0usize;
188
189        for (id, piece) in self.pieces.iter().enumerate() {
190            if logits[id] == f32::NEG_INFINITY {
191                continue;
192            }
193            if self.eog[id] {
194                if allow_eog {
195                    allowed_eog += 1;
196                } else {
197                    logits[id] = f32::NEG_INFINITY;
198                }
199            } else if piece.is_empty() || piece[0] == 0 {
200                logits[id] = f32::NEG_INFINITY;
201            } else {
202                candidates.push(Candidate::new(id, id as u32, piece));
203            }
204        }
205
206        let rejected = reject_candidates(&self.grammar, &candidates)?;
207        for index in &rejected {
208            logits[*index] = f32::NEG_INFINITY;
209        }
210
211        if candidates.len() - rejected.len() + allowed_eog == 0 {
212            if allow_eog {
213                return Ok(MaskOutcome::Complete);
214            }
215            return Err(ConstraintError::NoAllowedToken {
216                accepted: self.accepted,
217            });
218        }
219        Ok(MaskOutcome::Allowed)
220    }
221
222    /// Take out every end-of-generation token and leave the rest alone.
223    ///
224    /// The whole mask for an untriggered MANDATORY lazy grammar: the turn
225    /// may not end, and nothing else is decided yet. A vocabulary with
226    /// nothing left but its end-of-generation tokens is refused rather
227    /// than sampled, for the same reason the ordinary path refuses one.
228    fn mask_eog_only(&self, logits: &mut [f32]) -> Result<MaskOutcome, ConstraintError> {
229        let mut survivors = 0usize;
230        for (id, logit) in logits.iter_mut().enumerate() {
231            if *logit == f32::NEG_INFINITY {
232                continue;
233            }
234            if self.eog[id] {
235                *logit = f32::NEG_INFINITY;
236            } else {
237                survivors += 1;
238            }
239        }
240        if survivors == 0 {
241            return Err(ConstraintError::NoAllowedToken {
242                accepted: self.accepted,
243            });
244        }
245        Ok(MaskOutcome::Allowed)
246    }
247
248    /// Advance the grammar over a sampled token.
249    ///
250    /// `llama_grammar_accept_impl`. An end-of-generation token does not
251    /// consume characters -- it asserts the parse is finished -- so it
252    /// goes to [`Grammar::accept_eog`], which refuses if it is not.
253    ///
254    /// The order of the two tests is upstream's and matters: the trigger
255    /// check comes FIRST, so while a lazy grammar is awaiting, even an
256    /// end-of-generation token is buffered rather than asserted against a
257    /// parse that has not started.
258    pub fn accept(&mut self, token: usize) -> Result<(), ConstraintError> {
259        let piece = self
260            .pieces
261            .get(token)
262            .ok_or(ConstraintError::TokenOutOfVocab {
263                token,
264                vocab_size: self.pieces.len(),
265            })?;
266        if self.grammar.is_awaiting_trigger() {
267            self.grammar.accept_token(token as u32, piece)?;
268        } else if self.eog[token] {
269            self.grammar.accept_eog()?;
270        } else {
271            self.grammar.accept_token(token as u32, piece)?;
272        }
273        self.accepted += 1;
274        Ok(())
275    }
276
277    /// Whether a parse is complete, so generation may end here.
278    ///
279    /// True throughout an untriggered lazy grammar: it has not been
280    /// applied, so it has no say in when generation ends.
281    pub fn allows_eog(&self) -> bool {
282        self.grammar.allows_eog()
283    }
284
285    /// Whether this is a lazy grammar that has not switched on yet.
286    ///
287    /// A caller deciding whether it needs full vocabulary logits must NOT
288    /// read this as "unconstrained": the trigger can fire on any token, so
289    /// the grammar needs a real logit vector from the first one. It is for
290    /// reporting and for tests.
291    pub fn is_awaiting_trigger(&self) -> bool {
292        self.grammar.is_awaiting_trigger()
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::grammar::LazyTriggers;
300
301    /// A toy vocabulary: ids are indices into this table.
302    ///
303    /// Id 4 is empty and id 5 leads with a NUL, which are the two pieces
304    /// the grammar is never allowed to see. Id 6 is end-of-generation.
305    const PIECES: &[&[u8]] = &[
306        b"a",        // 0
307        b"b",        // 1
308        b"c",        // 2
309        b"ab",       // 3
310        b"",         // 4
311        b"\0stop",   // 5
312        b"</s>",     // 6 (EOG)
313        b"\xf0\x9f", // 7: the first two bytes of a 4-byte emoji
314    ];
315    const EOG_ID: usize = 6;
316
317    fn sampler(src: &str) -> GrammarSampler {
318        let grammar = Grammar::from_str_with_root(src, "root").expect("grammar parses");
319        GrammarSampler::new(
320            grammar,
321            PIECES.len(),
322            |id| PIECES[id].to_vec(),
323            |id| id == EOG_ID,
324        )
325    }
326
327    /// Every logit starts allowed, so a masked one is the grammar's doing.
328    fn flat_logits() -> Vec<f32> {
329        vec![0.0; PIECES.len()]
330    }
331
332    fn allowed(logits: &[f32]) -> Vec<usize> {
333        logits
334            .iter()
335            .enumerate()
336            .filter(|(_, l)| **l != f32::NEG_INFINITY)
337            .map(|(i, _)| i)
338            .collect()
339    }
340
341    /// The core of the hook: only pieces the grammar can consume survive.
342    /// `root ::= "ab"` admits "a" and "ab" at the start, and nothing else.
343    #[test]
344    fn mask_leaves_only_pieces_the_grammar_admits() {
345        let s = sampler(r#"root ::= "ab""#);
346        let mut logits = flat_logits();
347        s.mask_logits(&mut logits).expect("grammar has a move");
348        assert_eq!(allowed(&logits), vec![0, 3]);
349    }
350
351    /// The first of the two vocabulary-side rules. `root ::= "a"*` admits
352    /// the empty string, so a zero-width piece is one the *grammar* would
353    /// happily accept -- it is masked because a vocabulary rule says so,
354    /// not because the grammar rejected it.
355    #[test]
356    fn an_empty_or_nul_leading_piece_is_masked_even_when_the_grammar_would_take_it() {
357        let s = sampler(r#"root ::= "a"*"#);
358        let mut logits = flat_logits();
359        s.mask_logits(&mut logits).expect("grammar has a move");
360        assert!(
361            !allowed(&logits).contains(&4),
362            "an empty piece advances the grammar by nothing and the loop by a token"
363        );
364        assert!(
365            !allowed(&logits).contains(&5),
366            "a NUL-leading piece is masked unconditionally"
367        );
368    }
369
370    /// The second. `root ::= "a"` is unsatisfied before "a" is accepted,
371    /// so the end-of-generation token must not be sampleable; once the
372    /// parse completes it must be.
373    #[test]
374    fn eog_is_masked_until_the_grammar_is_satisfied() {
375        let mut s = sampler(r#"root ::= "a""#);
376        let mut logits = flat_logits();
377        s.mask_logits(&mut logits).expect("grammar has a move");
378        assert!(
379            !allowed(&logits).contains(&EOG_ID),
380            "generation could end before the grammar was satisfied"
381        );
382
383        s.accept(0).expect("\"a\" is what the grammar asked for");
384        assert!(s.allows_eog());
385        let mut logits = flat_logits();
386        s.mask_logits(&mut logits).expect("eog is still a move");
387        assert!(allowed(&logits).contains(&EOG_ID));
388    }
389
390    /// Accepting moves the machine: after "a", `root ::= "ab"` wants "b".
391    #[test]
392    fn accepting_a_token_advances_what_is_allowed_next() {
393        let mut s = sampler(r#"root ::= "ab""#);
394        s.accept(0).unwrap();
395        let mut logits = flat_logits();
396        s.mask_logits(&mut logits).expect("grammar has a move");
397        assert_eq!(allowed(&logits), vec![1]);
398    }
399
400    /// A token the mask forbade, accepted anyway, is a refusal rather
401    /// than a silently dead grammar that then rejects everything.
402    #[test]
403    fn accepting_a_token_the_grammar_forbids_is_an_error() {
404        let mut s = sampler(r#"root ::= "ab""#);
405        let err = s.accept(2).expect_err("\"c\" is not in this grammar");
406        assert!(matches!(err, ConstraintError::Grammar(_)), "{err}");
407    }
408
409    /// Ending on EOG when the parse is unfinished is refused too: the
410    /// mask should have made it unsampleable, so reaching here means the
411    /// two halves disagreed.
412    #[test]
413    fn accepting_eog_before_the_grammar_is_satisfied_is_an_error() {
414        let mut s = sampler(r#"root ::= "ab""#);
415        let err = s.accept(EOG_ID).expect_err("the parse is not finished");
416        assert!(matches!(err, ConstraintError::Grammar(_)), "{err}");
417    }
418
419    /// A satisfied grammar with nothing left to say, in a vocabulary
420    /// with no end-of-generation token to say it with, is a COMPLETE
421    /// answer and not a failure. (With an EOG token -- the ordinary
422    /// case -- EOG survives the mask and this is never reached, which
423    /// the test above already shows.)
424    #[test]
425    fn a_satisfied_grammar_with_no_continuation_is_complete_not_an_error() {
426        let grammar = Grammar::from_str_with_root(r#"root ::= "a""#, "root").unwrap();
427        let mut s = GrammarSampler::new(grammar, PIECES.len(), |id| PIECES[id].to_vec(), |_| false);
428        assert_eq!(
429            s.mask_logits(&mut flat_logits()).unwrap(),
430            MaskOutcome::Allowed
431        );
432        s.accept(0).unwrap();
433        assert_eq!(
434            s.mask_logits(&mut flat_logits()).unwrap(),
435            MaskOutcome::Complete,
436            "a finished parse reported as a failure"
437        );
438    }
439
440    /// A grammar this vocabulary cannot spell must STOP, not sample from
441    /// an all-`-inf` distribution and call the result constrained.
442    #[test]
443    fn a_grammar_no_token_can_satisfy_is_refused_rather_than_sampled() {
444        let s = sampler(r#"root ::= "zzz""#);
445        let mut logits = flat_logits();
446        let err = s
447            .mask_logits(&mut logits)
448            .expect_err("no piece in this vocabulary starts with z");
449        assert!(
450            matches!(err, ConstraintError::NoAllowedToken { .. }),
451            "{err}"
452        );
453    }
454
455    /// The folded-lm_head case: one number that is a token id, not a
456    /// vocabulary. Masking it would zero the id.
457    #[test]
458    fn logits_that_are_not_vocabulary_shaped_are_refused() {
459        let s = sampler(r#"root ::= "a""#);
460        let mut folded = vec![3.0f32];
461        let err = s.mask_logits(&mut folded).expect_err("not a vocabulary");
462        assert!(
463            matches!(
464                err,
465                ConstraintError::VocabMismatch {
466                    got: 1,
467                    expected: 8
468                }
469            ),
470            "{err}"
471        );
472        assert_eq!(folded[0], 3.0, "the token id was overwritten");
473    }
474
475    /// A piece that ends mid-codepoint stays viable: the grammar carries
476    /// the partial sequence to the next piece. Rejecting it here is the
477    /// bug llama.cpp's `partial_utf8` exists to avoid, and it would make
478    /// every emoji unreachable under any grammar with a `.`-like class.
479    #[test]
480    fn a_piece_ending_mid_codepoint_is_not_rejected() {
481        // U+1F600 is \xf0\x9f\x98\x80; piece 7 is its first two bytes.
482        let s = sampler(r#"root ::= [\U0001F600-\U0001F64F]"#);
483        let mut logits = flat_logits();
484        s.mask_logits(&mut logits).expect("grammar has a move");
485        assert!(
486            allowed(&logits).contains(&7),
487            "a partial UTF-8 piece was rejected before its continuation could arrive"
488        );
489    }
490
491    /// A vocabulary for the lazy tests: the trigger word `<tool_call>` is
492    /// two pieces, so no single token spells it.
493    const LAZY_PIECES: &[&[u8]] = &[
494        b"sure",        // 0: prose
495        b", one sec ",  // 1: prose, and the token that straddles the trigger
496        b"<tool",       // 2
497        b"_call>",      // 3
498        b"{",           // 4
499        b"}",           // 5
500        b"</s>",        // 6 (EOG)
501        b"never valid", // 7: forbidden by the grammar at every point
502    ];
503    const LAZY_EOG: usize = 6;
504    /// The grammar begins with the trigger word, because a WORD trigger
505    /// feeds the matched text to the grammar.
506    const LAZY_GRAMMAR: &str = r#"root ::= "<tool_call>" "{" "}""#;
507
508    fn lazy_sampler(triggers: LazyTriggers) -> GrammarSampler {
509        let grammar = Grammar::from_str_with_root(LAZY_GRAMMAR, "root")
510            .expect("grammar parses")
511            .into_lazy(triggers)
512            .expect("triggers are not empty");
513        GrammarSampler::new(
514            grammar,
515            LAZY_PIECES.len(),
516            |id| LAZY_PIECES[id].to_vec(),
517            |id| id == LAZY_EOG,
518        )
519    }
520
521    fn lazy_logits() -> Vec<f32> {
522        vec![0.0; LAZY_PIECES.len()]
523    }
524
525    /// The case lazy grammars exist for, end to end: free prose, then a
526    /// trigger spanning two tokens, then constrained output.
527    ///
528    /// The vacuity check is the first assertion: while awaiting, the mask
529    /// leaves token 7 sampleable, and the last assertion shows the
530    /// triggered grammar forbids it. Without that pair the test would pass
531    /// on a mask that does nothing at all.
532    #[test]
533    fn free_text_then_a_trigger_then_constrained_output() {
534        let mut s = lazy_sampler(LazyTriggers::new().with_word("<tool_call>").unwrap());
535
536        let mut logits = lazy_logits();
537        assert_eq!(s.mask_logits(&mut logits).unwrap(), MaskOutcome::Allowed);
538        assert_eq!(
539            allowed(&logits),
540            (0..LAZY_PIECES.len()).collect::<Vec<_>>(),
541            "an untriggered lazy grammar must mask nothing at all"
542        );
543
544        // Prose the grammar could never accept.
545        s.accept(0).expect("prose is free");
546        s.accept(1).expect("prose is free");
547        assert!(s.is_awaiting_trigger());
548
549        // The trigger word arrives across two tokens; neither piece holds
550        // it, so only the accumulated buffer can match.
551        s.accept(2)
552            .expect("still prose as far as the grammar knows");
553        assert!(
554            s.is_awaiting_trigger(),
555            "\"<tool\" alone is not the trigger word"
556        );
557        s.accept(3).expect("the trigger completes here");
558        assert!(!s.is_awaiting_trigger(), "the trigger did not fire");
559
560        // Now constrained: the replay put the grammar past "<tool_call>".
561        let mut logits = lazy_logits();
562        s.mask_logits(&mut logits).expect("grammar has a move");
563        assert_eq!(
564            allowed(&logits),
565            vec![4],
566            "after the trigger only \"{{\" continues the grammar"
567        );
568
569        s.accept(4).unwrap();
570        s.accept(5).unwrap();
571        let mut logits = lazy_logits();
572        s.mask_logits(&mut logits).expect("eog is a move");
573        assert_eq!(allowed(&logits), vec![LAZY_EOG], "the tool call is done");
574    }
575
576    /// The vacuity check's other half: the same grammar WITHOUT the
577    /// trigger forbids the prose from the first token, which is why lazy
578    /// is a separate mechanism and not a flag.
579    #[test]
580    fn the_same_grammar_eagerly_forbids_the_prose_the_lazy_one_allowed() {
581        let grammar = Grammar::from_str_with_root(LAZY_GRAMMAR, "root").unwrap();
582        let mut s = GrammarSampler::new(
583            grammar,
584            LAZY_PIECES.len(),
585            |id| LAZY_PIECES[id].to_vec(),
586            |id| id == LAZY_EOG,
587        );
588        let mut logits = lazy_logits();
589        s.mask_logits(&mut logits).expect("grammar has a move");
590        assert_eq!(
591            allowed(&logits),
592            vec![2],
593            "eagerly, only the start of the trigger word is sampleable"
594        );
595        s.accept(0).expect_err("\"sure\" is not \"<tool_call>\"");
596    }
597
598    /// A trigger TOKEN fires on an id and throws the prose away: the
599    /// grammar is fed the trigger token alone.
600    #[test]
601    fn a_trigger_token_seeds_the_grammar_with_itself_only() {
602        // Token 2's piece is "<tool", so this grammar starts where that
603        // token leaves off.
604        let grammar = Grammar::from_str_with_root(r#"root ::= "<tool" "{}""#, "root")
605            .unwrap()
606            .into_lazy(LazyTriggers::new().with_token(2))
607            .unwrap();
608        let mut s = GrammarSampler::new(
609            grammar,
610            LAZY_PIECES.len(),
611            |id| LAZY_PIECES[id].to_vec(),
612            |id| id == LAZY_EOG,
613        );
614        s.accept(0).expect("prose is free");
615        s.accept(2)
616            .expect("the trigger token fires and is replayed");
617        assert!(!s.is_awaiting_trigger());
618        let mut logits = lazy_logits();
619        s.mask_logits(&mut logits).expect("grammar has a move");
620        assert_eq!(
621            allowed(&logits),
622            vec![4],
623            "the prose must not have been fed to the grammar"
624        );
625    }
626
627    /// Generation may end while a lazy grammar waits: a turn with no tool
628    /// call is a legal turn.
629    #[test]
630    fn end_of_generation_is_free_while_awaiting_a_trigger() {
631        let mut s = lazy_sampler(LazyTriggers::new().with_word("<tool_call>").unwrap());
632        let mut logits = lazy_logits();
633        s.mask_logits(&mut logits).unwrap();
634        assert!(allowed(&logits).contains(&LAZY_EOG));
635        assert!(s.allows_eog());
636        s.accept(LAZY_EOG)
637            .expect("an untriggered grammar cannot object to stopping");
638    }
639
640    /// A MANDATORY trigger forbids exactly one thing before it fires:
641    /// ending the turn. Everything the model might say on the way to a
642    /// tool call is still free, which is the difference from an eager
643    /// grammar and the reason this exists.
644    #[test]
645    fn a_mandatory_trigger_forbids_ending_the_turn_before_it_fires() {
646        let mut s = lazy_sampler(
647            LazyTriggers::new()
648                .with_word("<tool_call>")
649                .unwrap()
650                .mandatory(),
651        );
652        assert!(!s.allows_eog(), "the turn must not be endable yet");
653
654        let mut logits = lazy_logits();
655        s.mask_logits(&mut logits).expect("prose is still free");
656        assert_eq!(
657            allowed(&logits),
658            vec![0, 1, 2, 3, 4, 5, 7],
659            "only the end-of-generation token may be taken away"
660        );
661
662        // Prose, then the trigger.
663        s.accept(0).unwrap();
664        s.accept(2).unwrap();
665        s.accept(3).unwrap();
666        assert!(!s.is_awaiting_trigger());
667        assert!(
668            !s.allows_eog(),
669            "the grammar is now live and unsatisfied, so still no ending"
670        );
671
672        s.accept(4).unwrap();
673        s.accept(5).unwrap();
674        let mut logits = lazy_logits();
675        s.mask_logits(&mut logits).unwrap();
676        assert_eq!(
677            allowed(&logits),
678            vec![LAZY_EOG],
679            "with the call complete the turn may finally end"
680        );
681    }
682
683    /// The same triggers WITHOUT `mandatory` leave the ending free. The
684    /// pair is what shows the flag is what did it.
685    #[test]
686    fn an_optional_trigger_leaves_the_ending_free() {
687        let s = lazy_sampler(LazyTriggers::new().with_word("<tool_call>").unwrap());
688        assert!(s.allows_eog());
689        let mut logits = lazy_logits();
690        s.mask_logits(&mut logits).unwrap();
691        assert!(allowed(&logits).contains(&LAZY_EOG));
692    }
693
694    /// The mask never *unblocks* anything: a logit already forbidden --
695    /// by `logit_bias: -inf`, or by JSON mode -- stays forbidden even
696    /// where the grammar is happy with it.
697    #[test]
698    fn an_already_masked_logit_is_left_masked() {
699        let s = sampler(r#"root ::= "ab""#);
700        let mut logits = flat_logits();
701        logits[0] = f32::NEG_INFINITY;
702        s.mask_logits(&mut logits).expect("\"ab\" is still a move");
703        assert_eq!(allowed(&logits), vec![3]);
704    }
705}