Skip to main content

ferrox_models/grammar/
error.rs

1//! Errors this grammar engine returns.
2//!
3//! llama.cpp's parser throws `std::runtime_error`, catches it in
4//! `llama_grammar_parser::parse`, prints to stderr, clears the rule table
5//! and returns `false` -- so a caller learns only "it failed". Two of its
6//! stack-machine invariants are `GGML_ABORT`, which kills the process.
7//!
8//! Per `CLAUDE.md` ("return `Result` and name what is missing"), every one
9//! of those becomes a typed variant carrying the byte offset and the
10//! remaining input, so a server can hand the message back to the client
11//! that sent the grammar.
12
13use std::fmt;
14
15/// What the grammar engine refused, and why.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum GrammarError {
18    /// A syntax error in the GBNF source. `offset` is the byte offset into
19    /// the grammar text where llama.cpp's parser would have thrown, and
20    /// `rest` is the (truncated) input from there on, matching the
21    /// `"expecting X at " + src` shape of the upstream messages.
22    Syntax {
23        expected: String,
24        offset: usize,
25        rest: String,
26    },
27    /// A rule was referenced but never given a `::=` definition.
28    UndefinedRule { name: String, rule_id: u32 },
29    /// The grammar parsed but has no rule with the requested root name.
30    MissingRoot { name: String },
31    /// A rule can reach itself without consuming input. llama.cpp logs
32    /// "unsupported grammar, left recursion detected" and returns null.
33    LeftRecursion { rule_id: u32, name: Option<String> },
34    /// A repetition operator would expand to more rules than the engine
35    /// is willing to build. llama.cpp's `MAX_REPETITION_THRESHOLD`.
36    RepetitionTooLarge {
37        requested: u64,
38        limit: u64,
39        offset: usize,
40    },
41    /// `<name>` token syntax was used but no vocabulary was supplied to
42    /// resolve it. `<[42]>` needs no vocabulary and always works.
43    TokenNeedsVocabulary { token: String, offset: usize },
44    /// A `<name>` token did not tokenize to exactly one token.
45    TokenNotSingle { token: String, n_tokens: usize },
46    /// The generated text left no viable parse: every stack died. Carries
47    /// the piece that killed it, as llama.cpp's thrown message does.
48    NoViableStack { piece: String },
49    /// A lazy grammar's trigger pattern does not compile. llama.cpp builds
50    /// a `std::regex` in the constructor, which throws.
51    TriggerPatternInvalid { pattern: String, reason: String },
52    /// A trigger pattern compiled but failed while matching -- the
53    /// backtracking limit, in practice. Upstream's `std::regex` has the
54    /// same failure mode and does not report it.
55    TriggerPatternFailed { pattern: String, reason: String },
56    /// A grammar was made lazy with nothing that could ever switch it on,
57    /// which is an unconstrained generation wearing a grammar. llama.cpp
58    /// permits it; this engine will not pretend to constrain.
59    LazyWithoutTriggers,
60    /// A not-yet-triggered lazy grammar was asked which tokens it forbids.
61    /// It forbids nothing, and answering "nothing" would be
62    /// indistinguishable from a grammar that allows everything -- so the
63    /// question is refused. Callers test
64    /// [`Grammar::is_awaiting_trigger`](super::Grammar::is_awaiting_trigger)
65    /// first, as `llama_grammar_apply_impl` does.
66    AwaitingTrigger,
67    /// An invariant llama.cpp asserts with `GGML_ABORT`. Reaching one is a
68    /// bug in this engine, not in the caller's grammar.
69    Internal(&'static str),
70}
71
72/// How much of the remaining input a syntax error quotes.
73const REST_CLIP: usize = 40;
74
75impl GrammarError {
76    /// Build a [`GrammarError::Syntax`] from a position in the source.
77    pub(crate) fn syntax(expected: impl Into<String>, src: &[u8], offset: usize) -> Self {
78        let start = offset.min(src.len());
79        let end = (start + REST_CLIP).min(src.len());
80        // The offset can land mid-codepoint on malformed input; take the
81        // longest valid prefix rather than panicking inside an error path.
82        let rest = match std::str::from_utf8(&src[start..end]) {
83            Ok(s) => s.to_string(),
84            Err(e) => String::from_utf8_lossy(&src[start..start + e.valid_up_to()]).into_owned(),
85        };
86        GrammarError::Syntax {
87            expected: expected.into(),
88            offset: start,
89            rest,
90        }
91    }
92}
93
94impl fmt::Display for GrammarError {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            GrammarError::Syntax {
98                expected,
99                offset,
100                rest,
101            } => {
102                write!(
103                    f,
104                    "grammar syntax error at byte {offset}: {expected}, at {rest:?}"
105                )
106            }
107            GrammarError::UndefinedRule { name, rule_id } => write!(
108                f,
109                "grammar references rule {name:?} (id {rule_id}) which is never defined with ::="
110            ),
111            GrammarError::MissingRoot { name } => {
112                write!(f, "grammar does not contain a {name:?} rule to start from")
113            }
114            GrammarError::LeftRecursion { rule_id, name } => match name {
115                Some(n) => write!(
116                    f,
117                    "unsupported grammar: rule {n:?} (id {rule_id}) is left-recursive"
118                ),
119                None => write!(
120                    f,
121                    "unsupported grammar: rule id {rule_id} is left-recursive"
122                ),
123            },
124            GrammarError::RepetitionTooLarge {
125                requested,
126                limit,
127                offset,
128            } => write!(
129                f,
130                "grammar repetition at byte {offset} would expand to {requested} rules, over the \
131                 limit of {limit}; reduce the repetition count or the rule complexity"
132            ),
133            GrammarError::TokenNeedsVocabulary { token, offset } => write!(
134                f,
135                "grammar token {token:?} at byte {offset} names a token but no vocabulary was \
136                 supplied; use the <[id]> form or pass a vocabulary"
137            ),
138            GrammarError::TokenNotSingle { token, n_tokens } => write!(
139                f,
140                "grammar token {token:?} tokenizes to {n_tokens} tokens, but must be exactly 1"
141            ),
142            GrammarError::NoViableStack { piece } => write!(
143                f,
144                "no grammar parse survives the piece {piece:?}; it should have been masked out \
145                 before it was sampled"
146            ),
147            GrammarError::TriggerPatternInvalid { pattern, reason } => write!(
148                f,
149                "lazy grammar trigger pattern {pattern:?} does not compile: {reason}"
150            ),
151            GrammarError::TriggerPatternFailed { pattern, reason } => write!(
152                f,
153                "lazy grammar trigger pattern {pattern:?} failed while matching the output so \
154                 far: {reason}"
155            ),
156            GrammarError::LazyWithoutTriggers => write!(
157                f,
158                "a lazy grammar needs at least one trigger token or trigger pattern; with none \
159                 it can never switch on, and nothing would be constrained"
160            ),
161            GrammarError::AwaitingTrigger => write!(
162                f,
163                "this lazy grammar has not been triggered yet and constrains nothing; check \
164                 is_awaiting_trigger before asking which tokens it rejects"
165            ),
166            GrammarError::Internal(what) => {
167                write!(f, "internal grammar engine invariant violated: {what}")
168            }
169        }
170    }
171}
172
173impl std::error::Error for GrammarError {}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn syntax_error_clips_and_quotes_the_rest_of_the_input() {
181        let src =
182            b"root ::= \"a\" @@@ trailing garbage that runs on and on and on and on past the clip";
183        let e = GrammarError::syntax("expecting newline or end", src, 13);
184        match &e {
185            GrammarError::Syntax {
186                expected,
187                offset,
188                rest,
189            } => {
190                assert_eq!(expected, "expecting newline or end");
191                assert_eq!(*offset, 13);
192                assert!(rest.starts_with("@@@ trailing"));
193                assert_eq!(rest.len(), REST_CLIP);
194            }
195            other => panic!("wrong variant: {other:?}"),
196        }
197        assert!(e.to_string().contains("byte 13"));
198    }
199
200    #[test]
201    fn syntax_error_offset_past_the_end_is_clamped() {
202        let src = b"root";
203        let e = GrammarError::syntax("expecting ::=", src, 999);
204        match e {
205            GrammarError::Syntax { offset, rest, .. } => {
206                assert_eq!(offset, 4);
207                assert_eq!(rest, "");
208            }
209            other => panic!("wrong variant: {other:?}"),
210        }
211    }
212
213    #[test]
214    fn syntax_error_does_not_panic_on_a_split_codepoint() {
215        // "aé" -- offset 2 lands between the two bytes of 'é'.
216        let src = "aé".as_bytes();
217        let e = GrammarError::syntax("expecting name", src, 2);
218        match e {
219            GrammarError::Syntax { rest, .. } => assert_eq!(rest, ""),
220            other => panic!("wrong variant: {other:?}"),
221        }
222    }
223
224    #[test]
225    fn every_variant_says_what_is_missing() {
226        let cases: Vec<GrammarError> = vec![
227            GrammarError::UndefinedRule {
228                name: "ws".into(),
229                rule_id: 4,
230            },
231            GrammarError::MissingRoot {
232                name: "root".into(),
233            },
234            GrammarError::LeftRecursion {
235                rule_id: 1,
236                name: Some("expr".into()),
237            },
238            GrammarError::LeftRecursion {
239                rule_id: 1,
240                name: None,
241            },
242            GrammarError::RepetitionTooLarge {
243                requested: 10_000,
244                limit: 2000,
245                offset: 7,
246            },
247            GrammarError::TokenNeedsVocabulary {
248                token: "<think>".into(),
249                offset: 9,
250            },
251            GrammarError::TokenNotSingle {
252                token: "<think>".into(),
253                n_tokens: 3,
254            },
255            GrammarError::NoViableStack { piece: "}".into() },
256            GrammarError::TriggerPatternInvalid {
257                pattern: "(unclosed".into(),
258                reason: "unclosed group".into(),
259            },
260            GrammarError::TriggerPatternFailed {
261                pattern: "(a+)+b".into(),
262                reason: "backtrack limit exceeded".into(),
263            },
264            GrammarError::LazyWithoutTriggers,
265            GrammarError::AwaitingTrigger,
266            GrammarError::Internal("stack rested on CHAR_ALT"),
267        ];
268        for c in cases {
269            let msg = c.to_string();
270            assert!(msg.len() > 20, "message too thin for {c:?}: {msg}");
271        }
272    }
273}