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}