phrasing and routing: a raw newline is not JSON (V-537)
Sixty of the failures in the 2026-08-05 temperature sweep were one error,
`phraser: model output starts as JSON but does not parse`, all of them in the
reply family and two of them in all twelve runs. The write-up read that as
truncation. It is not: no run hit the token cap.
The string rule in both grammars was `[^"\\]`, which admits a literal
newline. A model that wants two lines writes one, the generation satisfies the
grammar, and json.Unmarshal then rejects it with "invalid character '\n' in
string literal". The object starts with "{", so it came back as errBrokenJSON
and the reply was an empty string. The router's rule also admitted `"\\" .`,
so \q satisfied it and failed to parse the same way.
Both string rules are now llama.cpp's own json.gbnf class: the control range is
out and the escape alternatives are exact. Verified against the resident model
on 8899 — llama-server accepts both grammars and both still emit what they did.
escapeRawControls is the second line, for NoGrammar and for a remote server that
ignores a grammar: a reply whose only fault is a raw newline is readable, so it
is read rather than dropped.
This commit is contained in:
@@ -34,13 +34,20 @@ func NewLLMRouter(c Completer) *LLMRouter { return &LLMRouter{c: c} }
|
||||
// (Vikunja #531), where nothing sent a repeat penalty. This path sends
|
||||
// routeRepeatPenalty, which masked it here — the bound is what actually
|
||||
// prevents it, so it does not depend on a sampler setting staying put.
|
||||
//
|
||||
// The string class excludes the control range and the escape alternatives are
|
||||
// exact, both for the reason the phrasing grammar gives (Vikunja #537): a raw
|
||||
// newline inside a JSON string does not parse, and `"\\" .` licensed `\q`,
|
||||
// which does not parse either. A route that does not parse falls through to the
|
||||
// classifier, so here the defect reads as lost accuracy rather than as an empty
|
||||
// reply. Same class as internal/phraser's responseGrammar, on purpose.
|
||||
const routeGrammar = `
|
||||
root ::= "[" ws action ("," ws action)* ws "]"
|
||||
action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}"
|
||||
intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\"" | "\"unknown\""
|
||||
field ::= key ws ":" ws string
|
||||
key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\""
|
||||
string ::= "\"" ([^"\\] | "\\" .){0,120} "\""
|
||||
string ::= "\"" ([^"\\\x00-\x1F] | "\\" ["\\/bfnrt] | "\\u" [0-9a-fA-F]{4}){0,120} "\""
|
||||
ws ::= [ \t\n]{0,4}
|
||||
`
|
||||
|
||||
|
||||
@@ -38,9 +38,17 @@ func TestLLMRouterSetsRepeatPenalty(t *testing.T) {
|
||||
|
||||
// An unbounded string rule lets one field eat the whole token budget.
|
||||
func TestRouteGrammarBoundsStrings(t *testing.T) {
|
||||
if !strings.Contains(routeGrammar, `string ::= "\"" ([^"\\] | "\\" .){0,120} "\""`) {
|
||||
if !strings.Contains(routeGrammar, `{0,120} "\""`) {
|
||||
t.Fatal("grammar string rule lost its length bound")
|
||||
}
|
||||
// And it must not admit a raw newline or a made-up escape, either of which
|
||||
// makes the route unparseable and costs the turn its router (Vikunja #537).
|
||||
if !strings.Contains(routeGrammar, `[^"\\\x00-\x1F]`) {
|
||||
t.Error("string rule admits raw control characters")
|
||||
}
|
||||
if strings.Contains(routeGrammar, `"\\" .`) {
|
||||
t.Error(`string rule still admits "\\" . — \q satisfies the grammar and fails to parse`)
|
||||
}
|
||||
}
|
||||
|
||||
// A question naming a fact key used to be stored as a fact because the fact rule
|
||||
|
||||
Reference in New Issue
Block a user