Download docs/modules/grammar.md from PYTHAI/bankml: direct link, hf CLI and curl.
- Browser
- Download file 14.6 kB
-
https://huggingface.co/spaces/PYTHAI/bankml/resolve/main/docs/modules/grammar.md
- Command line
-
hf download hf://spaces/PYTHAI/bankml/docs/modules/grammar.md
-
curl -L -o grammar.md https://huggingface.co/spaces/PYTHAI/bankml/resolve/main/docs/modules/grammar.md
bankML/grammar.rs β llama.cpp's GBNF engine, JSON mode and the content rule
Summary
grammar.rs constrains sampling the way llama-server b11192 does. It is a port of llama.cpp's
src/llama-grammar.cpp (MIT, attributed in the file): the GBNF parser, the pushdown stacks, advance_stack,
reject_candidates, the partial UTF-8 decoder, accept_token and apply. The semantics are llama.cpp's; only the
data layout differs (a stack element is an index into one flat element array instead of a pointer).
It also holds what llama-server does around the engine:
- Which constraint a request asks for (
Constraint), resolved asoaicompat_chat_params_parseresolves the OpenAI fields, and from Ollama'sformat. - JSON mode's grammar. With
response_format: {"type": "json_object"}on the jinja chat path, llama-server does not usegrammars/json.gbnf. Its chat layer turns{"type": "object"}into a PEG parser and that into GBNF whose root starts with the generation prompt, and the sampler accepts the generation prompt's tokens into the grammar before the first draw.JSON_OBJECT_GRAMMARis that text byte for byte (Qwen3 template);JSON_OBJECT_GRAMMAR_CHATMLis the ChatML templates' version (no<think>block). - The content rule. The answer's
contentis the JSON value alone, without the fence (json_content,json_message,ContentStream).
Where the grammar sits in sampling: the unconstrained chain draws first; if the token passes the grammar it stands.
Otherwise every rejected token is set to ββ and the chain draws again from the same generator
(Sampler::sample_constrained, see sampler.md).
Callers: the native engine (native.rs: Native::grammar builds and prefills a Grammar, complete_with (and
complete, which wraps it) checks, masks and accepts per token); serve.rs (NativeChat::parse resolves from_openai_text; the stream uses
ContentStream); ollama.rs (from_ollama, from_ollama_text); bankml generate --json; and schema.rs, whose
grammars this engine parses.
Technical usage
Resolving a request
pub enum Constraint { None, JsonObject, Schema(Value), Gbnf(String) }
pub fn from_openai(req: &Json) -> Result<Constraint, String>
pub fn from_openai_text(req: &Json, body: &str) -> Result<Constraint, String>
pub fn from_ollama(format: Option<&Json>) -> Result<Constraint, String>
pub fn from_ollama_text(req: &Json, body: &str) -> Result<Constraint, String>
| request | Constraint |
|---|---|
/v1: response_format json_object (no schema), or a schema {} / {"type": "object"} |
JsonObject |
/v1: response_format json_schema, json_object + schema, top-level json_schema (an object) |
Schema(value) (converted at parse time, so a refusal is a 400) |
/v1: grammar (a GBNF string) |
Gbnf(text): a user grammar, root root, no prefill |
Ollama: format: "json" or {} |
JsonObject |
Ollama: format: <schema object> |
Schema(value), as response_format: {"type": "json_schema", β¦} |
none, text, a non-object schema inside response_format |
None |
The _text variants read the schema from the request's own text, so a literal such as 2.0 stays a float.
The engine
impl Rules { pub fn parse(text: &str, tokenize: &dyn Fn(&[u8]) -> Vec<u32>) -> Result<Rules, String> }
impl Vocab {
pub fn new(pieces: Vec<Vec<u8>>, eog_ids: &[u32]) -> Vocab
pub fn piece(&self, id: u32) -> &[u8]
pub fn is_eog(&self, id: u32) -> bool
pub fn pieces(&self) -> &[Vec<u8>]
pub fn len(&self) -> usize // and is_empty()
}
impl Grammar {
pub fn new(r: std::sync::Arc<Rules>) -> Grammar
pub fn apply(&self, v: &Vocab, cands: &mut [Cand])
pub fn allows(&self, v: &Vocab, id: u32) -> bool
pub fn accept(&mut self, v: &Vocab, id: u32) -> Result<(), String>
pub fn complete(&self) -> bool
}
pub fn json_object_grammar(t: crate::chat::Template) -> (&'static str, &'static str)
pub const JSON_OBJECT_GRAMMAR: &str; pub const JSON_OBJECT_GRAMMAR_CHATML: &str; pub const JSON_OBJECT_PREFILL: &str;
pub fn mask_hash(allowed: impl Iterator<Item = u32>) -> (usize, u64)
Rules::parseisllama_grammar_init_impl(vocab, text, "root"): parse, check every rule reference, refuse left recursion.tokenizeresolves<token>terminals (special tokens parsed);<[id]>and!<β¦>are supported.Vocabholds every token's piece as the grammar reads it (Tokenizer::piece), decoded to code points once, and the end-of-generation set. Its trie (0.3.9) is built on the first whole-vocabulary mask and kept (OnceLock).applysets every rejected candidate to ββ. An end-of-generation token passes only when a stack is empty (the grammar is complete); an empty piece never passes.allowsis the single-candidate check.acceptadvances the stacks. A token that leaves no stack is anErr(llama.cpp throws).json_object_grammar(t)gives the JSON-mode grammar and its prefill for a template.
The content rule
pub fn json_content(raw: &str) -> &str
pub fn json_message(raw: &str) -> &str
impl ContentStream {
pub fn new(c: &Constraint) -> ContentStream
pub fn push(&mut self, piece: &str) -> String
pub fn content(&self, raw: &str, streamed: bool) -> String
}
json_contentreturns the value insidespace ("```json" space VALUE space "```" | space VALUE space): the object once complete, the open part while it is still open. A string ends at its closing quote, a number or literal at the first space or the fence. An escape the cut left unfinished is not content (0.3.5).json_messageis the non-streamed answer: the parsed content, or the raw text when that parse is empty, as llama-server answers.ContentStream::pushsends only the growth ofjson_content; without a JSON constraint it passes pieces through.
echo "Describe a cat as JSON." | bankml generate .models/Bonsai-8B-Q1_0.gguf --json
curl -s 127.0.0.1:PORT/v1/chat/completions \
-d '{"messages":[{"role":"user","content":"A cat, as JSON"}],"response_format":{"type":"json_object"}}'
How it is verified
- Unit tests:
the_server_grammar_parses,parse_errors_say_where,a_small_grammar_masks_and_accepts,partial_utf8_across_tokens,decode_matches_the_c_reading,requests_resolve_as_llama_server_does,json_content_is_the_value. oracle_grammar_masks(#[ignore], in the gate):testing/grammar_oracle.cppdrives libllama b11192's own grammar sampler (llama_sampler_init_grammar,_apply,_accept) on the pinned vocabulary. Pieces and end flags: 151,669 of 151,669 tokens, end set of 6. Masks over 12 grammars (JSON mode with its prefill, the 8 in llama.cpp'sgrammars/, 3 written for token terminals,.,{m,n}edges, comments, CRLF, astral and CJK ranges), each input tokenized normally and one token per byte: 196 of 196 runs; 1,645 of 1,645 masks and 116 of 116 rejection points identical (masks compared by count and FNV-1a hash,mask_hash).oracle_json_mode,oracle_json_mode_ternary(native.rs):testing/json_oracle.py --recordagainst llama-server b11192: the same token ids, raw text, content, finish reason and counts, and the server's reported grammar and generation prompt equal to bankml's constant and prefill. Bonsai-8B 23 of 23 (860 tokens, 152 redrawn), ternary 13 of 13 (534 tokens, 49 redrawn); 23 of 23 on each O4 model. Live (--bankml): 8 of 8.oracle_json_content: llama.cpp's owncommon_chat_parse(testing/content_oracle.{cpp,py}) on every prefix of every recorded constrained answer and edge cases: 30,063 of 30,063 texts on each template.- Schema answers (
oracle_json_schema*) run through this engine too; see schema.md.
Advantages and efficiency
- The same JSON as llama-server. Not a JSON-only mask: the same grammar text, the same prefill, the same redraw and the same content rule, so constrained answers, seeded or greedy, are token-identical and can be checked.
- Cheap when the model already writes valid JSON. The first draw is checked as one candidate (microseconds). The whole-vocabulary mask runs only when that token breaks the grammar.
- Work done once. Each vocabulary's pieces are decoded to code points once (
Vocab::new; the native engine keeps one per model in aOnceLock). The JSON-modeRulesare parsed once per engine and shared through anArc. - Measured (docs/PERFORMANCE.md, gate 0.3.3, laptop): one whole-vocabulary mask over 151,669 tokens, median 24.2 ms (p90 47.0, max 101.0). Grammar time per generated token, everything included: 3.90 ms (Bonsai-8B Q1_0) and 3.95 ms (ternary), about 1 % of a 1-bit step and 1.4 % of a ternary step. An answer written as valid JSON on its own spent 0.4 ms of grammar per token. These 0.3.3 figures predate the trie and were not re-measured with it.
- The trie (0.3.9). A whole-vocabulary mask with no UTF-8 left open walks a trie of the vocabulary's code
points (
Trie, built once per vocabulary, tokens sorted so each subtree is one range): each grammar stack meets a shared prefix once instead of once per token, and the stacks after a terminal are computed once per stack and mask. Measured over the oracle's 1,645 masks (laptop, gate load): median 2.94 ms against 38.8 ms one by one (13Γ), p90 49.3 against 80.6, the worst 762 ms (the first, which builds the trie) against 1,085. Short candidate lists (under 1,024,TRIE_MIN) and a token that left UTF-8 open keep the one-by-one path (apply_each). - Rust practice. Zero crates; no
unsafe. Parse errors say where (expecting name at β¦). Where llama.cpp would loop practically forever ({m,n}with n below m) or throw (an empty stack after accept), bankml returns anErr. The repetition limits of llama.cpp (2,000) are kept. - Next: tool calls through the template (O6).
Design notes
- History. O6's first cut (0.3.3) was JSON mode. 0.3.4 added the ChatML templates' JSON-mode grammar
(
JSON_OBJECT_GRAMMAR_CHATML, SmolLM2-Instruct andmindx-genN). O6b added every other JSON schema (Constraint::Schema). 0.3.5 fixed the content rule for a cut inside an escape and addedjson_message's fallback. 0.3.9 added the trie. - Source. The port follows llama.cpp b11192 at commit
171e8846b(github.com/ggml-org/llama.cpp). llama.cpp's MIT notice is reproduced in the module header, as its licence asks (LICENSING.md). A stack element is an index into one flat element array where llama.cpp uses a pointer; indices compare the same way, so semantics are kept. - The redraw.
common_sampler_sampleruns withgrammar_first = false: the whole chain (top-k β¦ temperature β dist) makes one RNG draw; if that token passes the grammar it is taken. Otherwise the logits are reset, rejected tokens set to ββ, and the whole chain runs again, a second draw from the samemt19937. A seeded run therefore consumes the generator exactly as llama-server's does. - JSON mode's grammar. llama-server's chat layer converts
{"type": "object"}to a PEG parser and then to GBNF (common/chat-auto-parser-generator.cpp); the sampler then accepts the generation prompt's tokens into the grammar (common_grammar_needs_prefill).JSON_OBJECT_GRAMMARequals what b11192 reports ingeneration_settings.grammar; the JSON-mode oracle checks this on every recorded request. A schema whose conversion equals that grammar resolves toConstraint::JsonObject. - Schemas.
schema.rsreproducesjson_schema_to_grammarand the chat parser's wrapping, byte-identical over llama.cpp's own test cases and a mindX-shaped corpus. A schema b11192 refuses is refused with its reason. Refusals and conversion warnings do not depend on the template (checked against b11192's output), soschema_constraintconverts with the Qwen3 template for all. json_schema: null. llama-server converts it to a bare{"type": "object"}grammar that rejects the chat template's generation prompt, and the request fails; a non-object top-leveljson_schemafails conversion. bankml refuses both with the reason.- Content of an open value. While the answer is still open (a length-limited answer), the content is the value from its first byte to the end of the text.
json_message's fallback. When the parse gives an empty message, for example an answer cut inside or right after the opening fence, llama-server answers a non-streamed request with the raw text (server_task_result_cmpl_final::to_json_oaicompat_chat). A stream never sends that fallback, because the server's final parse adds no difference, soContentStreamkeepsjson_content.- Content oracle refusals. llama.cpp's parser refuses only an answer containing a reasoning block, which no JSON-mode or schema grammar admits after its prefill; bankml's answers never contain one.
- Trie details. Candidates exclude end-of-generation tokens and pieces starting with NUL. Tokens are sorted by
code points so each node's subtree is one range, with tokens ending at the node first. The walk memoizes, per
stack, the stacks after its terminal, keyed by the stack's address and length; this is sound because every
walked stack lives in the grammar's stack set or in an
Rcthe memo holds until the mask is done. Vocab::piecesis also read by the DRY sampler's sequence breakers.
Limitations
- A user
grammargets no prefill, as llama-server does.response_formatandgrammartogether are refused (llama-server would keep only the format's grammar). - A top-level
json_schemathat isnullor not an object is refused (llama-server fails the request). - Ollama's
formataccepts only"json",""or a schema object. - With UTF-8 left open by the last token, the mask is still the straight port of
reject_candidates. - Tool calls are not supported yet.
See also
- ../oracles.md Β§5c, Β§5d, Β§5e
- ../PERFORMANCE.md β JSON mode's cost
- ../OLLAMA.md β O6, what llama-server does with
response_format - ../TODO.md β 0.4.0, the faster whole-vocabulary mask (done in 0.3.9)
- ../usage.md β
bankml generate --json, the/v1and/apifields - Sibling pages: schema.md, sampler.md, tokenizer.md, chat.md, native.md, serve.md, ollama.md