package eval import ( "context" "os" "path/filepath" "testing" "github.com/kami/maven/internal/router" ) func TestLoadReachFixture(t *testing.T) { f, err := LoadReach() if err != nil { t.Fatalf("LoadReach: %v", err) } if _, err := f.Now(); err != nil { t.Fatalf("Now: %v", err) } valid := map[Service]bool{ServiceNone: true, ServicePraxis: true, ServiceHexis: true} seen := map[string]bool{} byService := map[Service]int{} 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 !valid[c.WantService] { t.Errorf("%s: want_service %q, want none|praxis|hexis", c.ID, c.WantService) } if c.Lang != "ru" && c.Lang != "en" { t.Errorf("%s: lang %q, want ru|en", c.ID, c.Lang) } // A capability only means something on the Praxis side, and it must // name an arm that exists — otherwise the case asserts a target the // dispatch can never hit. if c.WantCapability != "" { if c.WantService != ServicePraxis { t.Errorf("%s: want_capability on %q", c.ID, c.WantService) } if PraxisAliases[c.WantCapability] != c.WantCapability { t.Errorf("%s: capability %q is not a praxis arm", c.ID, c.WantCapability) } } byService[c.WantService]++ } // Coverage floor. The negative cases matter most: without them a router // that sent everything to Hexis would score perfectly. for s := range valid { if byService[s] < 5 { t.Errorf("want_service %q has %d cases, want >= 5", s, byService[s]) } } } // TestReachFixtureIsHeldOut — same rule as TestFixtureIsHeldOut. The classifier // routes by similarity to frozen seeds, so a case copied from models/seeds // would measure memorisation rather than reach. func TestReachFixtureIsHeldOut(t *testing.T) { f, err := LoadReach() if err != nil { t.Fatalf("LoadReach: %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) } } } // TestReachDerivation pins the gate order that ScoreReach depends on. These are // the four branches of actionAct plus the clarify pre-empt, asserted directly // so a change to cmd/mavend/actions_act.go that this package no longer mirrors // fails here rather than silently moving the score. func TestReachDerivation(t *testing.T) { m := router.DefaultActMatcher{Fns: actFns} cases := []struct { name string dec router.Decision want Service capability string }{ { name: "act with a praxis alias in the fn slot", dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "list_attention", HasFn: true}}, want: ServicePraxis, capability: "list_attention", }, { name: "act with a russian praxis alias", dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "готово", HasFn: true}}, want: ServicePraxis, capability: "resolve_item", }, { name: "act with an entity but no fn earns one from the matcher", dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: "выключи свет в спальне"}}, want: ServiceHexis, }, { name: "act with a non-praxis fn and text goes to hexis", dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "перезапусти гитею"}}, want: ServiceHexis, }, { name: "act with a fn and no text stays local", dec: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true}}, want: ServiceNone, }, { name: "a clarified act with text still reaches hexis", dec: router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Text: "выключи это"}}, want: ServiceHexis, }, { name: "a clarified act that already has a fn does not", dec: router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "перезапусти"}}, want: ServiceNone, }, { name: "no intent but act reaches nothing", dec: router.Decision{Intent: router.IntentQuery, Slots: router.Slots{Fn: "list_attention", HasFn: true, Text: "что требует внимания"}}, want: ServiceNone, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, capability := Reach(tc.dec, m) if got != tc.want { t.Errorf("Reach = %q, want %q", got, tc.want) } if capability != tc.capability { t.Errorf("capability = %q, want %q", capability, tc.capability) } }) } } // TestPraxisAliasesShape — the copy of praxisCapabilities in reach.go is a // drift risk (see its comment). This does not close it, but it does catch the // cheap half: an arm losing all its aliases, or an alias pointing at an arm // that no longer has a canonical name. func TestPraxisAliasesShape(t *testing.T) { arms := map[string]int{} for alias, capability := range PraxisAliases { if alias == "" || capability == "" { t.Errorf("empty alias or capability: %q → %q", alias, capability) } arms[capability]++ } for _, want := range []string{ "list_attention", "acknowledge_item", "resolve_item", "ignore_item", "pin_item", "list_changes", "entity_attention", } { if arms[want] == 0 { t.Errorf("praxis arm %q has no aliases", want) } // Every arm must be reachable by its own name, which is the alias the // LLM router's fn slot actually emits. if PraxisAliases[want] != want { t.Errorf("arm %q does not alias to itself", want) } } } // TestReachBaselineHash — the deterministic ratchet. Same deal as // TestClassifierBaseline: the hash embedder never clears the confidence gate on // paraphrases, so almost everything lands on clarify, and the number this // asserts is the overreach count rather than the accuracy. // // Overreach is the direction worth a hard assertion. An utterance that should // stay inside Maven and instead resolves an entity through Nexus is one Hexis // capability away from executing something nobody asked for, and unlike a miss // he never gets asked about it. func TestReachBaselineHash(t *testing.T) { f, err := LoadReach() if err != nil { t.Fatalf("LoadReach: %v", err) } m := router.DefaultActMatcher{Fns: actFns} rep, err := ScoreReach(context.Background(), "reach: classifier+hash", newBaselineRouter(t, router.NewHashEmbedder(1024), nil), m, f) if err != nil { t.Fatalf("ScoreReach: %v", err) } t.Log("\n" + rep.String() + rep.Failures()) if rep.Overreach > 4 { t.Errorf("%d utterances reached a service they should not have, want <= 4:\n%s", rep.Overreach, rep.Failures()) } } // TestReachBaselineONNX — the deployed configuration: the cascade with the // multilingual e5 embedder. Opt-in via MAVEN_ONNX_LIB, same as TestONNXBaseline, // because deps/ is gitignored. `make eval-reach` points it at the vendored copy. // // Reports rather than asserts. This is the number Vikunja #405 asked for, and a // threshold invented alongside the first measurement is not a ratchet, it is a // guess written down twice. func TestReachBaselineONNX(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 := LoadReach() if err != nil { t.Fatalf("LoadReach: %v", err) } rep, err := ScoreReach(context.Background(), "reach: classifier+onnx", newBaselineRouter(t, emb, nil), router.DefaultActMatcher{Fns: actFns}, f) if err != nil { t.Fatalf("ScoreReach: %v", err) } t.Log("\n" + rep.String() + rep.Failures()) }