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}