|
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
14.6 kB
| # `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 as `oaicompat_chat_params_parse` resolves the | |
| OpenAI fields, and from Ollama's `format`. | |
| - **JSON mode's grammar.** With `response_format: {"type": "json_object"}` on the jinja chat path, llama-server does | |
| not use `grammars/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_GRAMMAR` is that text byte for byte (Qwen3 template); `JSON_OBJECT_GRAMMAR_CHATML` | |
| is the ChatML templates' version (no `<think>` block). | |
| - **The content rule.** The answer's `content` is 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](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 | |
| ```rust | |
| 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 | |
| ```rust | |
| 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::parse` is `llama_grammar_init_impl(vocab, text, "root")`: parse, check every rule reference, refuse left | |
| recursion. `tokenize` resolves `<token>` terminals (special tokens parsed); `<[id]>` and `!<…>` are supported. | |
| - `Vocab` holds 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`). | |
| - `apply` sets 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. `allows` is the single-candidate check. | |
| - `accept` advances the stacks. A token that leaves no stack is an `Err` (llama.cpp throws). | |
| - `json_object_grammar(t)` gives the JSON-mode grammar and its prefill for a template. | |
| ### The content rule | |
| ```rust | |
| 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_content` returns the value inside `space ("```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_message` is the non-streamed answer: the parsed content, or the raw text when that parse is empty, as | |
| llama-server answers. | |
| - `ContentStream::push` sends only the growth of `json_content`; without a JSON constraint it passes pieces through. | |
| ```sh | |
| 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.cpp` drives 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's `grammars/`, 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 --record` against | |
| 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 own `common_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](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 a `OnceLock`). The JSON-mode `Rules` are parsed once per engine and shared through an `Arc`. | |
| - **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 an | |
| `Err`. 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 and `mindx-genN`). O6b added every other JSON schema | |
| (`Constraint::Schema`). 0.3.5 fixed the content rule for a cut inside an escape and added `json_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_sample` runs with `grammar_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 same `mt19937`. 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_GRAMMAR` equals what b11192 reports in | |
| `generation_settings.grammar`; the JSON-mode oracle checks this on every recorded request. A schema whose | |
| conversion equals that grammar resolves to `Constraint::JsonObject`. | |
| - **Schemas.** `schema.rs` reproduces `json_schema_to_grammar` and 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), so | |
| `schema_constraint` converts 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-level `json_schema` fails 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, so `ContentStream` keeps `json_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 `Rc` the memo holds until the mask is done. | |
| - `Vocab::pieces` is also read by the DRY sampler's sequence breakers. | |
| ## Limitations | |
| - A user `grammar` gets no prefill, as llama-server does. `response_format` and `grammar` together are refused | |
| (llama-server would keep only the format's grammar). | |
| - A top-level `json_schema` that is `null` or not an object is refused (llama-server fails the request). | |
| - Ollama's `format` accepts 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](../oracles.md) §5c, §5d, §5e | |
| - [../PERFORMANCE.md](../PERFORMANCE.md) — JSON mode's cost | |
| - [../OLLAMA.md](../OLLAMA.md) — O6, what llama-server does with `response_format` | |
| - [../TODO.md](../TODO.md) — 0.4.0, the faster whole-vocabulary mask (done in 0.3.9) | |
| - [../usage.md](../usage.md) — `bankml generate --json`, the `/v1` and `/api` fields | |
| - Sibling pages: [schema.md](schema.md), [sampler.md](sampler.md), [tokenizer.md](tokenizer.md), | |
| [chat.md](chat.md), [native.md](native.md), [serve.md](serve.md), [ollama.md](ollama.md) | |