dde556a3d3
V-586 reported 64/91 on the RU routing fixture, unchanged. That number does not
bear on the change: the fixture holds three fact cases and all three miss on
intent, so DefaultFactParser is never reached and any parser edit scores as
"unchanged".
So the parser gets its own corpus, 91 cases, scored against BOTH
implementations — the closed classes that ship and legacyFactParse, a verbatim
copy of the substring parser at 0445693, frozen in the test file so the
comparison reruns. True positives 35/40 to 39/40, misfires rejected 8/15 to
14/15. The rewrite wins every case anyone argued about.
The third case class is the point: 36 sentences a person would plainly say
whose word is in no lexicon set. The old parser caught 3 by accident, the new
one catches 0. "ем суп", "вздремнул", "помылся", "перекур", "i napped". A
silent miss is this parser's worst failure mode and the corpus sizes it.
Two defects recorded rather than fixed, since this branch measures: "допил
воду" misses because the dictionary lemmatises допил to допилить, the same saw
collision drink_verbs carries пил for; and the oblique cases of душ go with the
exact match that keeps the soul out.
The LLM arm the original commit skipped is run here against gemma-4-12b on the
workstation at 192.168.1.105:8080 — it was reachable all along, the failure was
the shell's HTTP_PROXY. cascade+llm 85.7% to 86.8%, one case, same failing set,
variance. Full write-up in docs/evals/2026-08-06-fact-parser.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
287 lines
13 KiB
Go
287 lines
13 KiB
Go
package router
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The fact-parser corpus (V-586). DefaultFactParser is the only thing standing
|
|
// between a spoken sentence and a written self-care fact, and until this file
|
|
// it was covered by a handful of examples chosen by whoever last edited it. The
|
|
// RU routing fixture does not cover it either: it holds three fact cases and
|
|
// all three miss on intent, so the parser is never reached and a parser change
|
|
// scores as "unchanged".
|
|
//
|
|
// So the corpus is here, and it scores BOTH implementations: the closed-class
|
|
// parser that ships, and legacyFactParse below, a faithful copy of the
|
|
// hand-written substring version 22edc3c replaced. The table is the measurement
|
|
// — see docs/evals/2026-08-06-fact-parser.md for the numbers as of that day.
|
|
//
|
|
// Three case classes, and the third is the point:
|
|
//
|
|
// true positive — a sentence he would say, with the key it must write.
|
|
// misfire — a sentence the substring parser wrote a fact for and
|
|
// should not have. want is "".
|
|
// false-negative — a sentence he would plausibly say whose word is in NO
|
|
// lexicon set. want is the key a human would assign, and
|
|
// the ship parser is EXPECTED to miss it. These measure the
|
|
// cost of the word-list design, not a bug in it.
|
|
//
|
|
// Nothing here asserts a score. A closed class is a decision about vocabulary
|
|
// and the bar belongs in a dated eval, not in an assertion that turns every
|
|
// vocabulary edit into a red test. What it does fail on is a regression in the
|
|
// two classes that are not judgement calls: a true positive that stops matching
|
|
// and a misfire that starts.
|
|
|
|
type factCase struct {
|
|
utterance string
|
|
want string // "" — no fact
|
|
class string // "tp", "misfire", "fn"
|
|
broken bool // ship parser is known to get this wrong; see the 06-08 eval
|
|
note string
|
|
}
|
|
|
|
var factCorpus = []factCase{
|
|
// ---- water, true positives -------------------------------------------
|
|
{"выпил стакан воды", "water", "tp", false, ""},
|
|
{"попил воды", "water", "tp", false, ""},
|
|
{"я попил водички", "water", "tp", false, ""},
|
|
{"пью воду", "water", "tp", false, ""},
|
|
{"воду пил уже", "water", "tp", false, ""},
|
|
{"допил воду", "water", "tp", true, "REGRESSION: the dictionary lemmatises допил to допилить, to finish sawing — the same saw collision drink_verbs already carries пил for, unfixed for the prefixed form"},
|
|
{"запил таблетку водой", "water", "tp", false, ""},
|
|
{"drank water", "water", "tp", false, ""},
|
|
{"i drank some water", "water", "tp", false, ""},
|
|
|
|
// ---- meal, true positives --------------------------------------------
|
|
{"поужинал", "meal", "tp", false, ""},
|
|
{"я пообедал", "meal", "tp", false, ""},
|
|
{"позавтракал кашей", "meal", "tp", false, ""},
|
|
{"перекусил бутербродом", "meal", "tp", false, ""},
|
|
{"покушал", "meal", "tp", false, ""},
|
|
{"поел супа", "meal", "tp", false, ""},
|
|
{"обед был в час", "meal", "tp", false, ""},
|
|
{"ужинать буду позже", "meal", "tp", false, ""},
|
|
{"i ate", "meal", "tp", false, ""},
|
|
{"had lunch", "meal", "tp", false, ""},
|
|
{"dinner done", "meal", "tp", false, ""},
|
|
|
|
// ---- shower, true positives ------------------------------------------
|
|
{"принял душ", "shower", "tp", false, ""},
|
|
{"сходил в душ", "shower", "tp", false, ""},
|
|
{"душ принят", "shower", "tp", false, ""},
|
|
{"ополоснулся душем", "shower", "tp", false, ""},
|
|
{"took a shower", "shower", "tp", false, ""},
|
|
{"i showered", "shower", "tp", false, ""},
|
|
|
|
// ---- break, true positives -------------------------------------------
|
|
{"сделал перерыв", "break", "tp", false, ""},
|
|
{"отдохнул полчаса", "break", "tp", false, ""},
|
|
{"передохнул немного", "break", "tp", false, ""},
|
|
{"отдыхаю", "break", "tp", false, ""},
|
|
{"был перерыв на обед", "meal", "tp", false, "meal wins the switch; both keys are true of the sentence"},
|
|
{"took a break", "break", "tp", false, ""},
|
|
|
|
// ---- sleep, true positives -------------------------------------------
|
|
{"спал восемь часов", "sleep", "tp", false, ""},
|
|
{"спала плохо", "sleep", "tp", false, "she may report her own; the parser is speaker-agnostic"},
|
|
{"поспал днём", "sleep", "tp", false, ""},
|
|
{"выспался наконец", "sleep", "tp", false, ""},
|
|
{"проспал будильник", "sleep", "tp", false, ""},
|
|
{"пойду спать", "sleep", "tp", false, ""},
|
|
{"slept 8 hours", "sleep", "tp", false, ""},
|
|
{"i slept badly", "sleep", "tp", false, ""},
|
|
|
|
// ---- misfires 22edc3c set out to reject -------------------------------
|
|
{"пилот сказал что вылет через час", "", "misfire", false, "substring пил"},
|
|
{"водитель уже подъехал", "", "misfire", false, "substring вод"},
|
|
{"надо заводить машину", "", "misfire", false, "substring вод"},
|
|
{"душа болит", "", "misfire", false, "substring душ"},
|
|
{"в комнате душно", "", "misfire", false, "substring душ"},
|
|
{"это была беда", "", "misfire", false, "substring еда"},
|
|
{"наша победа", "", "misfire", false, "substring еда"},
|
|
{"пила лежит в гараже", "", "misfire", false, "substring пил"},
|
|
{"водитель пилота ждёт", "", "misfire", false, "both stems in one sentence — the old water arm fires"},
|
|
{"обеденный перерыв отменили", "", "misfire", true, "neither parser gets this: the old one writes meal off the adjective, the new one writes break off перерыв. A cancelled break is not a break taken."},
|
|
|
|
// ---- hard negatives that decide the design ----------------------------
|
|
{"есть новости по бэкапу базы", "", "misfire", false, "есть is the existential, not a meal"},
|
|
{"напоминания на завтра есть", "", "misfire", false, "завтра carries завтрак as a substring"},
|
|
{"на душе легко", "", "misfire", false, "душе is the soul, and also the prepositional of душ"},
|
|
{"пилил доску весь вечер", "", "misfire", false, ""},
|
|
{"поставь будильник на завтра", "", "misfire", false, "завтра again, no meal"},
|
|
|
|
// ---- false negatives: words in no lexicon set -------------------------
|
|
// Water.
|
|
{"выпил чаю", "water", "fn", false, "hydration by any liquid; чай is in no set"},
|
|
{"глотнул воды", "water", "fn", false, "глотнуть is not a drink verb"},
|
|
{"хлебнул воды", "water", "fn", false, "хлебнуть is not a drink verb"},
|
|
{"воды хлебнул из бутылки", "water", "fn", false, ""},
|
|
{"выпил стакан", "water", "fn", false, "the noun is elided; he says this"},
|
|
{"i hydrated", "water", "fn", false, "hydrate is in no set"},
|
|
{"finished my bottle of water", "water", "fn", false, "no drink verb in the English set"},
|
|
|
|
// Meal.
|
|
{"ем суп", "meal", "fn", false, "есть is deliberately absent, and this is the cost"},
|
|
{"съел бутерброд", "meal", "fn", false, "съесть is in no set"},
|
|
{"наелся", "meal", "fn", false, "наесться is in no set"},
|
|
{"пожрал", "meal", "fn", false, "coarse but spoken"},
|
|
{"полдник был", "meal", "fn", false, "полдник is in no set"},
|
|
{"i had a snack", "meal", "fn", false, "snack is in no set"},
|
|
{"having supper", "meal", "fn", false, "supper is in no set"},
|
|
{"brunch was good", "meal", "fn", false, "brunch is in no set"},
|
|
{"i eat now", "meal", "fn", false, "eat is in no set — only ate is"},
|
|
|
|
// Shower.
|
|
{"был в душе", "shower", "fn", false, "prepositional; anyExact cannot take it without taking the soul"},
|
|
{"после душа полегчало", "shower", "fn", false, "genitive, same collision"},
|
|
{"помылся", "shower", "fn", false, "помыться is in no set"},
|
|
{"сходил в ванную", "shower", "fn", false, "ванная is in no set"},
|
|
{"искупался", "shower", "fn", false, "искупаться is in no set"},
|
|
{"i am showering", "shower", "fn", false, "showering is not a member and the set is exact-matched"},
|
|
|
|
// Break.
|
|
{"сделал передышку", "break", "fn", false, "передышка is in no set"},
|
|
{"перекур", "break", "fn", false, "перекур is in no set"},
|
|
{"полежал немного", "break", "fn", false, "полежать is in no set"},
|
|
{"сделал паузу", "break", "fn", false, "пауза is in no set"},
|
|
{"i took five", "break", "fn", false, "idiomatic, in no set"},
|
|
{"resting now", "break", "fn", false, "resting is not a member and rest is matched by lemma only"},
|
|
|
|
// Sleep.
|
|
{"вздремнул", "sleep", "fn", false, "вздремнуть is in no set"},
|
|
{"прикорнул на диване", "sleep", "fn", false, "прикорнуть is in no set"},
|
|
{"дрых до обеда", "sleep", "fn", false, "дрыхнуть is in no set — and обед makes this a MEAL for both parsers"},
|
|
{"недоспал", "sleep", "fn", false, "недоспать is in no set"},
|
|
{"лёг в двенадцать", "sleep", "fn", false, "лечь is in no set"},
|
|
{"сон был короткий", "sleep", "fn", false, "сон deliberately absent"},
|
|
{"i napped", "sleep", "fn", false, "nap is in no set"},
|
|
{"took a nap", "sleep", "fn", false, "break matches nothing here either"},
|
|
}
|
|
|
|
// legacyFactParse — DefaultFactParser exactly as it stood at 22edc3c's parent
|
|
// (0445693), copied here so the corpus scores the trade rather than describing
|
|
// it. Do not fix it. It is a frozen baseline, and the day it stops being worth
|
|
// comparing against, delete it and the two-column table with it.
|
|
func legacyFactParse(utterance string) (string, string, bool) {
|
|
s := strings.ToLower(strings.TrimSpace(utterance))
|
|
hasRoot := func(root string) bool { return strings.Contains(s, root) }
|
|
containsWord := func(w string) bool {
|
|
for _, tok := range strings.Fields(s) {
|
|
if tok == w {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
switch {
|
|
case (containsWord("water") && containsWord("drank")) ||
|
|
(hasRoot("вод") && (hasRoot("пил") || hasRoot("пью") || hasRoot("пей"))):
|
|
return "water", `"drank"`, true
|
|
case containsWord("meal") || (containsWord("ate") && !containsWord("backup")) || containsWord("lunch") || containsWord("dinner") ||
|
|
hasRoot("поел") || hasRoot("поесть") || hasRoot("куша") || hasRoot("обед") || hasRoot("ужин") || hasRoot("завтрак") || hasRoot("еда"):
|
|
return "meal", `"ate"`, true
|
|
case containsWord("shower") || hasRoot("душ"):
|
|
return "shower", `"took"`, true
|
|
case containsWord("break") || hasRoot("перерыв") || hasRoot("отдох"):
|
|
return "break", `"took"`, true
|
|
case containsWord("slept") || containsWord("sleep") ||
|
|
hasRoot("спал") || hasRoot("выспал"):
|
|
if v, ok := parseDurationValue(afterWord(s, "slept")); ok {
|
|
return "sleep", strconv.Quote(v), true
|
|
}
|
|
return "sleep", `"slept"`, true
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// TestFactParserCorpus scores both parsers over the corpus and prints the
|
|
// side-by-side. Run it with -v; the table is the output.
|
|
func TestFactParserCorpus(t *testing.T) {
|
|
type tally struct{ tpHit, tpMiss, misfire, misfireOK, fnHit, fnMiss int }
|
|
var now, old tally
|
|
|
|
var rows []string
|
|
rows = append(rows, "| utterance | class | want | old (substring) | new (closed class) |")
|
|
rows = append(rows, "|---|---|---|---|---|")
|
|
|
|
score := func(key string, ok bool, c factCase, tl *tally) string {
|
|
got := ""
|
|
if ok {
|
|
got = key
|
|
}
|
|
switch c.class {
|
|
case "tp":
|
|
if got == c.want {
|
|
tl.tpHit++
|
|
} else {
|
|
tl.tpMiss++
|
|
}
|
|
case "misfire":
|
|
if got == c.want {
|
|
tl.misfireOK++
|
|
} else {
|
|
tl.misfire++
|
|
}
|
|
case "fn":
|
|
if got == c.want {
|
|
tl.fnHit++
|
|
} else {
|
|
tl.fnMiss++
|
|
}
|
|
}
|
|
if got == "" {
|
|
return "—"
|
|
}
|
|
return got
|
|
}
|
|
|
|
for _, c := range factCorpus {
|
|
nk, _, nok := DefaultFactParser{}.Parse(c.utterance)
|
|
ok, _, ook := legacyFactParse(c.utterance)
|
|
gotNew := score(nk, nok, c, &now)
|
|
gotOld := score(ok, ook, c, &old)
|
|
want := c.want
|
|
if want == "" {
|
|
want = "—"
|
|
}
|
|
mark := ""
|
|
if gotOld != gotNew {
|
|
mark = " **≠**"
|
|
}
|
|
rows = append(rows, "| `"+c.utterance+"` | "+c.class+" | "+want+" | "+gotOld+" | "+gotNew+mark+" |")
|
|
}
|
|
|
|
line := func(name string, tl tally) string {
|
|
return name +
|
|
": true positives " + strconv.Itoa(tl.tpHit) + "/" + strconv.Itoa(tl.tpHit+tl.tpMiss) +
|
|
", misfires rejected " + strconv.Itoa(tl.misfireOK) + "/" + strconv.Itoa(tl.misfireOK+tl.misfire) +
|
|
", false-negative cases recovered " + strconv.Itoa(tl.fnHit) + "/" + strconv.Itoa(tl.fnHit+tl.fnMiss)
|
|
}
|
|
t.Log("\n" + strings.Join(rows, "\n") + "\n\n" + line("old (substring)", old) + "\n" + line("new (closed class)", now))
|
|
|
|
// The two regressions that are not judgement calls.
|
|
for _, c := range factCorpus {
|
|
k, _, ok := DefaultFactParser{}.Parse(c.utterance)
|
|
got := ""
|
|
if ok {
|
|
got = k
|
|
}
|
|
if c.class == "fn" {
|
|
continue
|
|
}
|
|
if c.broken {
|
|
// Recorded as wrong on 06-08. If it starts passing, someone fixed
|
|
// it and the flag is now a lie — say so rather than staying green.
|
|
if got == c.want {
|
|
t.Errorf("%q now returns %q as wanted — drop its broken flag", c.utterance, got)
|
|
}
|
|
continue
|
|
}
|
|
if got != c.want {
|
|
t.Errorf("%s %q: got %q, want %q (%s)", c.class, c.utterance, got, c.want, c.note)
|
|
}
|
|
}
|
|
}
|