fe489dff6d
attentionq.go, repair.go and internal/router/complaint.go carry the last
hand-written Russian patterns of the V-522 sweep, and they live on task/467.
internal/lexicon, internal/morph and cmd/mavend/topics.go live here. One of
the two had to move.
Four conflicts, and one of them is a real collision rather than a mechanical
one. Both branches wrote the narrative stage 0 rule. This side had
NarrativeQueryGrammars, plural, with the rest-of-day rule beside it and the
verb alternation built from the lexicon; task/467 had NarrativeQueryGrammar,
singular, which extracts the topic into Slots.Text, refuses a bare "расскажи",
and excludes the shapes that are chat ("расскажи о себе", "историю на ночь").
Resolved by keeping this side's container and this side's lexicon-built
pattern, and taking every behaviour only the other side had: the topic slot,
the empty-topic refusal, chatNarrativeTopics, and its wiring position after
TaskCaptureGrammar so "запиши" still beats "расскажи".
The rest: queryFeeds keeps task/467's conditional claim (V-474 supersedes the
unconditional one), rank.go keeps Spoken and drops pluralTasksRU because
say.CountWord is the one copy of Russian count agreement, and vendor/ was
re-vendored — the merged modules.txt claimed replaces for nexus and praxis
that neither go.mod has.
Routing fixture 58/82, unchanged from both sides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
336 lines
12 KiB
Go
336 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.ReminderGrammar())
|
||
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)), " ")
|
||
}
|