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, "| <change> | %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))
}