Constrained Decoding › Grammar-Guided Generation
Language AI HandbookAll chapters Constrained Decoding › Grammar-Guided GenerationOne section · Sections in this chapter4 / 13 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
The most powerful form of constrained decoding uses formal grammars to specify valid outputs. A grammar defines the structure of valid strings, and we use it to compute valid tokens at each step.
Context-Free Grammars
Context-free grammars (CFGs) can express the syntax of most structured formats: JSON, XML, SQL, programming languages, and more. A grammar consists of production rules that define how to expand non-terminal symbols into terminals (actual tokens) and other non-terminals.
# A simple grammar for arithmetic expressions
# E -> E + T | E - T | T
# T -> T * F | T / F | F
# F -> ( E ) | number
# For constrained decoding, we need to track parser state
class SimpleArithmeticParser:
"""
Track valid next tokens for simple arithmetic expressions.
This is a simplified example - real implementations use
more sophisticated parsing techniques.
"""
def __init__(self):
self.stack = ["E"] # Start with expression
self.depth = 0 # Parenthesis depth
def valid_next_tokens(self, current_text: str) -> set[str]:
"""Determine valid next tokens based on current parse state."""
# Simplified logic for demonstration
if not current_text:
return {"(", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}
last_char = current_text[-1]
if last_char.isdigit():
# After a number: operators, closing paren, or more digits
valid = {
"+",
"-",
"*",
"/",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
}
if self.depth > 0:
valid.add(")")
return valid
elif last_char in "+-*/":
# After operator: number or opening paren
return {"(", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}
elif last_char == "(":
self.depth += 1
return {"(", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}
elif last_char == ")":
self.depth -= 1
valid = {"+", "-", "*", "/"}
if self.depth > 0:
valid.add(")")
return valid
return set()Valid next tokens at each step of expression building:
After (empty) → valid: ['(', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
After "3" → valid: ['*', '+', '-', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
After "3+" → valid: ['(', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
After "3+4" → valid: ['*', '+', '-', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
After "(" → valid: ['(', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
After "(2" → valid: [')', '*', '+', '-', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
After "(2+3" → valid: [')', '*', '+', '-', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']The parser maintains state as tokens are generated, computing valid continuations at each step. Real grammar-guided generation uses more sophisticated parsing algorithms, but the core principle remains: track the grammar state and filter tokens accordingly.

Implementing Grammar Constraints
For practical grammar-guided generation, we need to map between the language model's tokens (often subwords) and the grammar's terminals. This mapping is non-trivial because a single grammar terminal might span multiple model tokens, and vice versa.
import numpy as np
class GrammarConstrainedSampler:
"""
Apply grammar constraints during text generation.
This simplified implementation assumes character-level tokenization.
Real implementations must handle subword tokenization carefully.
"""
def __init__(self, grammar_valid_fn):
"""
Initialize with a function that returns valid next tokens.
Args:
grammar_valid_fn: Function(current_text) -> set of valid tokens
"""
self.grammar_valid_fn = grammar_valid_fn
def constrained_sample(
self,
logits: np.ndarray,
current_text: str,
token_to_char: dict[int, str],
) -> int:
"""
Sample from logits with grammar constraints.
Args:
logits: Raw model logits over vocabulary
current_text: Text generated so far
token_to_char: Mapping from token IDs to characters
Returns:
Selected token ID
"""
# Get valid tokens from grammar
valid_chars = self.grammar_valid_fn(current_text)
# Create mask for valid token IDs
valid_token_ids = {
tid for tid, char in token_to_char.items() if char in valid_chars
}
# Apply mask: set invalid logits to -infinity
masked_logits = logits.copy()
for tid in range(len(logits)):
if tid not in valid_token_ids:
masked_logits[tid] = float("-inf")
# Convert to probabilities and sample
probs = self._softmax(masked_logits)
return np.random.choice(len(probs), p=probs)
def _softmax(self, x: np.ndarray) -> np.ndarray:
"""Compute softmax probabilities."""
exp_x = np.exp(x - np.max(x))
return exp_x / exp_x.sum()Constrained sampling in action:
Scenario: Model logits favor ')' but grammar requires number/operator
Current text: '1+'
Valid next tokens: {'5', '8', '3', '6', '9', '(', '4', '0', '2', '7', '1'}
Model logits: {'0': np.float64(1.0), '1': np.float64(1.5), '2': np.float64(1.2), '+': np.float64(0.5), '-': np.float64(0.3), '*': np.float64(0.2), '(': np.float64(0.8), ')': np.float64(3.0)}
Sampled distribution over 100 trials:
'1': 31%
'0': 29%
'2': 25%
'(': 15%Notice how the sampler never outputs ')' even though the model assigned it the highest logit. The constraint prevents invalid tokens from being selected, while the relative preferences among valid tokens are preserved.