Constrained Decoding › Grammar-Guided Generation

Michael BrenndoerferUpdated July 31, 20256 min read
Language AI HandbookAll chapters
Part 28 of 67GPT Architecture
Chapter 206Constrained Decoding › Grammar-Guided Generation
Reading section 4 of 13 · Grammar-Guided Generation · 6 minSection 4 of 13 · 6 min

Book contents

Section 4 of 13 · 6 min read

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.

In[8]:
Code
# 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()
Out[9]:
Console
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.

Out[10]:
Visualization
Bar chart showing valid next token count at each position of the expression (2+3)*4, with bars colored by token type: start in blue, brackets in orange, operators in green, and numbers in purple.
Valid token count at each position during arithmetic expression parsing. The number of valid choices varies based on grammar state and context.

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.

In[11]:
Code
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()
Out[12]:
Console
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.