diff --git a/internal/router/semantic/eval.go b/internal/router/semantic/eval.go new file mode 100644 index 0000000..073896c --- /dev/null +++ b/internal/router/semantic/eval.go @@ -0,0 +1,186 @@ +package semantic + +import ( + "fmt" + "math" + "sort" + "strings" +) + +// EvalCase — one row in the frozen evaluation set. Carries both the expected +// coarse route and metadata about how it was generated. +type EvalCase struct { + ID string `json:"id"` + Text string `json:"text"` + ExpectedRoute SemanticRoute `json:"expected_route"` + Source string `json:"source"` + SourceID string `json:"source_id"` + SplitGroup string `json:"split_group"` + FastPathResolved bool `json:"fast_path_resolved"` + Tags []string `json:"tags,omitempty"` +} + +// EvalOutcome — one scored case. +type EvalOutcome struct { + Case EvalCase + Got SemanticRoute + OK bool + FastOK bool // agreement with fast-path when applicable +} + +// EvalReport — aggregate metrics for a frozen eval run. +type EvalReport struct { + Total int + Passed int + ByRoute map[SemanticRoute]RouteMetrics + // FalseAction — the primary safety metric: cases that should NOT be + // action but were classified as action. + FalseAction int + FalseActionRate float64 + // Confusion[want][got] counts + Confusion map[SemanticRoute]map[SemanticRoute]int + // FastPathResolved vs residual split + FastPathTotal int + FastPathPassed int + ResidualTotal int + ResidualPassed int +} + +// RouteMetrics — per-route precision/recall/F1. +type RouteMetrics struct { + Precision float64 + Recall float64 + F1 float64 + TP int + FP int + FN int +} + +// ScoreEval runs a SemanticRouter against a frozen eval set and returns +// aggregate metrics. +func ScoreEval(router SemanticRouter, evalSet []EvalCase) EvalReport { + rep := EvalReport{ + ByRoute: make(map[SemanticRoute]RouteMetrics), + Confusion: make(map[SemanticRoute]map[SemanticRoute]int), + } + for _, r := range AllRoutes { + rep.Confusion[r] = make(map[SemanticRoute]int) + } + + for _, c := range evalSet { + decision, err := router.Route(nil, c.Text) + var got SemanticRoute + if err != nil { + got = RouteUncertain + } else { + got = decision.Route + } + ok := got == c.ExpectedRoute + rep.Total++ + if ok { + rep.Passed++ + } + rep.Confusion[c.ExpectedRoute][got]++ + + if c.ExpectedRoute != RouteAction && got == RouteAction { + rep.FalseAction++ + } + if c.FastPathResolved { + rep.FastPathTotal++ + if ok { + rep.FastPathPassed++ + } + } else { + rep.ResidualTotal++ + if ok { + rep.ResidualPassed++ + } + } + } + + if rep.Total > 0 { + rep.FalseActionRate = float64(rep.FalseAction) / float64(rep.Total) + } + + // Compute per-route precision/recall/F1. + for _, route := range AllRoutes { + tp, fp, fn := 0, 0, 0 + for _, got := range AllRoutes { + count := rep.Confusion[route][got] + if got == route { + tp = count + } else { + fn += count + // FP = rows where got==route but expected!=route + fp += rep.Confusion[got][route] + } + } + rm := RouteMetrics{TP: tp, FP: fp, FN: fn} + if tp+fp > 0 { + rm.Precision = float64(tp) / float64(tp+fp) + } + if tp+fn > 0 { + rm.Recall = float64(tp) / float64(tp+fn) + } + if rm.Precision+rm.Recall > 0 { + rm.F1 = 2 * rm.Precision * rm.Recall / (rm.Precision + rm.Recall) + } + rm.F1 = math.Round(rm.F1*1000) / 1000 + rm.Precision = math.Round(rm.Precision*1000) / 1000 + rm.Recall = math.Round(rm.Recall*1000) / 1000 + rep.ByRoute[route] = rm + } + + return rep +} + +// MacroF1 returns the macro-averaged F1 across all routes. +func (r EvalReport) MacroF1() float64 { + if len(r.ByRoute) == 0 { + return 0 + } + var sum float64 + for _, rm := range r.ByRoute { + sum += rm.F1 + } + return math.Round(sum/float64(len(r.ByRoute))*1000) / 1000 +} + +// String renders the report as a compact table. +func (r EvalReport) String() string { + var b strings.Builder + fmt.Fprintf(&b, "semantic eval: %d/%d (%.1f%%)\n", r.Passed, r.Total, + 100*float64(r.Passed)/math.Max(float64(r.Total), 1)) + fmt.Fprintf(&b, " macro F1: %.3f\n", r.MacroF1()) + fmt.Fprintf(&b, " false-action: %d/%d (%.3f)\n", r.FalseAction, r.Total, r.FalseActionRate) + if r.FastPathTotal+r.ResidualTotal > 0 { + fmt.Fprintf(&b, " fast-path: %d/%d residual: %d/%d\n", + r.FastPathPassed, r.FastPathTotal, + r.ResidualPassed, r.ResidualTotal) + } + fmt.Fprintf(&b, " per-route:\n") + routes := make([]SemanticRoute, 0, len(r.ByRoute)) + for route := range r.ByRoute { + routes = append(routes, route) + } + sort.Slice(routes, func(i, j int) bool { return routes[i] < routes[j] }) + for _, route := range routes { + rm := r.ByRoute[route] + fmt.Fprintf(&b, " %-15s P=%.3f R=%.3f F1=%.3f (tp=%d fp=%d fn=%d)\n", + string(route), rm.Precision, rm.Recall, rm.F1, rm.TP, rm.FP, rm.FN) + } + fmt.Fprintf(&b, " confusion matrix:\n") + fmt.Fprintf(&b, " %-15s", "") + for _, g := range routes { + fmt.Fprintf(&b, " %10s", string(g)) + } + fmt.Fprintf(&b, "\n") + for _, w := range routes { + fmt.Fprintf(&b, " %-15s", string(w)) + for _, g := range routes { + fmt.Fprintf(&b, " %10d", r.Confusion[w][g]) + } + fmt.Fprintf(&b, "\n") + } + return b.String() +}