// Package eval is the held-out routing contract — the fixture Vikunja #319 // measures against before #320 flips the default route decider. // // Why it is a separate package from router: the fixture must be scorable by // BOTH paths (today's classifier cascade and the resident model's LLM router) // from outside the router package, including from cmd/mavend where the real // llama-server client lives. A _test.go file in router can't be imported, and // testdata isn't reachable from another package's working directory — so the // fixture is embedded here and the scorer takes a Router interface. // // The fixture is HELD OUT from models/seeds/*.txt on purpose: a classifier // scored on its own seed phrases measures memorisation of frozen centroids, // which is exactly the illusion that hid the weak RU query handling. See // TestFixtureIsHeldOut, which enforces it. package eval import ( "context" _ "embed" "encoding/json" "fmt" "sort" "strings" "time" "github.com/kami/maven/internal/router" ) //go:embed ru_routing_v1.json var fixtureJSON []byte // Case — one utterance and the route it must produce. Slot expectations are // deliberately coarse (see the fixture's notes): want_fn is a boolean because // the fn allowlist lives in deploy config, and want_fact_key names the loop's // rule keys because a fact under the wrong key starves its predicate. // // Intent is empty exactly when WantClarify is set: the contract there is that // the router refuses instead of guessing. type Case struct { ID string `json:"id"` Utterance string `json:"utterance"` Lang string `json:"lang"` Intent router.Intent `json:"intent"` WantTime bool `json:"want_time"` WantFn bool `json:"want_fn"` WantFactKey string `json:"want_fact_key"` WantClarify bool `json:"want_clarify"` Tags []string `json:"tags"` Note string `json:"note"` } // Fixture — the versioned envelope, same shape as // cmd/mavend/testdata/system_safety_scenarios.json. SchemaVersion gates the // loader so an older binary refuses a fixture it would misread rather than // scoring it wrong and reporting a number. type Fixture struct { SchemaVersion int `json:"schema_version"` Name string `json:"name"` ReferenceNow string `json:"reference_now"` Notes []string `json:"notes"` Cases []Case `json:"cases"` } // SchemaVersion — the version this package understands. const SchemaVersion = 1 // Load returns the embedded fixture. func Load() (Fixture, error) { var f Fixture if err := json.Unmarshal(fixtureJSON, &f); err != nil { return Fixture{}, fmt.Errorf("parse fixture: %w", err) } if f.SchemaVersion != SchemaVersion { return Fixture{}, fmt.Errorf("fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion) } if len(f.Cases) == 0 { return Fixture{}, fmt.Errorf("fixture has no cases") } return f, nil } // Now — the fixture's reference clock. Relative reminder slots ("через // полчаса") resolve against it, so a scoring run is reproducible regardless of // when it runs. func (f Fixture) Now() (time.Time, error) { t, err := time.Parse(time.RFC3339, f.ReferenceNow) if err != nil { return time.Time{}, fmt.Errorf("parse reference_now %q: %w", f.ReferenceNow, err) } return t, nil } // Router — the one thing a route decider must do to be scorable. *router.Router // satisfies it directly; an LLM-only path wraps its Route in RouterFunc. type Router interface { Route(ctx context.Context, utterance string, now time.Time) (router.Decision, error) } // RouterFunc adapts a bare function to Router — for scoring a single stage // (e.g. *router.LLMRouter, whose Route returns an extra ok bool) without // standing up the whole cascade. type RouterFunc func(ctx context.Context, utterance string, now time.Time) (router.Decision, error) // Route implements Router. func (f RouterFunc) Route(ctx context.Context, utterance string, now time.Time) (router.Decision, error) { return f(ctx, utterance, now) } // Outcome — one scored case. Reasons is empty exactly when Pass is true. type Outcome struct { Case Case Decision router.Decision Err error Latency time.Duration Pass bool // IntentOK is tracked separately from Pass: a case can land the right // intent and still fail on a slot, and #319 needs those two numbers apart // (a slot gap is a parser fix; a wrong intent is a router fix). IntentOK bool Reasons []string } // Report — the aggregate. Accuracy is the headline; the rest exists so a // regression names itself instead of just moving a percentage. type Report struct { Name string Total int Passed int IntentHit int // FalseClarify — the router asked when the fixture expected a decision. // A gap, recoverable by asking again. FalseClarify int // MissedClarify — the router decided confidently where the fixture // expected a refusal. The dangerous direction: "сделай это" routed to an // act is a confident destructive guess. MissedClarify int Errors int // SlotsDeferred — stage-0 hits whose slot the daemon fills downstream // (reminder grammar → applyAction's time parser). Not a miss, but not a // full router-level win either; tracked so the two aren't conflated. SlotsDeferred int Outcomes []Outcome // Confusion counts want→got intent pairs, decided cases only. Confusion map[string]int // ByTag accuracy for the fixture's tags ("hard", "homelab", …). ByTag map[string]TagStat // ByLang accuracy — the RU/EN split is the whole reason this fixture // exists. ByLang map[string]TagStat P50 time.Duration P95 time.Duration Max time.Duration } // TagStat — passed/total for one slice of the fixture. type TagStat struct{ Passed, Total int } // Accuracy — fraction of cases fully satisfied (intent AND slots AND the // clarify contract). func (r Report) Accuracy() float64 { if r.Total == 0 { return 0 } return float64(r.Passed) / float64(r.Total) } // IntentAccuracy — fraction with the right intent, ignoring slot fills. func (r Report) IntentAccuracy() float64 { if r.Total == 0 { return 0 } return float64(r.IntentHit) / float64(r.Total) } // Score runs every case through r and aggregates. It never fails the run on a // route error — an erroring case scores as a miss and is counted in Errors, // because "the model was down" and "the model was wrong" are different numbers // and #319 needs to tell them apart. // // Latency is wall-clock per Route call, including any llama-server round trip. // That is the point on the CPU-only target: a correctness win the resident // model pays for with seconds per turn is not a win. func Score(ctx context.Context, name string, r Router, f Fixture) (Report, error) { now, err := f.Now() if err != nil { return Report{}, err } rep := Report{ Name: name, Total: len(f.Cases), Confusion: map[string]int{}, ByTag: map[string]TagStat{}, ByLang: map[string]TagStat{}, } lat := make([]time.Duration, 0, len(f.Cases)) for _, c := range f.Cases { start := time.Now() d, err := r.Route(ctx, c.Utterance, now) o := Outcome{Case: c, Decision: d, Err: err, Latency: time.Since(start)} lat = append(lat, o.Latency) switch { case err != nil: rep.Errors++ o.Reasons = append(o.Reasons, fmt.Sprintf("route error: %v", err)) case c.WantClarify: // Only the refusal matters here; whatever intent the cascade // guessed underneath is irrelevant if it gated. o.IntentOK = d.Clarify if !d.Clarify { rep.MissedClarify++ o.Reasons = append(o.Reasons, fmt.Sprintf("decided %q confidently (%.3f), want clarify", d.Intent, d.Confidence)) } default: o.IntentOK = d.Intent == c.Intent && !d.Clarify if d.Clarify { rep.FalseClarify++ o.Reasons = append(o.Reasons, fmt.Sprintf("clarified (%.3f), want intent %q", d.Confidence, c.Intent)) } else if d.Intent != c.Intent { rep.Confusion[string(c.Intent)+"→"+string(d.Intent)]++ o.Reasons = append(o.Reasons, fmt.Sprintf("intent %q, want %q (%.3f)", d.Intent, c.Intent, d.Confidence)) } if c.WantTime && !d.Slots.HasTime { // Stage 0 skips the extractor by design: ReminderGrammar // captures the text after "напомни"/"remind me" and the // daemon's applyAction runs the time parser on it (see // stage0.go). Charging the router for a slot it was never // asked to fill would make an exact-match win look like a // miss — so it is counted, not failed. if d.Stage == 0 { rep.SlotsDeferred++ } else { o.Reasons = append(o.Reasons, "no time slot, want one") } } if c.WantFn && !d.Slots.HasFn { o.Reasons = append(o.Reasons, "no fn slot, want an allowlist match") } if c.WantFactKey != "" && d.Slots.Key != c.WantFactKey { o.Reasons = append(o.Reasons, fmt.Sprintf("fact key %q, want %q", d.Slots.Key, c.WantFactKey)) } } o.Pass = len(o.Reasons) == 0 if o.Pass { rep.Passed++ } if o.IntentOK { rep.IntentHit++ } bump(rep.ByLang, c.Lang, o.Pass) for _, tag := range c.Tags { bump(rep.ByTag, tag, o.Pass) } rep.Outcomes = append(rep.Outcomes, o) } sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] }) rep.P50, rep.P95 = percentile(lat, 0.50), percentile(lat, 0.95) if len(lat) > 0 { rep.Max = lat[len(lat)-1] } return rep, nil } func bump(m map[string]TagStat, key string, pass bool) { if key == "" { return } s := m[key] s.Total++ if pass { s.Passed++ } m[key] = s } // percentile — nearest-rank on a pre-sorted slice. No interpolation: with ~80 // samples an interpolated p95 invents a latency no turn actually took. func percentile(sorted []time.Duration, p float64) time.Duration { if len(sorted) == 0 { return 0 } i := int(p * float64(len(sorted))) if i >= len(sorted) { i = len(sorted) - 1 } return sorted[i] } // String renders the report as the comparison table #319 pastes into the task — // headline accuracy, the two clarify directions apart, latency, and the slices // that name where a path is weak. func (r Report) String() string { var b strings.Builder fmt.Fprintf(&b, "%s: %d/%d cases (%.1f%% full, %.1f%% intent-only)\n", r.Name, r.Passed, r.Total, 100*r.Accuracy(), 100*r.IntentAccuracy()) fmt.Fprintf(&b, " clarify: %d false (asked, shouldn't) / %d missed (guessed, shouldn't) | errors: %d | slots deferred to daemon: %d\n", r.FalseClarify, r.MissedClarify, r.Errors, r.SlotsDeferred) fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max) fmt.Fprintf(&b, " by lang: %s\n", renderStats(r.ByLang)) fmt.Fprintf(&b, " by tag: %s\n", renderStats(r.ByTag)) if len(r.Confusion) > 0 { fmt.Fprintf(&b, " confusion: %s\n", renderCounts(r.Confusion)) } return b.String() } // Failures — the per-case detail, sorted by ID so two runs diff cleanly. func (r Report) Failures() string { var b strings.Builder out := append([]Outcome(nil), r.Outcomes...) sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID }) for _, o := range out { if o.Pass { continue } fmt.Fprintf(&b, " %s %q: %s\n", o.Case.ID, o.Case.Utterance, strings.Join(o.Reasons, "; ")) } return b.String() } func renderStats(m map[string]TagStat) string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { s := m[k] parts = append(parts, fmt.Sprintf("%s %d/%d", k, s.Passed, s.Total)) } return strings.Join(parts, " ") } func renderCounts(m map[string]int) string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { if m[keys[i]] != m[keys[j]] { return m[keys[i]] > m[keys[j]] } return keys[i] < keys[j] }) parts := make([]string, 0, len(keys)) for _, k := range keys { parts = append(parts, fmt.Sprintf("%s ×%d", k, m[k])) } return strings.Join(parts, " ") }