package main import ( "fmt" "io" "sort" "strings" "text/tabwriter" "harness/internal/llm" ) // report prints the numbers METRICS.md wants: the headline pass rate, and // enough per-task and per-tool detail to tell which change caused a move. func report(w io.Writer, results []Result) { if len(results) == 0 { return } fmt.Fprintln(w, "\n"+strings.Repeat("─", 78)) byTask := map[string][]Result{} var order []string for _, r := range results { if _, seen := byTask[r.Task]; !seen { order = append(order, r.Task) } byTask[r.Task] = append(byTask[r.Task], r) } sort.Strings(order) tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "TASK\tPASS\tTURNS\tIN\tOUT\tCACHED\tWALL") for _, name := range order { rs := byTask[name] fmt.Fprintf(tw, "%s\t%d/%d\t%.1f\t%.0f\t%.0f\t%.0f\t%.1fs\n", name, countPass(rs), len(rs), mean(rs, func(r Result) float64 { return float64(r.Turns) }), mean(rs, func(r Result) float64 { return float64(r.Usage.InputTokens) }), mean(rs, func(r Result) float64 { return float64(r.Usage.OutputTokens) }), mean(rs, func(r Result) float64 { return float64(r.Usage.CacheReadInputTokens) }), mean(rs, func(r Result) float64 { return float64(r.WallMS) / 1000 })) } tw.Flush() // A task counts as solved if every repeat passed; anything less is a task // the harness cannot do reliably, which is the number worth reporting. var solved int for _, name := range order { if countPass(byTask[name]) == len(byTask[name]) { solved++ } } fmt.Fprintln(w, strings.Repeat("─", 78)) calls, fails := toolStats(results) fmt.Fprintf(w, "pass rate %d/%d tasks solved on every run (%d/%d runs passed)\n", solved, len(order), countPass(results), len(results)) fmt.Fprintf(w, "edit success %s\n", rate(calls["edit_file"]-fails["edit_file"], calls["edit_file"])) fmt.Fprintf(w, "mean turns %.1f\n", mean(results, func(r Result) float64 { return float64(r.Turns) })) fmt.Fprintf(w, "mean tokens %.0f in (%.0f cached), %.0f out\n", mean(results, func(r Result) float64 { return float64(r.Usage.InputTokens) }), mean(results, func(r Result) float64 { return float64(r.Usage.CacheReadInputTokens) }), mean(results, func(r Result) float64 { return float64(r.Usage.OutputTokens) })) fmt.Fprintf(w, "mean cost $%.4f\n", mean(results, func(r Result) float64 { return cost(r.Usage) })) fmt.Fprintf(w, "mean wall %.1fs\n", mean(results, func(r Result) float64 { return float64(r.WallMS) / 1000 })) fmt.Fprintln(w, "\ntool calls (failures in brackets)") tw = tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) var names []string for name := range calls { names = append(names, name) } sort.Strings(names) for _, name := range names { fmt.Fprintf(tw, " %s\t%d\t[%d]\t%s ok\n", name, calls[name], fails[name], rate(calls[name]-fails[name], calls[name])) } tw.Flush() fmt.Fprintln(w, "\nMETRICS.md row:") fmt.Fprintf(w, "| | %d/%d | %s | %.1f | $%.4f |\n", solved, len(order), rate(calls["edit_file"]-fails["edit_file"], calls["edit_file"]), mean(results, func(r Result) float64 { return float64(r.Turns) }), mean(results, func(r Result) float64 { return cost(r.Usage) })) } // Per-MTok rates for Claude Opus 5. Cache reads and writes use the standard // 0.1x and 1.25x multipliers. Override these if you are running another model. const ( inputPerMTok = 5.0 outputPerMTok = 25.0 cacheReadPerMTok = inputPerMTok * 0.1 cacheWritePerMTok = inputPerMTok * 1.25 ) func cost(u llm.Usage) float64 { return (float64(u.InputTokens)*inputPerMTok + float64(u.OutputTokens)*outputPerMTok + float64(u.CacheReadInputTokens)*cacheReadPerMTok + float64(u.CacheCreationInputTokens)*cacheWritePerMTok) / 1_000_000 } func toolStats(results []Result) (calls, fails map[string]int) { calls, fails = map[string]int{}, map[string]int{} for _, r := range results { for _, ev := range r.Events { if ev.Type != "tool_call" { continue } calls[ev.Tool]++ if ev.OK != nil && !*ev.OK { fails[ev.Tool]++ } } } return calls, fails } func countPass(rs []Result) int { var n int for _, r := range rs { if r.Pass { n++ } } return n } func mean(rs []Result, f func(Result) float64) float64 { if len(rs) == 0 { return 0 } var sum float64 for _, r := range rs { sum += f(r) } return sum / float64(len(rs)) } func rate(ok, total int) string { if total == 0 { return "n/a" } return fmt.Sprintf("%.0f%%", 100*float64(ok)/float64(total)) }