c259ed6c73
Step 4 of the board build (docs/plans/15-board-surface.md). Naming a task instead of its position reached nothing: "закрой задачу купить молоко" routed act, found no allowlisted fn, and the gate asked "Что сделать?". The position path already worked through resolveCandidate, but only in the two turns after she read the list out. TaskStatusGrammar is the same shape TaskCaptureGrammar uses — matches broadly, decides in Build, no eighth intent — and fills the fn slot with task_status, which is neither a Hexis capability nor a Praxis one. Three conditions, all required: the board noun, so no ordinary sentence claims a turn; exactly one status class, since "готово, убери" names two and asking beats picking; and a status word matched as an imperative exactly or a stative by lemma. So a bare "готово" and a bare "закрой" are not this rule's, and the second belongs to Praxis, which claims it already. Two lexicon sets rather than one with a value. The store records which of the two transitions happened and /tasks shows it: work he chose to stop is not work he did. Measured on the fixture, two new cases (ru-act-020, ru-act-021). Classifier + ONNX 62/89 (69.7%) → 64/91 (70.3%); cascade+llm 67/89 (75.3%) → 69/91 (75.8%, 80.2% intent-only) at p50 1.225s. Both new cases claimed at stage 0, no case regressed, clarify counts unchanged at 3 false / 1 missed. The task's own warning stands: every such grammar runs its parser ahead of the resident model on every turn, so this is the last one that is free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
339 lines
12 KiB
Go
339 lines
12 KiB
Go
package eval
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/kami/maven/internal/config"
|
||
"github.com/kami/maven/internal/router"
|
||
)
|
||
|
||
const seedDir = "../../../models/seeds"
|
||
|
||
// actFns — the verbs the baseline act matcher accepts. In the daemon the
|
||
// allowlist is exactly the enabled tool names (see buildRouter in
|
||
// cmd/mavend/voice.go); here it stands in for a deployment's tools so the
|
||
// fixture's want_fn cases are satisfiable at all. DefaultActMatcher is
|
||
// verb-prefix only, so these are verbs, not full commands.
|
||
var actFns = []string{
|
||
"перезапусти", "перезагрузи", "выключи", "включи", "останови", "запусти",
|
||
"закрой", "открой", "сделай", "поставь",
|
||
"restart", "reboot", "stop", "start", "turn off", "turn on", "open", "close",
|
||
}
|
||
|
||
func TestLoadFixture(t *testing.T) {
|
||
f, err := Load()
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
if _, err := f.Now(); err != nil {
|
||
t.Fatalf("Now: %v", err)
|
||
}
|
||
valid := map[router.Intent]bool{
|
||
router.IntentAct: true, router.IntentReminder: true, router.IntentFact: true,
|
||
router.IntentNote: true, router.IntentQuery: true, router.IntentChat: true,
|
||
router.IntentSystem: true,
|
||
}
|
||
seen := map[string]bool{}
|
||
for _, c := range f.Cases {
|
||
if c.ID == "" || seen[c.ID] {
|
||
t.Errorf("case %q: empty or duplicate id", c.ID)
|
||
}
|
||
seen[c.ID] = true
|
||
if strings.TrimSpace(c.Utterance) == "" {
|
||
t.Errorf("%s: empty utterance", c.ID)
|
||
}
|
||
if c.Lang != "ru" && c.Lang != "en" {
|
||
t.Errorf("%s: lang %q, want ru|en", c.ID, c.Lang)
|
||
}
|
||
// Intent is empty exactly when the case expects a refusal — otherwise
|
||
// a typo'd intent would score as an unreachable target forever.
|
||
if c.WantClarify {
|
||
if c.Intent != "" {
|
||
t.Errorf("%s: want_clarify with intent %q — pick one", c.ID, c.Intent)
|
||
}
|
||
if c.WantTime || c.WantFn || c.WantFactKey != "" {
|
||
t.Errorf("%s: want_clarify with slot expectations", c.ID)
|
||
}
|
||
continue
|
||
}
|
||
if !valid[c.Intent] {
|
||
t.Errorf("%s: intent %q not in the seven", c.ID, c.Intent)
|
||
}
|
||
// Slot expectations must match the intent that owns that slot, or the
|
||
// case asserts something Extract never fills.
|
||
if c.WantTime && c.Intent != router.IntentReminder {
|
||
t.Errorf("%s: want_time on intent %q", c.ID, c.Intent)
|
||
}
|
||
if c.WantFn && c.Intent != router.IntentAct {
|
||
t.Errorf("%s: want_fn on intent %q", c.ID, c.Intent)
|
||
}
|
||
if c.WantFactKey != "" && c.Intent != router.IntentFact {
|
||
t.Errorf("%s: want_fact_key on intent %q", c.ID, c.Intent)
|
||
}
|
||
}
|
||
// Coverage floor: every intent plus the refusal lane must be represented,
|
||
// or a path can regress to zero without the number moving.
|
||
byIntent := map[router.Intent]int{}
|
||
clarify := 0
|
||
for _, c := range f.Cases {
|
||
if c.WantClarify {
|
||
clarify++
|
||
continue
|
||
}
|
||
byIntent[c.Intent]++
|
||
}
|
||
for in := range valid {
|
||
if byIntent[in] < 5 {
|
||
t.Errorf("intent %q has %d cases, want >= 5", in, byIntent[in])
|
||
}
|
||
}
|
||
if clarify < 5 {
|
||
t.Errorf("%d clarify cases, want >= 5", clarify)
|
||
}
|
||
}
|
||
|
||
// TestFixtureIsHeldOut — the fixture's whole claim to measuring anything. The
|
||
// classifier routes by similarity to frozen seed phrases, so a fixture sharing
|
||
// utterances with models/seeds/*.txt would score its own training set. Verbatim
|
||
// match is the line drawn: paraphrases are the point of the fixture, copies are
|
||
// the failure.
|
||
func TestFixtureIsHeldOut(t *testing.T) {
|
||
f, err := Load()
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
seeds := loadSeeds(t)
|
||
for _, c := range f.Cases {
|
||
if src, ok := seeds[normalize(c.Utterance)]; ok {
|
||
t.Errorf("%s: %q is verbatim in %s — not held out", c.ID, c.Utterance, src)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestClassifierBaseline — the number Vikunja #319 compares against. This is
|
||
// the committed stopgap path (stage 0 grammar → nearest-centroid classifier
|
||
// over the real models/seeds corpus), scored on held-out utterances.
|
||
//
|
||
// It runs on HashEmbedder, not the ONNX multilingual embedder: the hash floor
|
||
// is deterministic, so this baseline is reproducible in CI. The production
|
||
// ONNX number will be higher; measure both against the same fixture before
|
||
// flipping the default (#320), and never compare a hash-embedder run to an
|
||
// ONNX one.
|
||
//
|
||
// The assertion is a ratchet, not a target — it only catches a regression
|
||
// below what the cascade already does. The gap between it and 100% is the
|
||
// backlog.
|
||
func TestClassifierBaseline(t *testing.T) {
|
||
f, err := Load()
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
// dim 1024: the hash embedder is bag-of-words, so a narrower space would
|
||
// collide tokens across intents and measure the hash, not the centroids.
|
||
rep, err := Score(context.Background(), "classifier+hash", newBaselineRouter(t, router.NewHashEmbedder(1024), nil), f)
|
||
if err != nil {
|
||
t.Fatalf("Score: %v", err)
|
||
}
|
||
t.Log("\n" + rep.String() + rep.Failures())
|
||
|
||
// 0.15 is under the observed 0.171, not a target. Almost every case here
|
||
// falls to clarify because the hash embedder's cosine never clears the
|
||
// 0.55 gate on paraphrases — which is the documented floor behaviour
|
||
// (AGENTS.md: "Russian recall rarely clears the confidence gate"), not a
|
||
// bug this fixture is asking anyone to fix. The number worth moving is
|
||
// TestONNXBaseline's.
|
||
const floor = 0.15
|
||
if rep.Accuracy() < floor {
|
||
t.Errorf("accuracy %.3f below ratchet %.2f — routing regressed", rep.Accuracy(), floor)
|
||
}
|
||
// The dangerous direction is asserted separately and tightly: a confident
|
||
// route for "сделай это" is a destructive guess, and unlike a miss the user
|
||
// never gets asked. Clarify cases must not silently start deciding.
|
||
if rep.MissedClarify > 2 {
|
||
t.Errorf("%d ambiguous utterances routed confidently, want <= 2:\n%s", rep.MissedClarify, rep.Failures())
|
||
}
|
||
}
|
||
|
||
// TestONNXBaseline — the number that actually belongs in Vikunja #319: the
|
||
// deployed cascade with the multilingual ONNX embedder, the configuration
|
||
// homesrv runs. Opt-in via MAVEN_ONNX_LIB because deps/ is gitignored, so the
|
||
// runtime is not guaranteed to exist on a fresh clone; CI has the hash ratchet
|
||
// above instead. `make eval-router` defaults the variable to the vendored
|
||
// deps/onnxruntime-linux-x64-1.26.0 copy.
|
||
//
|
||
// Measured 2026-07-31 on deps/onnxruntime-linux-x64-1.26.0: 28/76 (36.8%),
|
||
// 21 false clarifies, 5 MISSED clarifies, p50 ~30-70ms. The missed clarifies
|
||
// are the finding — every one of the six ambiguous utterances scores higher
|
||
// cosine under ONNX than under the hash floor, so the 0.55 gate that held them
|
||
// back stops holding: "сделай это" routes to act at 0.847. A better embedder
|
||
// made the refusal lane worse, which is an argument about the gate, not the
|
||
// embedder.
|
||
//
|
||
// Reports rather than asserts: the score is an input to the #320 flip decision,
|
||
// and a threshold invented here would just be a second opinion about the same
|
||
// unmeasured thing. Compare it to an LLM-router run over the SAME fixture —
|
||
// accuracy and p50/p95 latency both, since the resident model pays CPU seconds
|
||
// per turn that the classifier's ~10µs does not.
|
||
func TestONNXBaseline(t *testing.T) {
|
||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||
if lib == "" {
|
||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||
}
|
||
model := filepath.Join("../../..", "models/embedder/multilingual-e5-small/model_quantized.onnx")
|
||
tok := filepath.Join("../../..", "models/embedder/multilingual-e5-small/tokenizer.json")
|
||
for _, p := range []string{lib, model, tok} {
|
||
if _, err := os.Stat(p); err != nil {
|
||
t.Skipf("missing %s: %v", p, err)
|
||
}
|
||
}
|
||
emb, err := router.NewONNXEmbedder(model, tok, lib)
|
||
if err != nil {
|
||
t.Skipf("onnx embedder unavailable: %v", err)
|
||
}
|
||
defer emb.Close()
|
||
|
||
f, err := Load()
|
||
if err != nil {
|
||
t.Fatalf("Load: %v", err)
|
||
}
|
||
rep, err := Score(context.Background(), "classifier+onnx", newBaselineRouter(t, emb, nil), f)
|
||
if err != nil {
|
||
t.Fatalf("Score: %v", err)
|
||
}
|
||
t.Log("\n" + rep.String() + rep.Failures())
|
||
}
|
||
|
||
// newBaselineRouter mirrors buildRouter in cmd/mavend/voice.go — same grammar
|
||
// set, same extractor floors, same seed corpus, same confidence gate — so the
|
||
// score reflects the deployed cascade and not a test-local approximation. Only
|
||
// the embedder varies, and that is the axis being measured.
|
||
func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter) *router.Router {
|
||
t.Helper()
|
||
acts := router.DefaultActMatcher{Fns: actFns}
|
||
cls := router.NewClassifier(emb)
|
||
ctx := context.Background()
|
||
seeds := seedsWithIntent(t)
|
||
texts := make([]string, 0, len(seeds))
|
||
for text := range seeds {
|
||
texts = append(texts, text)
|
||
}
|
||
// Sorted: centroids are order-independent, but tie-breaking in Classify's
|
||
// result sort is not, and a jittering baseline can't be a ratchet.
|
||
sort.Strings(texts)
|
||
for _, text := range texts {
|
||
if err := cls.AddExample(ctx, seeds[text], text); err != nil {
|
||
t.Fatalf("seed %q: %v", text, err)
|
||
}
|
||
}
|
||
grammars := router.DefaultGrammars(acts)
|
||
grammars = append(grammars, router.SystemTimeDateGrammars()...)
|
||
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
||
// anything while its grammar set is the daemon's grammar set.
|
||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||
grammars = append(grammars, router.FeedQueryGrammar())
|
||
// The list side of the same exposure: a phrasing with no possessive in it
|
||
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||
grammars = append(grammars, router.TaskListGrammar())
|
||
grammars = append(grammars, router.ListGrammars()...)
|
||
grammars = append(grammars, router.ReminderGrammar())
|
||
grammars = append(grammars, router.PraxisGrammars()...)
|
||
grammars = append(grammars, router.TaskStatusGrammar())
|
||
grammars = append(grammars, router.TaskCaptureGrammar())
|
||
// "расскажи про X" is a world question the model called a fact, and the
|
||
// rule goes last because it matches on the first word alone (Vikunja #498).
|
||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||
return router.New(router.Config{
|
||
Grammars: grammars,
|
||
Classifier: cls,
|
||
Extractor: router.Extractor{
|
||
Time: router.StubDateTimeParser{},
|
||
Acts: acts,
|
||
Facts: router.DefaultFactParser{},
|
||
},
|
||
// The deployed gate, not a test-local one: a fixture scored at a looser
|
||
// threshold reports an accuracy no real turn would see.
|
||
Threshold: config.DefaultRouterThreshold,
|
||
LLM: llmR,
|
||
})
|
||
}
|
||
|
||
// seedOrder — fixed iteration order over the corpus. Not cosmetic: a few
|
||
// phrases appear under two intents ("как дела у сервера" is in both query.txt
|
||
// and system.txt), and ranging over a map would hand the duplicate to a
|
||
// different centroid on every run, which makes the baseline score jitter and
|
||
// the ratchet meaningless.
|
||
var seedOrder = []router.Intent{
|
||
router.IntentAct, router.IntentReminder, router.IntentFact,
|
||
router.IntentNote, router.IntentQuery, router.IntentChat, router.IntentSystem,
|
||
}
|
||
|
||
// loadSeeds maps normalized seed text → the file it came from.
|
||
func loadSeeds(t *testing.T) map[string]string {
|
||
t.Helper()
|
||
files := readSeedFiles(t)
|
||
out := map[string]string{}
|
||
for _, intent := range seedOrder {
|
||
for _, l := range files[intent] {
|
||
out[normalize(l)] = string(intent) + ".txt"
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// seedsWithIntent flattens the corpus to text→intent. A phrase appearing under
|
||
// two intents collapses to one example — the classifier would otherwise train
|
||
// two centroids to fight over the same vector. First intent in seedOrder wins,
|
||
// deterministically.
|
||
func seedsWithIntent(t *testing.T) map[string]router.Intent {
|
||
t.Helper()
|
||
files := readSeedFiles(t)
|
||
out := map[string]router.Intent{}
|
||
for _, intent := range seedOrder {
|
||
for _, l := range files[intent] {
|
||
if _, dup := out[l]; dup {
|
||
continue
|
||
}
|
||
out[l] = intent
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func readSeedFiles(t *testing.T) map[router.Intent][]string {
|
||
t.Helper()
|
||
out := map[router.Intent][]string{}
|
||
for _, in := range seedOrder {
|
||
path := filepath.Join(seedDir, string(in)+".txt")
|
||
fh, err := os.Open(path)
|
||
if err != nil {
|
||
t.Fatalf("open %s: %v", path, err)
|
||
}
|
||
sc := bufio.NewScanner(fh)
|
||
for sc.Scan() {
|
||
line := strings.TrimSpace(sc.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
out[in] = append(out[in], line)
|
||
}
|
||
err = sc.Err()
|
||
fh.Close()
|
||
if err != nil {
|
||
t.Fatalf("read %s: %v", path, err)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// normalize — held-out comparison is on lowercased, whitespace-collapsed text
|
||
// so a copied seed can't sneak in behind capitalisation.
|
||
func normalize(s string) string {
|
||
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
|
||
}
|