Files
Maven/internal/router/eval/claims_test.go
T
claude 8015fdbb79 Harden semantic boundaries and repair dialogue state
Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
2026-08-13 03:00:31 +04:00

228 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package eval
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"testing"
"github.com/kami/maven/internal/router"
)
// Package-level note for V-565. The cascade's arbitration is list order, and
// the reason is that no two claimants report a comparable number. These tests
// measure what each claimant actually reports across the 91-case RU fixture,
// so the ordinal band set in docs/plans/19-dialogue-arbitration.md is argued
// from a distribution rather than from taste. They report and never assert:
// a ratchet here would freeze a number nobody has decided to hold yet.
// TestStage0Contention — how often more than one stage-0 grammar matches the
// same utterance. Every one of them reports Confidence 1.0, so where two
// match, list order is the entire decision and nothing in the Decision says a
// second rule wanted the turn.
func TestStage0Contention(t *testing.T) {
f, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
grammars := baselineGrammars(router.DefaultActMatcher{Fns: actFns})
t.Logf("stage 0: %d grammars over %d cases", len(grammars), len(f.Cases))
matched, contended := 0, 0
pairs := map[string]int{}
for _, c := range f.Cases {
claimants := matchingGrammars(grammars, c.Utterance)
if len(claimants) == 0 {
continue
}
matched++
if len(claimants) < 2 {
continue
}
contended++
t.Logf(" contended %s %q: %v (winner %q by order)", c.ID, c.Utterance, claimants, claimants[0])
for _, loser := range claimants[1:] {
pairs[claimants[0]+" beats "+loser]++
}
}
t.Logf("stage 0 claimed %d/%d cases, %d of those with more than one claimant", matched, len(f.Cases), contended)
for _, k := range sortedKeys(pairs) {
t.Logf(" %s ×%d", k, pairs[k])
}
}
// matchingGrammars — every regexp or structural grammar that accepts, in the
// daemon's order. Route stops at the first; this does not.
func matchingGrammars(grammars []router.Grammar, utterance string) []string {
stripped, hadWake := router.StripWakeToken(utterance)
var out []string
for _, g := range grammars {
_, matched, ok := g.Evaluate(utterance)
if !matched && hadWake {
_, matched, ok = g.Evaluate(stripped)
}
if !matched || !ok {
continue
}
out = append(out, g.Name)
}
return out
}
// TestClaimConfidenceDistributionHash — the confidence each claimant reports,
// on the deterministic hash embedder so it runs anywhere. The ONNX run below
// is the one whose cosines are the deployed numbers.
func TestClaimConfidenceDistributionHash(t *testing.T) {
reportConfidences(t, "hash", router.NewHashEmbedder(1024))
}
// TestONNXClaimConfidenceDistribution — the same measurement on the embedder
// homesrv runs, so the cosine column is the real one. Opt-in via
// MAVEN_ONNX_LIB, same as TestONNXBaseline, and one TestONNX* per process.
func TestONNXClaimConfidenceDistribution(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()
reportConfidences(t, "onnx", emb)
}
// reportConfidences runs the fixture through the deployed cascade and buckets
// the reported confidence by which layer produced it, then reports how well
// each bucket predicts a correct route. A band is only worth defining if the
// accuracy inside it differs from the accuracy outside it.
func reportConfidences(t *testing.T, name string, emb router.Embedder) {
t.Helper()
f, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
now, err := f.Now()
if err != nil {
t.Fatalf("Now: %v", err)
}
r := newBaselineRouter(t, emb, nil)
cls := newBaselineClassifier(t, emb)
type bucket struct{ n, correct int }
byValue := map[string]*bucket{}
byMargin := map[string]*bucket{}
var cosines, margins []float64
for _, c := range f.Cases {
d, err := r.Route(context.Background(), c.Utterance, now)
if err != nil {
t.Fatalf("%s: %v", c.ID, err)
}
layer := "classifier"
if d.Stage == 0 {
layer = "stage0"
} else {
cosines = append(cosines, d.Confidence)
}
key := fmt.Sprintf("%s conf=%.2f", layer, d.Confidence)
if layer == "classifier" {
key = fmt.Sprintf("%s conf=%.1f..%.1f", layer, floorTo(d.Confidence, 0.1), floorTo(d.Confidence, 0.1)+0.1)
}
b := byValue[key]
if b == nil {
b = &bucket{}
byValue[key] = b
}
ok := routeCorrect(c, d)
b.n++
if ok {
b.correct++
}
// The margin between the classifier's top two intents is the other
// float one could call a confidence. Measured on the same cases, so
// the ledger's "is a calibrated float available cheaply" question
// gets an answer instead of an assumption.
if layer != "classifier" {
continue
}
res, err := cls.Classify(context.Background(), c.Utterance)
if err != nil || len(res) < 2 {
continue
}
margin := res[0].Score - res[1].Score
margins = append(margins, margin)
mk := fmt.Sprintf("margin %.2f..%.2f", floorTo(margin, 0.02), floorTo(margin, 0.02)+0.02)
mb := byMargin[mk]
if mb == nil {
mb = &bucket{}
byMargin[mk] = mb
}
mb.n++
if ok {
mb.correct++
}
}
t.Logf("%s: confidence buckets over %d cases (correct = right intent, or clarified when the fixture wants a refusal)", name, len(f.Cases))
for _, k := range sortedKeys2(byValue) {
b := byValue[k]
t.Logf(" %-32s n=%2d correct=%2d (%.0f%%)", k, b.n, b.correct, 100*float64(b.correct)/float64(b.n))
}
if len(cosines) > 0 {
sort.Float64s(cosines)
t.Logf(" classifier cosine spread: min %.3f p25 %.3f p50 %.3f p75 %.3f max %.3f",
cosines[0], cosines[len(cosines)/4], cosines[len(cosines)/2],
cosines[3*len(cosines)/4], cosines[len(cosines)-1])
}
if len(margins) > 0 {
sort.Float64s(margins)
t.Logf(" classifier top1-top2 margin: min %.3f p50 %.3f max %.3f",
margins[0], margins[len(margins)/2], margins[len(margins)-1])
for _, k := range sortedKeys2(byMargin) {
b := byMargin[k]
t.Logf(" %-32s n=%2d correct=%2d (%.0f%%)", k, b.n, b.correct, 100*float64(b.correct)/float64(b.n))
}
}
}
// routeCorrect — the intent contract only. Slots are a parser question and
// would blur what the confidence number is being asked to predict.
func routeCorrect(c Case, d router.Decision) bool {
if c.WantClarify {
return d.Clarify
}
return d.Intent == c.Intent && !d.Clarify
}
func floorTo(v, step float64) float64 {
return float64(int(v/step)) * step
}
func sortedKeys(m map[string]int) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func sortedKeys2[T any](m map[string]T) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}