Constrained Decoding — Design Record¶
This document records the design of Fucina's grammar/JSON-schema constrained decoding: the pluggable logit-processor seam on the shared sampler, the vendored llguidance engine behind it, the argument for why the seam composes with speculative decoding without a rollback primitive, the grammar-driven drafting layer that turns a constraint from a speculation-killer into a speculation accelerator, and the adjudications — designs evaluated and rejected, with the conditions that would reopen them. Grounded in the source; file:line anchors are included so claims can be re-verified rather than trusted.
The short version: every decode path in the tree samples through one
Sampler.next, so a single optional hook there — mask the logits row before
the pipeline, observe the selected token after — gives every model family
constrained decoding with zero decode-loop changes. The mask comes from
llguidance (compiled JSON schema / regex / Lark grammar → per-step token
bitmask, ~tens of µs of pure CPU work per token), vendored under
vendor/llguidance/ and built only under -Dllguidance=true; the seam
itself is pure Zig and always available. Constrained output is
token-for-token identical across the plain, speculative, and lockstep-batch
paths — proven greedy and sampled — and with the grammar-drafting layer the
constraint raises speculative acceptance instead of muting it (measured:
0% → 83% on JSON-schema chat, output byte-identical).
Scope: any runner built on llm.sampler.Sampler — today the qwen3 and
gemma4 CLIs (--json-schema JSON|@FILE, --lark GRAMMAR|@FILE,
--regex PATTERN) and anything embedding chat.Conversation
(Options.logit_processor). Non-autoregressive paths (diffusion_gemma's
entropy-bound sampler, omnivoice's MaskGIT) are structurally out of scope —
they do not sample token-by-token through the shared sampler.
1. Architecture¶
Two layers with one optional third, each independently testable:
LogitProcessor (vtable) src/llm/logit_processor.zig:35
process(logits, history) mask/bias the row before sampling
commit(token) observe every selected token
reset() re-arm per assistant turn
forcedTokens / validPrefixLen optional structural lookaheads
↑ hosted by
Sampler.processor src/llm/sampler.zig:58
process at next() entry :73, commit on every exit path :104
↑ installed via
chat.Options.logit_processor src/llm/chat.zig:159
per-turn reset :304, sendBatch share guard :412
↑ implemented by (opt-in, -Dllguidance=true)
llguidance Constraint src/llm/llguidance.zig
grammar compile + token bitmask over a Fucina-tokenizer bridge
↑ drafted by (when speculation is on)
ConstrainedSource src/llm/speculative/constrained.zig:36
forced spans → certain drafts; invalid drafts pruned pre-verify
The layering is strict: logit_processor.zig knows nothing about grammars,
llguidance.zig knows nothing about chat or speculation, and
constrained.zig knows only the two vtables it bridges (LogitProcessor ↔
DraftSource). A bias list, a banned-token rule, or a watermarking scheme
plugs into the same seam with no llguidance involvement — and conversely,
the llguidance build flag being off removes zero functionality from the
seam itself.
2. The seam — why inside the Sampler¶
The decision with the most leverage in this design is where the processor
hook lives. Every decode path already funnels through
Sampler.next(ctx, logits, history):
chat.Conversation.send(plain turn decode),chat.Conversation.sendBatch(lockstep multi-streamsampleStep),- the speculative decoder's plain step and every verify row
(
speculative/core.zig), - every hand-rolled runner loop (
examples/qwen3/main.zigcompletion/bench, multi-stream arms).
Hooking the sampler therefore means: implement once, constrained
everywhere — including paths that did not exist when the constraint was
written, as long as they sample through a Sampler. The alternative
(wiring a mask call into each loop) was rejected; see §8.
The contract (src/llm/logit_processor.zig:35):
process(logits, history)mutates one[vocab]f32 row in place before penalties/temperature/top-k/top-p/min-p run. A grammar mask writes-infover forbidden ids;-infsurvives the penalty pass unchanged (divide, multiply, and subtract all map-inf → -inf), so ordering against penalties is not semantically load-bearing.commit(token)observes the selected token, exactly once pernextcall, on every exit path — greedy included (src/llm/sampler.zig:104,146). This is the property everything else leans on (§4).reset()re-arms the state machine for a fresh constrained region.chat.Conversationcalls it inbeginTurnTokens(src/llm/chat.zig:304) — the shared turn prologue ofsend,sendSpecandsendBatch— so one constraint instance governs each assistant reply independently across a multi-turn conversation.- A processor that leaves no selectable candidate is
error.AllTokensMasked(src/llm/sampler.zig:103,120): a broken constraint fails loudly instead of silently sampling from a masked-out distribution. llguidance never produces an empty mask on a healthy matcher (a terminal grammar forces the stop token instead), so in practice this fires only on genuinely broken custom processors. - One processor per decode stream, single-threaded, adjacent to its
sampler.
sendBatchenforces this: two streams sharing one processor pointer iserror.SharedBatchProcessor(src/llm/chat.zig:412).
Stop handling needs no new mechanism: when a grammar completes, the
mask allows only the configured stop/EOS token, the sampler can only select
it, and the existing stop-token checks end the turn. Termination composes
with extra_stop_ids, stop_sequences, and the response budget unchanged.
3. The engine — vendored llguidance¶
llm.llguidance.Constraint (src/llm/llguidance.zig) compiles a grammar
(json_schema | regex | lark | composite llguidance) and adapts it to
the seam. llguidance was chosen over the alternatives (§8) because it is
the engine behind vLLM/SGLang-class structured output: full JSON-schema
coverage, a Lark-variant CFG language, regex, per-step masks over the whole
vocab in ~10–50 µs via a token-trie + derivative-based lexer, and a
maintained C FFI.
Mechanics worth pinning:
- Tokenizer bridge (
buildVocab,src/llm/llguidance.zig:245): the engine needs every token's RAW bytes. Byte-BPE tokens are byte-decoded; SPM pieces are unescaped (▁→ space) and<0xXX>byte tokens become their byte. Control tokens carry toktrie's0xFFspecial marker (:201) — BPE recognizes them by the<|...|>marker shape (the same setencodeWithSpecialsresolves atomically; byte-BPE has no attribute table), SPM by its GGUF-declaredcontrol/unknownattrs. The marker is a correctness feature, not bookkeeping: without it, a JSON string value containing the text<|im_end|>would let the sampler emit the actual control token and silently end the turn mid-object.0xFFnever occurs in valid UTF-8, so no ordinary token collides. - Vocabulary padding: models pad
config.vocab_sizepast the tokenizer vocab (Qwen3: 151 936 vs 151 669). The mask is sized to the MODEL vocab (Options.n_vocab); padding ids get empty token bytes, which the trie never matches — padding logits are permanently-infunder a constraint. - Canonical re-tokenization: the engine's
tokenize_fncallback runs Fucina's own tokenizer (Bridge,:214— BPEencodePlainAppend, SPMencodeRaw), which is what makes grammar-forced byte strings tokenize canonically (andforcedTokensnon-empty, §5). Built without llguidance'srayonfeature, the callback only ever runs on the calling thread. - Terminal behavior: grammar complete → the mask forces
Options.eos_token(chat passes the template's stop-marker id, the completion path defaults--stopto EOS); a mid-decode matcher failure degrades the same way (vtProcess,:458) so a stream always terminates cleanly, while an invalid grammar failsinitloudly with the engine's diagnostic. - Build gating:
-Dllguidance=true(default off) runscargo build --releaseinvendor/llguidanceand links the staticlib into the qwen3/gemma4 examples and the llm test roots. Off, a stubConstraintkeeps every caller compiling andinitreturnserror.LlguidanceNotEnabled; no Rust symbol is referenced, the build stays pure Zig. The staticlib keeps Rust'spanic = unwind(the FFI'scatch_unwindconverts grammar panics to error strings —abortwould lose that), which needs an unwinder at link time: libSystem covers macOS, and non-macOS targets link Zig's bundled LLVM libunwind (configureLlguidanceinbuild.zig) — hermetic, no system libgcc_s dependency. Vendoring policy (crates-only + pinnedCargo.lock, manifest deviations, the full-offlinecargo vendorrecipe, the update procedure):vendor/llguidance/README.md. Provenance:docs/THIRD-PARTY-NOTICES.md.
4. Composition with speculative decoding — no rollback needed¶
The obvious integration worry: speculative decoding samples hypothetical
continuations, and a grammar is stateful — surely the constraint needs a
rollback primitive for rejected drafts (llguidance even provides one,
llg_matcher_rollback). It does not, and the reason is a property of
Fucina's verify loop worth stating precisely:
Every
Sampler.nextresult is a committed token. The verify loop (speculative/core.zig,verifyStep) samples row i only after rows0..i-1's tokens are appended to history, and the token sampled at row i is itself committed immediately — as an accepted draft token, as the correction token (first mismatch, which ends the row loop), or as the bonus token. No sampled token is ever discarded by the decoder.
Since commit fires inside next, the matcher state advances in lockstep
with history by construction — there is nothing to roll back. A draft token
the grammar forbids is masked to -inf at its verify row, so the sampled
token cannot equal it; the sampled == draft comparison fails; the
sampled token IS the correction. Rejection sampling semantics are exactly
preserved, and the constrained speculative stream equals the constrained
plain stream token-for-token (given bitwise-equal logits — the same §13.9
caveat as unconstrained speculation).
The turn boundary is the one place a sampled token does not enter
history: the stop marker itself, and a token completing a text stop
sequence. Both are dropped identically by every path (plain send,
sendSpec's TurnGate — which truncates drafts at stop tokens so the
boundary token only ever arrives as a sampled correction/bonus — and
sendBatch's sampleStep), so processor state after any turn is the same:
post-stop, re-armed by the next turn's reset.
Proofs in-tree (src/llm/chat_tests.zig): constrained plain == constrained
speculative, greedy and sampled with a persistent RNG, both for a plain
mask processor and for a structural (forced-span) processor; commit-log
equality is asserted, not just stream equality.
5. Grammar-driven drafting — ConstrainedSource¶
Without help, a constraint hurts speculation: the SAM/recycling cascade
drafts unconstrained text, the mask rejects it, acceptance collapses and
the CostGate rightly mutes the feature (measured: 0% acceptance,
off 18/21 steps on JSON-schema chat). But the grammar knows things the
cascade cannot, and speculative/constrained.zig:36 turns that knowledge
into drafts. It wraps any inner DraftSource with a structural processor
(the two optional vtable hooks, both deterministic pure lookaheads —
verified against the vendored Rust: Matcher::compute_ff_tokens and
validate_tokens do not mutate parser state):
- Forced spans draft themselves (
forcedTokens, backed by llguidance's fast-forward tokens). When the grammar mandates a unique continuation —", "population":after a JSON key closes — those tokens are the draft. The masked sampler can only select the forced token at each row, so the span verifies with acceptance probability 1: one batched forward commits it all, plus the free bonus token. - Certainly-rejected drafts die pre-verify (
validPrefixLen, backed by token validation). On free-choice steps the inner cascade proposes and the draft is truncated at its first grammar-invalid token — tokens that would be masked at their verify row can only waste verify compute and drag the cascade's per-source acceptance gates down.
Accounting stays honest through a small core extension:
DraftSource.truncatePending (speculative/core.zig:93), the generic
"your just-returned draft was shortened" notification. The cascade shrinks
its pending acceptance window; the chat TurnGate now uses the same vtable
entry instead of reaching into the concrete cascade (it is fully
source-generic); ConstrainedSource forwards truncations only when the
live draft came from the inner source — forced drafts carry no pending
accounting anywhere. wantsTopK mirrors the inner source, so wrapping
never makes the decoder compute top-k feedback nobody consumes.
Losslessness is untouched: drafts never decide what is committed, and
both hooks are deterministic, so the source stays deterministic. The wiring
is automatic — chat.Conversation wraps its cascade whenever the installed
processor hasStructure() (src/llm/chat.zig:207), and the qwen3 --spec
path does the same.
Measured (Qwen3-0.6B-Q8_0, greedy JSON-schema chat, M1 Max):
| drafted | accepted | gate | tok/step | |
|---|---|---|---|---|
| constraint, no drafting layer | 10 | 0 (0%) | muted after 3 steps | 1.00 |
constraint + ConstrainedSource |
6 | 5 (83%) + bonus | never off | 1.24 |
Output byte-identical in both rows and to the non-speculative constrained
run. The fallback steps that remain are forced spans shorter than the
decoder's min_draft (single forced tokens take the plain step) — headroom
for a future min_draft-aware forced-span policy.
6. Multi-stream — Constraint.clone()¶
A constraint is single-stream state, so N-stream decode needs N matcher
states. Re-running init per stream would rebuild the vocab trie and
recompile the grammar; clone() (src/llm/llguidance.zig:385) instead
deep-clones the matcher (initial state if cloned after init/reset),
reference-counts the tokenizer handle, and borrows the tokenize bridge
from the original — the original must outlive its clones, which every
current caller satisfies structurally (the base constraint lives in main,
clones in the stream loop).
Consumers:
chat.sendBatch: per-conversation processors arrive via each conversation's ownOptions.logit_processor; the batch validator rejects a shared pointer (error.SharedBatchProcessor). Constrained lockstep output is proven equal to individual constrained sends (chat_tests.zig; n below the m-dependent kernel thresholds, so bitwise).- qwen3
--streams N+ a grammar flag: one clone per stream, reset per bench pass, both arms (lockstep and sequential) constrained identically — the harness's token-for-token cross-check passes on the real model.
7. Operational notes and honest caveats¶
- Greedy + an unbounded grammar field loops.
{"population": <integer>}under--temp 0: the model wants to write2.1 million, the grammar forbids., greedy re-picks0forever — the grammar cannot force termination inside a field whose continuation is always legal, and argmax never chooses to stop. This is constraint semantics, not a masking bug. Mitigations, in order: bound fields (maximum,maxLength,{m,n}), sample instead of greedy, keeprepeat_penaltyon. Documented inRUNNING-MODELS.md. - Constrain the whole reply — on reasoning models combine with
--no-think, or the grammar forbids the<think>preamble the model wants to emit. - SPM
tokenize_fnnuance: SPM re-tokenization goes throughencodeRaw, which appliesadd_space_prefixwhen the model sets it. Gemma sets it false, so the shipped families are unaffected; a space-prefixing SPM model would degrade only forced-token drafting granularity (mask correctness is trie-based and unaffected). - gemma4 is wired but not e2e-validated (no gemma GGUF on the dev disk at the time of writing); its SPM bridge is covered by the gated unit tests (attrs marking, byte-fallback tokens, control-token exclusion).
- llguidance's per-grammar
temperatureextension is deliberately ignored (the Matcher API drops it; sampling knobs stay the user's). - The engine adds ~15 MB to constrained binaries (Rust staticlib, stripped) and one opt-in toolchain requirement (cargo ≥ 1.87). Mask computation is off the model's critical path in practice (~tens of µs vs ms-scale forwards).
8. Adjudications¶
- Per-loop mask wiring (llama.cpp's shape: grammar applied inside each sampler chain construction) — rejected. Fucina has five-plus decode loops (plain chat, spec plain/verify, batch, runner completion/bench); the sampler-hosted hook constrains all of them with one seam and keeps the invariant "every path samples identically" a structural fact rather than a per-loop obligation.
- Rollback-based speculative integration (
llg_matcher_rollbackon draft rejection) — rejected as unnecessary: Fucina's verify loop commits every sampled token (§4), so there is never un-committed matcher state. Would reopen only if the decoder ever moved to sampling rows beyond an uncommitted prefix (e.g. tree/beam speculation). - Porting a grammar engine (GBNF-style) into Zig — rejected for scope: full JSON-schema semantics + a maintained Earley/lexer stack is a project-sized dependency to re-own, and constrained decoding is pure CPU-side logic where the FFI boundary costs nothing per token. The hand-written extern layer is ~15 declarations against a checked-in header, with an ABI round-trip in the gated tests. Would reopen if the Rust toolchain requirement became a real adoption barrier even as opt-in.
- Fast-forward tokens as forced injection (commit ff tokens directly, skipping sampling) — rejected: it changes the one-RNG-draw-per-committed-token accounting that the lossless speculation contract and the chat equivalence proofs rest on. Drafting them instead (§5) captures the same forward-pass savings within the existing contract, at the cost of one extra logits row per span.
- Masking after top-k truncation (filter the 256-candidate list instead of the full row) — rejected: cheaper per step but wrong — when all top-256 candidates are grammar-invalid the correct next token lies outside the truncated set, and the greedy path bypasses top-k entirely. The full-row mask is O(vocab/32) words of bit-tests; not worth a correctness cliff.
9. Test and validation map¶
| Claim | Where proven |
|---|---|
| Seam semantics (mask on both paths, one commit per selection, all-masked failure, penalty composition) | src/llm/logit_processor_tests.zig |
| Grammar walks, stop forcing, reset, special-token exclusion, JSON-schema over byte vocab, SPM attrs + byte-fallback, invalid grammars, ABI round trip | src/llm/llguidance_tests.zig (gated: skips unless -Dllguidance=true) |
| Structural hooks are pure lookaheads; clone independence | src/llm/llguidance_tests.zig |
| Combinator policy (forced preemption, invalid-prefix truncation, pending accounting, top-k mirroring) | src/llm/speculative/constrained_tests.zig |
| Constrained plain == speculative (greedy + sampled), forced spans drafted AND accepted, per-turn reset, batch == sequential per-stream constraints, shared-processor guard | src/llm/chat_tests.zig |
| Doc snippets (incl. the flag-gated llguidance snippet) | zig build snippet-check (§2.7 convention) |
E2E schema/regex conformance, --spec byte-parity + acceptance, --streams cross-check |
qwen3 runner on Qwen3-0.6B-Q8_0 (2026-07-11; grammar commands in RUNNING-MODELS.md §"Constrained decoding", the --spec/--streams commands in examples/qwen3/README.md) |
| Linux staticlib link + full gated suite (x86-64 glibc) | CI llguidance leg (ci.yml, ubuntu; §2.8) — first proven natively on the dev rig, 2026-07-11 |
Reference documentation: REFERENCE.md §13.6 (seam + engine), §13.9.6
(drafting), §2.2 (-Dllguidance), §13.8 (chat wiring).