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:
2026-08-05 11:24:35 +04:00
parent fdc18edd87
commit 32d5f68710
5 changed files with 124 additions and 10 deletions
+50
View File
@@ -52,3 +52,53 @@ func TestGrammarStringBoundHasRoomForARealAnswer(t *testing.T) {
t.Error("grammar string bound is not 1000; 400 truncated real replies mid-word (see the comment on responseGrammar)")
}
}
// A multi-line reply is the sixty-failure shape from the 2026-08-05 temperature
// sweep (Vikunja #537). The model wrote a literal newline inside the string,
// which the old grammar allowed and json.Unmarshal rejects with "invalid
// character '\n' in string literal". The object starts with "{", so it came back
// as errBrokenJSON and the case answered with nothing at all.
//
// The grammar is the fix. This is the parser's own second line, for the paths
// that send no grammar: the reply is readable, so read it.
func TestParseResponseMoodRepairsARawNewline(t *testing.T) {
raw := "{\"response\": \"первая строка\nвторая строка\", \"mood\": \"neutral\"}"
text, mood, err := parseResponseMood(raw)
if err != nil {
t.Fatalf("err = %v, want nil — a raw newline is repairable, not a failed generation", err)
}
if want := "первая строка\nвторая строка"; text != want {
t.Errorf("response = %q, want %q", text, want)
}
if mood != "neutral" {
t.Errorf("mood = %q, want neutral", mood)
}
}
// Repairing must not rewrite JSON that was already fine: an escaped newline
// stays one newline, and a backslash the model escaped properly is left alone.
func TestEscapeRawControlsLeavesValidJSONAlone(t *testing.T) {
raw := `{"response": "строка\nдве \\ и \"кавычки\"", "mood": "happy"}`
if got := escapeRawControls(raw); got != raw {
t.Errorf("escapeRawControls rewrote valid JSON:\n got %q\nwant %q", got, raw)
}
text, _, err := parseResponseMood(raw)
if err != nil {
t.Fatalf("err = %v", err)
}
if want := "строка\nдве \\ и \"кавычки\""; text != want {
t.Errorf("response = %q, want %q", text, want)
}
}
// A tab and a bare control byte take the same path as the newline.
func TestParseResponseMoodRepairsOtherControls(t *testing.T) {
raw := "{\"response\": \"таб\tи \x01байт\", \"mood\": \"tired\"}"
text, _, err := parseResponseMood(raw)
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if want := "таб\tи \x01байт"; text != want {
t.Errorf("response = %q, want %q", text, want)
}
}