Skip to main content

kopitiam_runtime/
constraint.rs

1//! Grammar-constrained decoding: masking away tokens the model is *not*
2//! allowed to emit next, **before** the sampler ever sees the logits.
3//!
4//! # Why this module exists (the keystone, `temp_ai_design.md` §10.1 #2)
5//!
6//! A 0.5B local model cannot reliably drive a model-driven agentic tool-loop
7//! — it fumbles JSON, invents tool names that don't exist, emits malformed
8//! paths. The fix is not "hope it does better lah". The fix is to make the
9//! invalid output **physically unreachable**: at every generation step we
10//! compute which next tokens keep the output valid (a grammar / JSON schema /
11//! an allowed-tool-name set says which), and we set every *other* token's
12//! logit to [`f32::NEG_INFINITY`]. After softmax a `-inf` logit has exactly
13//! zero probability, so the sampler — greedy or stochastic — simply *cannot*
14//! pick it. This turns "fumbles structure sometimes" into "cannot produce
15//! anything but valid structure". It is cheap: just a mask over the vocab.
16//! This one feature is what makes the small model tool-capable.
17//!
18//! Provenance: **AID-0045** ("Grammar-constrained decoding — logit masking
19//! makes a small model reliably structured").
20//!
21//! # The two contracts that must never be got wrong
22//!
23//! **1. Mask BEFORE sampling, not after.** The mask slots into the *front* of
24//! the sampling path — before temperature, before top-k, before top-p (see
25//! [`crate::sampling::StochasticSampler`]'s pipeline). Masking *after* a token
26//! has already been sampled is too late: the invalid token is already chosen,
27//! and now you can only reject-and-retry, which is slow and can loop forever
28//! on a stubborn model. Masking first means the invalid token never competes
29//! in the first place.
30//!
31//! **2. Disallowed logit -> `-inf`, NOT `0.0`.** A logit is a pre-softmax
32//! score, not a probability. Setting it to `0.0` would leave the token a
33//! perfectly ordinary, very-much-samplable score (`exp(0) = 1`). Only
34//! `-inf` survives every later transform in the pipeline: temperature
35//! *divides* the logit (`-inf / t == -inf` for any `t > 0`), top-k/top-p
36//! *compare* logits (`-inf` always ranks last), and softmax maps it to
37//! `exp(-inf) == 0.0`. So `-inf` is the one value that guarantees the token
38//! stays dead no matter what the rest of the sampler does to it. This is the
39//! exact same "excluded" marker [`crate::sampling`] already uses for top-k /
40//! top-p / min-p, so a constraint mask composes with those stages for free.
41//!
42//! # What is here, and what is deliberately a later bead
43//!
44//! Two tractable constraints ship now:
45//!
46//! * [`AllowedTokens`] — a **fixed allowed-token-set** (e.g. the ids that spell
47//!   out a tool-name enum). State-independent: the same set every step.
48//! * [`JsonStructure`] — a **simple structural JSON** constraint (balanced
49//!   `{}`/`[]`, quoted strings, `:`/`,` only where the grammar allows a
50//!   value/key/separator). It is *structural*, not fully lexical — see its own
51//!   docs for exactly which subset it enforces and which it leaves to a later
52//!   full-CFG bead.
53//!
54//! The [`TokenConstraint`] trait is the seam both plug into, so a future
55//! full context-free-grammar / JSON-schema constraint drops in without
56//! touching the masking core or the sampler.
57
58use std::collections::BTreeSet;
59
60use crate::sampling::Sampler;
61
62/// The decode state a [`TokenConstraint`] reasons about when it decides which
63/// next tokens are valid.
64///
65/// Deliberately a *pure function of the tokens generated so far* — no
66/// wall-clock, no hidden mutable cursor. That is the same "context = f(state),
67/// never f(wall-clock)" discipline the runtime holds everywhere (see
68/// `temp_ai_design.md` §4): given the same generated prefix, a constraint
69/// must always return the same [`AllowedSet`], so a constrained run is
70/// reproducible and testable.
71///
72/// `generated` is the tokens produced *this decode* (the completion), not the
73/// prompt — a structural constraint cares about the shape of the output it is
74/// steering, and the prompt is upstream of that.
75pub struct DecodeState<'a> {
76    /// Token ids emitted so far in this completion, oldest first.
77    pub generated: &'a [u32],
78}
79
80/// Which next-token ids a constraint permits at the current step.
81///
82/// Two shapes, because two very different constraints want very different
83/// storage:
84///
85/// * [`AllowedSet::Only`] — a **sparse** set: "only these handful of ids, mask
86///   everything else". Right for a fixed tool-name enum over a 150k vocab,
87///   where listing the allowed ids is far cheaper than a 150k-entry bool
88///   vector.
89/// * [`AllowedSet::Mask`] — a **dense** allow-mask, one `bool` per vocab id.
90///   Right for a structural constraint that has to test every candidate token
91///   anyway (see [`JsonStructure::allowed`]), so the per-id answer is already
92///   computed.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum AllowedSet {
95    /// Only these ids are allowed; every other id is masked.
96    Only(BTreeSet<u32>),
97    /// One `bool` per vocab id: `mask[id]` allowed iff `true`. An id past the
98    /// end of the vector is treated as not allowed.
99    Mask(Vec<bool>),
100}
101
102impl AllowedSet {
103    /// Is token `id` allowed by this set?
104    ///
105    /// For [`AllowedSet::Mask`], an `id` at or past the mask length is **not**
106    /// allowed — a mask sized to a smaller vocab than the logits row can never
107    /// accidentally green-light a token it never considered.
108    pub fn contains(&self, id: u32) -> bool {
109        match self {
110            AllowedSet::Only(set) => set.contains(&id),
111            AllowedSet::Mask(mask) => mask.get(id as usize).copied().unwrap_or(false),
112        }
113    }
114}
115
116/// What went wrong when a constraint could not be satisfied.
117///
118/// Kept as a small crate-local enum (hand-rolled `Display`/`Error`, no
119/// `thiserror` dependency — one two-variant enum does not earn a proc-macro
120/// crate, per the workspace's "avoid unnecessary dependencies" rule) rather
121/// than folded into [`kopitiam_core::Error`], because "the constraint left no
122/// valid token" is a decoding-policy fact, not a tensor/model fact, and this
123/// crate is the only place it can arise.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum ConstraintError {
126    /// After masking, **no** in-range token survived: the constraint forbade
127    /// every token the logits row actually has. This is an honest, catchable
128    /// error, never a panic — a caller can surface it, widen the constraint,
129    /// or stop. It usually means a bug in the constraint (it should always
130    /// leave *some* escape hatch, e.g. an EOS/whitespace token) rather than a
131    /// bug in the model.
132    NoTokenAllowed,
133    /// A constraint was constructed with an empty allowed set — rejected at
134    /// construction so a vacuous constraint can never silently mask an entire
135    /// vocab at decode time.
136    EmptyConstraint,
137}
138
139impl std::fmt::Display for ConstraintError {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            ConstraintError::NoTokenAllowed => {
143                write!(f, "constraint masked every token: no valid next token to sample")
144            }
145            ConstraintError::EmptyConstraint => {
146                write!(f, "constraint constructed with an empty allowed-token set")
147            }
148        }
149    }
150}
151
152impl std::error::Error for ConstraintError {}
153
154/// A rule that says which next tokens are valid, given what's been decoded so
155/// far.
156///
157/// The whole grammar-constrained-decoding feature hangs off this one method.
158/// Implementors range from the dead-simple ([`AllowedTokens`] ignores the
159/// state and returns a fixed set) to the stateful ([`JsonStructure`] replays
160/// the generated bytes to work out where in the JSON grammar it is). A future
161/// full-CFG or JSON-schema constraint is just another `impl` — the masking
162/// core ([`mask_logits`]) and the sampler wrapper ([`ConstrainedSampler`])
163/// never need to know which one they're driving.
164///
165/// # Contract
166///
167/// `allowed` must be a **pure function of `state`**: same generated prefix ->
168/// same [`AllowedSet`]. No interior mutability that changes the answer across
169/// identical calls, or reproducibility (and the tests that pin it) break.
170pub trait TokenConstraint {
171    /// The set of token ids that keep the output valid if emitted next.
172    fn allowed(&self, state: &DecodeState<'_>) -> AllowedSet;
173}
174
175/// The masking step itself: set every logit whose id is **not** in `allowed`
176/// to [`f32::NEG_INFINITY`], in place.
177///
178/// This is the entire keystone in one function. It is deliberately its own
179/// standalone, fallible unit so it can be tested to death independently of any
180/// sampler or model, and so the "no valid token" case is an honest
181/// [`ConstraintError`] a caller must handle — never a panic, never a silently
182/// wrong token.
183///
184/// # Ordering contract (load-bearing — do not move this call)
185///
186/// Call this on the **raw** logits row, *before* repetition penalty /
187/// temperature / top-k / top-p / min-p. See this module's docs: `-inf`
188/// survives all of those, so masking first and sampling second is what makes
189/// an invalid token unreachable. [`ConstrainedSampler::try_sample`] holds this
190/// ordering for you.
191///
192/// # Errors
193///
194/// [`ConstraintError::NoTokenAllowed`] if, after masking, **no** in-range
195/// token is allowed (the constraint forbade every id the row has). In that
196/// case the mask is left applied but there is nothing valid to sample, so the
197/// caller must decide what to do — this function will not guess.
198pub fn mask_logits(logits: &mut [f32], allowed: &AllowedSet) -> Result<(), ConstraintError> {
199    let mut any_allowed = false;
200    for (idx, logit) in logits.iter_mut().enumerate() {
201        if allowed.contains(idx as u32) {
202            any_allowed = true;
203        } else {
204            *logit = f32::NEG_INFINITY;
205        }
206    }
207    if any_allowed {
208        Ok(())
209    } else {
210        Err(ConstraintError::NoTokenAllowed)
211    }
212}
213
214/// A **fixed allowed-token-set** constraint: the same ids every step,
215/// regardless of decode state.
216///
217/// The bread-and-butter case for the tool-loop — e.g. "the next token must be
218/// one of the ids that spell a valid tool name from the enum", or "pick one of
219/// these N candidate-file ids". Because the set never changes, it is a
220/// `BTreeSet<u32>` fixed at construction and [`AllowedTokens::allowed`] just
221/// hands it back.
222///
223/// The clone-per-step in [`AllowedTokens::allowed`] is intentional and cheap:
224/// a tool-name set is tens of ids, and cloning a small `BTreeSet` once per
225/// generated token is nothing next to a whole transformer forward pass. If a
226/// future caller pins a *large* fixed set, switch its storage to a shared
227/// `Arc<BTreeSet<u32>>` — the [`TokenConstraint`] contract does not change.
228#[derive(Debug, Clone)]
229pub struct AllowedTokens {
230    ids: BTreeSet<u32>,
231}
232
233impl AllowedTokens {
234    /// Build a fixed-set constraint from an iterator of allowed ids.
235    ///
236    /// # Errors
237    ///
238    /// [`ConstraintError::EmptyConstraint`] if `ids` is empty — a constraint
239    /// that allows nothing would mask the entire vocab at every step and can
240    /// only ever produce [`ConstraintError::NoTokenAllowed`]. Rejecting it here
241    /// turns a guaranteed-later failure into an obvious construction-time one.
242    pub fn new(ids: impl IntoIterator<Item = u32>) -> Result<Self, ConstraintError> {
243        let ids: BTreeSet<u32> = ids.into_iter().collect();
244        if ids.is_empty() {
245            return Err(ConstraintError::EmptyConstraint);
246        }
247        Ok(Self { ids })
248    }
249}
250
251impl TokenConstraint for AllowedTokens {
252    fn allowed(&self, _state: &DecodeState<'_>) -> AllowedSet {
253        AllowedSet::Only(self.ids.clone())
254    }
255}
256
257/// A token id -> its byte string, for constraints that must reason about the
258/// *bytes* a candidate token would append (not just its id).
259///
260/// [`JsonStructure`] needs this: whether emitting token 512 keeps the JSON
261/// valid depends entirely on what characters token 512 *is* (`":"` vs `,` vs
262/// `abc`), which the id alone does not tell you. A real integration hands a
263/// view over the model's tokenizer vocabulary here; tests use [`SliceVocab`].
264pub trait TokenVocab {
265    /// How many token ids exist (`0..token_count()` is the id range).
266    fn token_count(&self) -> usize;
267    /// The bytes token `id` decodes to, or `None` for an out-of-range /
268    /// byte-less control id (e.g. EOS). A `None` token contributes no bytes to
269    /// the grammar and is never structurally allowed by [`JsonStructure`] on
270    /// its own — permit it explicitly via
271    /// [`JsonStructure::with_always_allowed`] if it should be samplable (EOS
272    /// usually should, so the model can actually stop).
273    fn token_bytes(&self, id: u32) -> Option<&[u8]>;
274}
275
276/// The obvious [`TokenVocab`] over an owned `id -> bytes` table. Handy for
277/// tests and for any caller that already has the vocabulary as a `Vec`.
278#[derive(Debug, Clone)]
279pub struct SliceVocab {
280    tokens: Vec<Vec<u8>>,
281}
282
283impl SliceVocab {
284    /// Wrap a `Vec` where index `i` is the byte string of token id `i`.
285    pub fn new(tokens: Vec<Vec<u8>>) -> Self {
286        Self { tokens }
287    }
288}
289
290impl TokenVocab for SliceVocab {
291    fn token_count(&self) -> usize {
292        self.tokens.len()
293    }
294    fn token_bytes(&self, id: u32) -> Option<&[u8]> {
295        self.tokens.get(id as usize).map(Vec::as_slice)
296    }
297}
298
299/// Which container we are currently nested inside, for [`JsonMachine`].
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301enum Container {
302    Object,
303    Array,
304}
305
306/// Where the byte-level JSON grammar currently sits — "what may legally come
307/// next". See [`JsonMachine`].
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309enum Pos {
310    /// A value must begin here (document start, after `:`, after `,` in an
311    /// array). Legal starters: `{ [ "` or a scalar char.
312    ValueStart,
313    /// Right after `[`: a value may begin, or the array may close with `]`.
314    ArrayStart,
315    /// Right after `{`: a `"`-key may begin, or the object may close with `}`.
316    ObjectStart,
317    /// After `,` inside an object: a `"`-key must begin (no trailing-comma
318    /// close).
319    KeyStart,
320    /// A key string just closed: only `:` may come next.
321    Colon,
322    /// A value just completed inside a container: `,` or the matching close
323    /// bracket.
324    AfterValue,
325    /// Inside a `"`-string. `escape` tracks a pending backslash; `is_key`
326    /// tracks whether closing it lands on [`Pos::Colon`] (a key) or completes
327    /// a value.
328    InString { escape: bool, is_key: bool },
329    /// Inside a scalar run (number / `true` / `false` / `null`). Ends at the
330    /// first byte that is not a scalar-continuation char, which is then
331    /// re-processed.
332    InScalar,
333    /// A top-level value completed: the document is done. Only whitespace may
334    /// follow.
335    Done,
336}
337
338/// A streaming, byte-level *structural* JSON checker.
339///
340/// # What it enforces (and what it does NOT)
341///
342/// This is the **structural** subset, on purpose (a full JSON CFG is a later
343/// bead — see this module's docs):
344///
345/// * Enforced: balanced `{}` / `[]` nesting, strings opened and closed with
346///   `"` (with `\`-escape handling), `:` only between an object key and its
347///   value, `,` only between elements, a value expected after `:` / `[` / `,`,
348///   no trailing comma, no stray close bracket, nothing but whitespace after a
349///   complete top-level value.
350/// * **Not** enforced (left to the future full-CFG bead): that a number is
351///   *well-formed* (`1.2.3` and `--0` pass — any run of `[A-Za-z0-9.+-]` is
352///   accepted as "a scalar"), that a literal is spelled exactly `true` /
353///   `false` / `null` (any letter-run passes), and full `\uXXXX` escape-digit
354///   validation (the four hex digits are not checked). It guarantees the
355///   *shape* is valid JSON, not that every scalar *token* is.
356///
357/// That subset is exactly what makes a small model's tool-call output
358/// *parseable* — brace/quote/separator discipline is where a 0.5B fumbles, and
359/// it is what this cheap machine fixes.
360#[derive(Debug, Clone)]
361struct JsonMachine {
362    stack: Vec<Container>,
363    pos: Pos,
364}
365
366impl JsonMachine {
367    fn new() -> Self {
368        Self { stack: Vec::new(), pos: Pos::ValueStart }
369    }
370
371    /// Feed one byte. Returns `false` if the byte is illegal in the current
372    /// state (the machine must not be used further after a `false`).
373    fn step(&mut self, b: u8) -> bool {
374        loop {
375            match self.pos {
376                Pos::InString { .. } => return self.string_step(b),
377                Pos::InScalar => {
378                    if is_scalar_cont(b) {
379                        return true; // scalar keeps going, byte consumed
380                    }
381                    // The scalar ends *before* this byte. Complete the value,
382                    // then re-process `b` in the resulting position (it might
383                    // be a `,`, a close bracket, or whitespace).
384                    self.complete_value();
385                    continue;
386                }
387                _ => return self.consume(b),
388            }
389        }
390    }
391
392    /// Advance a structural (non-string, non-scalar) position by one byte.
393    fn consume(&mut self, b: u8) -> bool {
394        if is_ws(b) {
395            // Whitespace is legal in every structural position, including
396            // `Done`. It never changes the position.
397            return true;
398        }
399        match self.pos {
400            Pos::ValueStart => self.begin_value(b),
401            Pos::ArrayStart => {
402                if b == b']' {
403                    self.close(Container::Array)
404                } else {
405                    self.begin_value(b)
406                }
407            }
408            Pos::ObjectStart => match b {
409                b'"' => {
410                    self.pos = Pos::InString { escape: false, is_key: true };
411                    true
412                }
413                b'}' => self.close(Container::Object),
414                _ => false,
415            },
416            Pos::KeyStart => match b {
417                b'"' => {
418                    self.pos = Pos::InString { escape: false, is_key: true };
419                    true
420                }
421                _ => false,
422            },
423            Pos::Colon => {
424                if b == b':' {
425                    self.pos = Pos::ValueStart;
426                    true
427                } else {
428                    false
429                }
430            }
431            Pos::AfterValue => match b {
432                b',' => match self.stack.last() {
433                    Some(Container::Object) => {
434                        self.pos = Pos::KeyStart;
435                        true
436                    }
437                    Some(Container::Array) => {
438                        self.pos = Pos::ValueStart;
439                        true
440                    }
441                    None => false, // a comma at top level is not valid
442                },
443                b'}' => self.close(Container::Object),
444                b']' => self.close(Container::Array),
445                _ => false,
446            },
447            Pos::Done => false, // only whitespace (handled above) may follow
448            Pos::InString { .. } | Pos::InScalar => unreachable!("handled in step()"),
449        }
450    }
451
452    /// Begin a value at `b` from a value-expected position.
453    fn begin_value(&mut self, b: u8) -> bool {
454        match b {
455            b'{' => {
456                self.stack.push(Container::Object);
457                self.pos = Pos::ObjectStart;
458                true
459            }
460            b'[' => {
461                self.stack.push(Container::Array);
462                self.pos = Pos::ArrayStart;
463                true
464            }
465            b'"' => {
466                self.pos = Pos::InString { escape: false, is_key: false };
467                true
468            }
469            _ if is_scalar_start(b) => {
470                self.pos = Pos::InScalar;
471                true
472            }
473            _ => false,
474        }
475    }
476
477    /// Close the current container, which must match `want`, then treat the
478    /// closed container as a completed value.
479    fn close(&mut self, want: Container) -> bool {
480        match self.stack.last() {
481            Some(&top) if top == want => {
482                self.stack.pop();
483                self.complete_value();
484                true
485            }
486            _ => false,
487        }
488    }
489
490    /// A value just finished (scalar ended, string value closed, or container
491    /// closed): decide where that leaves us.
492    fn complete_value(&mut self) {
493        self.pos = if self.stack.is_empty() { Pos::Done } else { Pos::AfterValue };
494    }
495
496    /// Advance while inside a string.
497    fn string_step(&mut self, b: u8) -> bool {
498        let Pos::InString { escape, is_key } = self.pos else {
499            unreachable!("string_step called outside a string");
500        };
501        if escape {
502            // Any byte after a backslash is a consumed escape payload. We do
503            // not validate `\uXXXX` hex digits here (documented as a
504            // later-bead gap on JsonMachine).
505            self.pos = Pos::InString { escape: false, is_key };
506            return true;
507        }
508        match b {
509            b'\\' => {
510                self.pos = Pos::InString { escape: true, is_key };
511                true
512            }
513            b'"' => {
514                if is_key {
515                    self.pos = Pos::Colon;
516                } else {
517                    self.complete_value();
518                }
519                true
520            }
521            // A raw control byte inside a string is invalid JSON; blocking it
522            // keeps the constraint honest about string content.
523            b'\n' | b'\r' => false,
524            _ => true, // ordinary string byte
525        }
526    }
527}
528
529fn is_ws(b: u8) -> bool {
530    matches!(b, b' ' | b'\t' | b'\n' | b'\r')
531}
532
533fn is_scalar_start(b: u8) -> bool {
534    b == b'-' || b.is_ascii_digit() || b.is_ascii_alphabetic()
535}
536
537fn is_scalar_cont(b: u8) -> bool {
538    b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+')
539}
540
541/// A [`TokenConstraint`] that keeps the model's output structurally valid JSON.
542///
543/// See [`JsonMachine`] for exactly which subset is enforced. At each step
544/// [`JsonStructure::allowed`] replays the already-generated bytes to find the
545/// current grammar position, then tests every candidate token: a token is
546/// allowed iff feeding its bytes from the current position never hits an
547/// illegal transition.
548///
549/// # `always_allowed` — the escape hatch you almost always need
550///
551/// A JSON document, once complete, structurally permits *only* whitespace
552/// next. If the model can never emit EOS, it can never stop. So control ids
553/// (EOS above all) should be registered via
554/// [`JsonStructure::with_always_allowed`]: they bypass the structural check and
555/// are always permitted, which is also what stops the mask from going empty at
556/// `Done` and raising [`ConstraintError::NoTokenAllowed`].
557///
558/// # Cost
559///
560/// `allowed` is `O(vocab_size × token_len)` per step (it probes every
561/// candidate token). Fine for the scaffold and for the small vocabularies the
562/// tool-loop actually constrains; a later bead can make the machine
563/// *incremental* (advance by the one chosen token instead of re-probing the
564/// whole vocab) if a large-vocab JSON constraint ever needs it. The pure
565/// `allowed(&self, state)` shape is kept deliberately so the answer stays a
566/// function of state (reproducible), incremental caching being an internal
567/// optimisation that must not change it.
568pub struct JsonStructure<V: TokenVocab> {
569    vocab: V,
570    always_allowed: BTreeSet<u32>,
571}
572
573impl<V: TokenVocab> JsonStructure<V> {
574    /// A JSON-structural constraint over `vocab`, with no always-allowed
575    /// control ids. Prefer [`JsonStructure::with_always_allowed`] so the model
576    /// has a way to stop.
577    pub fn new(vocab: V) -> Self {
578        Self { vocab, always_allowed: BTreeSet::new() }
579    }
580
581    /// As [`JsonStructure::new`], but `ids` (typically just EOS) always pass
582    /// the mask regardless of grammar position. See the type's docs for why
583    /// this is almost always needed.
584    pub fn with_always_allowed(vocab: V, ids: impl IntoIterator<Item = u32>) -> Self {
585        Self { vocab, always_allowed: ids.into_iter().collect() }
586    }
587
588    /// Replay `generated` to reconstruct the grammar position. Prior tokens are
589    /// assumed to have been valid (they were only sampled because a previous
590    /// mask allowed them), so a `false` transition during replay cannot happen
591    /// in a real constrained run; we ignore its return here rather than panic,
592    /// which keeps `allowed` total even if a caller feeds a hand-built
593    /// `generated` that never went through the mask.
594    fn replay(&self, generated: &[u32]) -> JsonMachine {
595        let mut machine = JsonMachine::new();
596        for &id in generated {
597            if let Some(bytes) = self.vocab.token_bytes(id) {
598                for &b in bytes {
599                    machine.step(b);
600                }
601            }
602        }
603        machine
604    }
605}
606
607impl<V: TokenVocab> TokenConstraint for JsonStructure<V> {
608    fn allowed(&self, state: &DecodeState<'_>) -> AllowedSet {
609        let machine = self.replay(state.generated);
610        let count = self.vocab.token_count();
611        let mut mask = vec![false; count];
612        for id in 0..count as u32 {
613            if self.always_allowed.contains(&id) {
614                mask[id as usize] = true;
615                continue;
616            }
617            let Some(bytes) = self.vocab.token_bytes(id) else { continue };
618            if bytes.is_empty() {
619                continue; // an empty token advances the grammar nowhere; not on its own useful
620            }
621            let mut probe = machine.clone();
622            let mut ok = true;
623            for &b in bytes {
624                if !probe.step(b) {
625                    ok = false;
626                    break;
627                }
628            }
629            mask[id as usize] = ok;
630        }
631        AllowedSet::Mask(mask)
632    }
633}
634
635/// Wraps any [`Sampler`] so the constraint's mask is applied at the **front**
636/// of the sampling path — the drop-in integration seam for real decoding.
637///
638/// # How it plugs in
639///
640/// [`ConstrainedSampler::try_sample`] does, in order: read the generated-so-far
641/// prefix -> ask the [`TokenConstraint`] for the [`AllowedSet`] -> [`mask_logits`]
642/// the raw logits (`-inf` on disallowed) -> hand the masked row to the inner
643/// [`Sampler`] (temperature/top-k/top-p/... all run *after* the mask, exactly
644/// as required) -> record the chosen token so the next step's [`DecodeState`]
645/// is correct. Because masking happens before the inner sampler, the sampler
646/// can only ever pick an allowed token — greedy's `argmax` skips `-inf`, and
647/// stochastic softmax gives `-inf` zero probability.
648///
649/// # Why `try_sample` is fallible instead of an infallible `Sampler` impl
650///
651/// [`Sampler::sample`] returns a bare `u32` — it cannot report "the constraint
652/// left nothing to sample". That case ([`ConstraintError::NoTokenAllowed`])
653/// must be an honest error, not a panic and not a silently-wrong token, so the
654/// constrained path is deliberately its own fallible method rather than an
655/// `impl Sampler` that would have to swallow the error. Callers drive it
656/// through [`crate::generate::generate_constrained`], which threads the error
657/// out cleanly.
658pub struct ConstrainedSampler<C: TokenConstraint, S: Sampler> {
659    constraint: C,
660    inner: S,
661    generated: Vec<u32>,
662}
663
664impl<C: TokenConstraint, S: Sampler> ConstrainedSampler<C, S> {
665    /// Wrap `inner` so `constraint` masks its logits every step.
666    pub fn new(constraint: C, inner: S) -> Self {
667        Self { constraint, inner, generated: Vec::new() }
668    }
669
670    /// The tokens this sampler has produced so far this decode.
671    pub fn generated(&self) -> &[u32] {
672        &self.generated
673    }
674
675    /// Forget the generated history so the same sampler can drive a fresh
676    /// decode (the constraint restarts from an empty prefix).
677    pub fn reset(&mut self) {
678        self.generated.clear();
679    }
680
681    /// Mask, then sample — the constrained step. See the type's docs for the
682    /// exact ordering it guarantees.
683    ///
684    /// # Errors
685    ///
686    /// [`ConstraintError::NoTokenAllowed`] if the constraint masked every
687    /// in-range token this step (see [`mask_logits`]). The generated history is
688    /// left unchanged on error, so a caller may retry with a widened
689    /// constraint without corrupting the decode state.
690    pub fn try_sample(&mut self, logits: &[f32]) -> Result<u32, ConstraintError> {
691        let allowed = {
692            let state = DecodeState { generated: &self.generated };
693            self.constraint.allowed(&state)
694        };
695        let mut work = logits.to_vec();
696        mask_logits(&mut work, &allowed)?;
697        let chosen = self.inner.sample(&work);
698        // Defence in depth: masking guarantees the inner sampler cannot return
699        // a disallowed token (greedy skips `-inf`, stochastic gives it zero
700        // probability). If this ever fires, the invariant in this module's docs
701        // has been broken and the whole feature is unsound.
702        debug_assert!(
703            allowed.contains(chosen),
704            "inner sampler returned masked-out token {chosen}; mask-before-sample invariant broken"
705        );
706        self.generated.push(chosen);
707        Ok(chosen)
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use crate::sampling::{greedy_argmax, GreedySampler, SamplingConfig, StochasticSampler};
715
716    // -- AllowedSet --
717
718    #[test]
719    fn allowed_set_only_contains_listed_ids() {
720        let set = AllowedSet::Only([1, 3, 5].into_iter().collect());
721        assert!(set.contains(3));
722        assert!(!set.contains(2));
723    }
724
725    #[test]
726    fn allowed_set_mask_treats_out_of_range_ids_as_not_allowed() {
727        let set = AllowedSet::Mask(vec![true, false, true]);
728        assert!(set.contains(0));
729        assert!(!set.contains(1));
730        assert!(set.contains(2));
731        assert!(!set.contains(9), "an id past the mask length must not be allowed");
732    }
733
734    // -- mask_logits: the keystone contract --
735
736    #[test]
737    fn mask_forces_only_allowed_ids_to_survive_as_neg_inf() {
738        // Vocab of 5; allow only ids 1 and 4.
739        let allowed = AllowedSet::Only([1, 4].into_iter().collect());
740        let mut logits = vec![10.0, 2.0, 9.0, 8.0, 1.0];
741        mask_logits(&mut logits, &allowed).unwrap();
742        assert!(logits[0].is_infinite() && logits[0] < 0.0, "disallowed id 0 must be -inf");
743        assert_eq!(logits[1], 2.0, "allowed id 1 must be untouched");
744        assert!(logits[2].is_infinite() && logits[2] < 0.0, "disallowed id 2 must be -inf");
745        assert!(logits[3].is_infinite() && logits[3] < 0.0, "disallowed id 3 must be -inf");
746        assert_eq!(logits[4], 1.0, "allowed id 4 must be untouched");
747    }
748
749    #[test]
750    fn masked_argmax_is_always_in_the_allowed_set() {
751        // Id 0 has the highest RAW logit but is disallowed; greedy over the
752        // masked row must pick the best *allowed* id (3), never id 0.
753        let allowed = AllowedSet::Only([2, 3].into_iter().collect());
754        let mut logits = vec![100.0, 50.0, 4.0, 9.0, 7.0];
755        mask_logits(&mut logits, &allowed).unwrap();
756        let picked = greedy_argmax(&logits);
757        assert_eq!(picked, 3, "argmax must land on the highest *allowed* logit, not the masked-out max");
758        assert!(allowed.contains(picked));
759    }
760
761    #[test]
762    fn mask_survives_temperature_scaling_stays_neg_inf() {
763        // -inf divided by any positive temperature is still -inf -> this is
764        // exactly why we mask to -inf and not 0.0 (0.0/t == 0.0, a very
765        // samplable score). Pin it.
766        let allowed = AllowedSet::Only([1].into_iter().collect());
767        let mut logits = vec![5.0, 5.0];
768        mask_logits(&mut logits, &allowed).unwrap();
769        let scaled = logits[0] / 0.7;
770        assert!(scaled.is_infinite() && scaled < 0.0, "-inf must survive temperature division");
771    }
772
773    #[test]
774    fn empty_allowed_set_is_an_honest_error_not_a_panic() {
775        // No allowed id is in range for this 3-long row -> NoTokenAllowed,
776        // returned as an Err, never a panic.
777        let allowed = AllowedSet::Only([99].into_iter().collect());
778        let mut logits = vec![1.0, 2.0, 3.0];
779        assert_eq!(mask_logits(&mut logits, &allowed), Err(ConstraintError::NoTokenAllowed));
780    }
781
782    // -- AllowedTokens --
783
784    #[test]
785    fn allowed_tokens_rejects_an_empty_set_at_construction() {
786        assert_eq!(AllowedTokens::new([]).unwrap_err(), ConstraintError::EmptyConstraint);
787    }
788
789    #[test]
790    fn allowed_tokens_returns_its_fixed_set_regardless_of_state() {
791        let c = AllowedTokens::new([2, 7]).unwrap();
792        let a = c.allowed(&DecodeState { generated: &[] });
793        let b = c.allowed(&DecodeState { generated: &[2, 2, 2] });
794        assert_eq!(a, b, "a fixed-set constraint must ignore decode state");
795        assert!(a.contains(2) && a.contains(7) && !a.contains(3));
796    }
797
798    // -- ConstrainedSampler: real-sampling integration --
799
800    #[test]
801    fn constrained_greedy_sampler_only_ever_emits_allowed_ids() {
802        let constraint = AllowedTokens::new([2, 4]).unwrap();
803        let mut sampler = ConstrainedSampler::new(constraint, GreedySampler);
804        // Id 0 always has the highest raw logit; without the mask greedy would
805        // pick it every time. With the mask it must pick among {2, 4}.
806        for _ in 0..10 {
807            let logits = vec![100.0, 90.0, 3.0, 80.0, 5.0];
808            let id = sampler.try_sample(&logits).unwrap();
809            assert!(id == 2 || id == 4, "constrained greedy emitted disallowed id {id}");
810        }
811    }
812
813    #[test]
814    fn constrained_stochastic_sampler_only_ever_emits_allowed_ids() {
815        let constraint = AllowedTokens::new([1, 3]).unwrap();
816        let inner = StochasticSampler::new(SamplingConfig {
817            temperature: 1.2,
818            top_k: Some(4),
819            top_p: Some(0.9),
820            seed: 123,
821            ..SamplingConfig::default()
822        });
823        let mut sampler = ConstrainedSampler::new(constraint, inner);
824        let mut rng_seed = 7u64;
825        for _ in 0..200 {
826            // cheap LCG just to vary the logits row
827            rng_seed = rng_seed.wrapping_mul(6364136223846793005).wrapping_add(1);
828            let logits: Vec<f32> = (0..5).map(|k| ((rng_seed >> (k * 8)) & 0xff) as f32 / 25.0).collect();
829            let id = sampler.try_sample(&logits).unwrap();
830            assert!(id == 1 || id == 3, "constrained stochastic emitted disallowed id {id}");
831        }
832    }
833
834    #[test]
835    fn constrained_sampler_records_its_generated_prefix() {
836        let constraint = AllowedTokens::new([2]).unwrap();
837        let mut sampler = ConstrainedSampler::new(constraint, GreedySampler);
838        sampler.try_sample(&[0.0, 0.0, 1.0]).unwrap();
839        sampler.try_sample(&[0.0, 0.0, 1.0]).unwrap();
840        assert_eq!(sampler.generated(), &[2, 2]);
841        sampler.reset();
842        assert_eq!(sampler.generated(), &[] as &[u32]);
843    }
844
845    #[test]
846    fn constrained_sampler_surfaces_no_token_allowed_as_err() {
847        // Allowed id 9 is out of range of a 3-long logits row -> honest Err.
848        let constraint = AllowedTokens::new([9]).unwrap();
849        let mut sampler = ConstrainedSampler::new(constraint, GreedySampler);
850        assert_eq!(sampler.try_sample(&[1.0, 2.0, 3.0]), Err(ConstraintError::NoTokenAllowed));
851        assert_eq!(sampler.generated(), &[] as &[u32], "a failed step must not record a token");
852    }
853
854    // -- JsonStructure: the structural machine --
855
856    /// A vocab of single structural characters plus a few multi-byte tokens,
857    /// so tests can drive the JSON machine by id.
858    fn json_test_vocab() -> SliceVocab {
859        SliceVocab::new(vec![
860            b"{".to_vec(),     // 0
861            b"}".to_vec(),     // 1
862            b"[".to_vec(),     // 2
863            b"]".to_vec(),     // 3
864            b":".to_vec(),     // 4
865            b",".to_vec(),     // 5
866            b"\"".to_vec(),    // 6  bare quote
867            b"\"k\"".to_vec(), // 7  a complete "k" string
868            b"1".to_vec(),     // 8  a scalar
869            b" ".to_vec(),     // 9  whitespace
870            b"\"v\"".to_vec(), // 10 a complete "v" string
871        ])
872    }
873
874    fn allowed_ids(set: &AllowedSet, count: usize) -> Vec<u32> {
875        (0..count as u32).filter(|&id| set.contains(id)).collect()
876    }
877
878    #[test]
879    fn json_document_start_allows_a_value_opener_not_a_close() {
880        let c = JsonStructure::new(json_test_vocab());
881        let a = c.allowed(&DecodeState { generated: &[] });
882        // `{`(0), `[`(2), a string(6,7,10), a scalar(8), whitespace(9) may
883        // start; a close `}`(1) `]`(3), `:`(4), `,`(5) may not.
884        assert!(a.contains(0) && a.contains(2), "must allow '{{' and '['");
885        assert!(a.contains(8), "must allow a scalar start");
886        assert!(!a.contains(1) && !a.contains(3), "must not allow a close bracket at start");
887        assert!(!a.contains(4) && !a.contains(5), "must not allow ':' or ',' at start");
888    }
889
890    #[test]
891    fn json_after_open_brace_forces_a_key_or_close_only() {
892        let c = JsonStructure::new(json_test_vocab());
893        // generated: `{`
894        let a = c.allowed(&DecodeState { generated: &[0] });
895        assert!(a.contains(7), "after '{{' a \"key\" string is allowed");
896        assert!(a.contains(6), "after '{{' a bare quote (start of a key) is allowed");
897        assert!(a.contains(1), "after '{{' the object may immediately close with '}}'");
898        assert!(!a.contains(0), "after '{{' another '{{' (a non-string value) is NOT a valid key");
899        assert!(!a.contains(8), "after '{{' a bare scalar is NOT a valid key");
900        assert!(!a.contains(5), "after '{{' a ',' is not valid");
901    }
902
903    #[test]
904    fn json_after_key_forces_a_colon() {
905        let c = JsonStructure::new(json_test_vocab());
906        // generated: `{` `"k"`
907        let a = c.allowed(&DecodeState { generated: &[0, 7] });
908        let non_ws: Vec<u32> = allowed_ids(&a, 11).into_iter().filter(|&id| id != 9).collect();
909        assert_eq!(non_ws, vec![4], "after a key only ':' (plus whitespace) is allowed");
910    }
911
912    #[test]
913    fn json_after_colon_expects_a_value() {
914        let c = JsonStructure::new(json_test_vocab());
915        // generated: `{` `"k"` `:`
916        let a = c.allowed(&DecodeState { generated: &[0, 7, 4] });
917        assert!(a.contains(10), "after ':' a string value is allowed");
918        assert!(a.contains(8), "after ':' a scalar value is allowed");
919        assert!(a.contains(0) && a.contains(2), "after ':' a nested object/array is allowed");
920        assert!(!a.contains(1) && !a.contains(4), "after ':' a '}}' or ':' is not allowed");
921    }
922
923    #[test]
924    fn json_after_value_in_object_allows_comma_or_close_only() {
925        let c = JsonStructure::new(json_test_vocab());
926        // generated: `{` `"k"` `:` `"v"` -- a *string* value, so the value is
927        // definitively complete (unlike a bare scalar, which has no explicit
928        // terminator and may still be continued mid-stream -- see
929        // `json_mid_scalar_may_continue_or_be_separated`).
930        let a = c.allowed(&DecodeState { generated: &[0, 7, 4, 10] });
931        assert!(a.contains(5), "after a value, ',' is allowed");
932        assert!(a.contains(1), "after a value, '}}' (close object) is allowed");
933        assert!(!a.contains(3), "a ']' cannot close an object");
934        assert!(!a.contains(10), "a bare second value without a separator is not allowed");
935        assert!(!a.contains(8), "a bare scalar value without a separator is not allowed");
936    }
937
938    #[test]
939    fn json_mid_scalar_may_continue_or_be_separated() {
940        // A scalar has no explicit terminator, so after emitting `1` the model
941        // may legitimately continue the number (`1` -> `11`) OR terminate it
942        // with a separator/close. Both are structurally valid; this pins that
943        // streaming-scalar behaviour so nobody "fixes" it into a bug.
944        let c = JsonStructure::new(json_test_vocab());
945        // generated: `{` `"k"` `:` `1`  (machine is still mid-scalar)
946        let a = c.allowed(&DecodeState { generated: &[0, 7, 4, 8] });
947        assert!(a.contains(8), "mid-scalar, another digit continues the number");
948        assert!(a.contains(5), "mid-scalar, ',' terminates the scalar and separates");
949        assert!(a.contains(1), "mid-scalar, '}}' terminates the scalar and closes the object");
950        assert!(!a.contains(3), "mid-scalar, ']' still cannot close an *object*");
951    }
952
953    #[test]
954    fn json_balanced_close_returns_to_done() {
955        let c = JsonStructure::new(json_test_vocab());
956        // generated: `{` `"k"` `:` `1` `}` -> a complete document.
957        let a = c.allowed(&DecodeState { generated: &[0, 7, 4, 8, 1] });
958        // Only whitespace may follow a complete top-level value.
959        assert!(a.contains(9), "whitespace may follow a complete document");
960        assert!(
961            !a.contains(5) && !a.contains(0) && !a.contains(1),
962            "nothing structural may follow a complete document"
963        );
964    }
965
966    #[test]
967    fn json_empty_array_may_close_immediately() {
968        let c = JsonStructure::new(json_test_vocab());
969        // generated: `[`
970        let a = c.allowed(&DecodeState { generated: &[2] });
971        assert!(a.contains(3), "an empty array '[]' must be allowed to close");
972        assert!(a.contains(8), "an array may also start with a value");
973        assert!(!a.contains(5), "a leading ',' inside a fresh array is not valid");
974    }
975
976    #[test]
977    fn json_always_allowed_ids_bypass_the_grammar() {
978        // Register id 3 (`]`) as always-allowed even though it is structurally
979        // invalid at document start; it must come through. This is how EOS is
980        // wired in a real decode.
981        let c = JsonStructure::with_always_allowed(json_test_vocab(), [3]);
982        let a = c.allowed(&DecodeState { generated: &[] });
983        assert!(a.contains(3), "an always-allowed id must bypass the structural check");
984    }
985
986    #[test]
987    fn json_machine_accepts_a_full_nested_document() {
988        // Drive the raw machine over a full document to prove the pieces
989        // compose: {"k":[1,"v"]}
990        let mut m = JsonMachine::new();
991        for &b in b"{\"k\":[1,\"v\"]}" {
992            assert!(m.step(b), "byte {:?} rejected in a valid document", b as char);
993        }
994        assert_eq!(m.pos, Pos::Done, "a balanced document must land in Done");
995    }
996
997    #[test]
998    fn json_machine_rejects_a_mismatched_close() {
999        let mut m = JsonMachine::new();
1000        assert!(m.step(b'{'));
1001        // A `]` cannot close an object.
1002        assert!(!m.step(b']'));
1003    }
1004}