a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
382 lines
12 KiB
Go
382 lines
12 KiB
Go
// Package recall computes retrieval recall@K, latency, and token-return
|
|
// metrics for the Gortex retrieval stack against a curated fixture of
|
|
// {query, expected_ids} pairs.
|
|
//
|
|
// # Methodology
|
|
//
|
|
// Recall is reported as any-hit set-level recall: a retrieval counts as
|
|
// correct at rank K if *any* of the Expected IDs for a case appears in
|
|
// the ranker's top-K results. Multiple Expected IDs per case are OK —
|
|
// they represent valid alternative targets (e.g. a type and its
|
|
// constructor both being reasonable answers to "BM25 backend").
|
|
//
|
|
// Cases are tiered so per-tier weakness is visible:
|
|
//
|
|
// - exact: symbol-name queries. Tests the basic "can you find a
|
|
// named symbol I already know about" case. BM25 should
|
|
// dominate here; a retrieval tool that can't ace exact
|
|
// tier is broken.
|
|
// - concept: natural-language paraphrase queries. Tests semantic
|
|
// understanding. This is where BM25 starts losing to
|
|
// semantic / RRF.
|
|
// - multi_hop: relational queries accepting several valid expected
|
|
// IDs (any-hit). Tests graph-aware retrieval.
|
|
//
|
|
// # Gold vs judged
|
|
//
|
|
// All fixture labels in this package are hand-curated gold labels, not
|
|
// LLM-judged. This is a stricter measurement than a dual-judge setup
|
|
// (CQS style) — the numbers you get here are lower than what a
|
|
// per-query LLM judge would award, because paraphrased-but-still-correct
|
|
// ranker output is scored as a miss unless it matches the gold ID.
|
|
//
|
|
// Used by the `gortex eval recall` CLI verb (roadmap I6).
|
|
package recall
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Tier classifies a case by query difficulty.
|
|
type Tier string
|
|
|
|
const (
|
|
TierExact Tier = "exact"
|
|
TierConcept Tier = "concept"
|
|
TierMultiHop Tier = "multi_hop"
|
|
)
|
|
|
|
// Ranker names a retrieval strategy used in the output report.
|
|
type Ranker struct {
|
|
Name string
|
|
// Search returns a ranked list of symbol IDs for the query.
|
|
Search func(query string, limit int) []string
|
|
}
|
|
|
|
// Case is a single fixture entry.
|
|
type Case struct {
|
|
ID string `yaml:"id" json:"id,omitempty"`
|
|
Tier Tier `yaml:"tier" json:"tier"`
|
|
Query string `yaml:"query" json:"query"`
|
|
Expected []string `yaml:"expected" json:"expected"`
|
|
// WinnowConstraints is passed through to winnow-style rankers so a
|
|
// case can pre-filter the candidate pool (e.g. {"language": "go"}).
|
|
WinnowConstraints map[string]any `yaml:"winnow_constraints,omitempty" json:"winnow_constraints,omitempty"`
|
|
}
|
|
|
|
// Fixture is a collection of cases plus metadata.
|
|
type Fixture struct {
|
|
Name string `yaml:"name" json:"name"`
|
|
Cases []Case `yaml:"cases" json:"cases"`
|
|
}
|
|
|
|
// Ks lists the rank cutoffs the report computes.
|
|
var Ks = []int{1, 5, 20}
|
|
|
|
// TokenCounter returns the tokenized length of an arbitrary string.
|
|
// Typically wired to tokens.Count (tiktoken cl100k_base) so the number
|
|
// agents actually pay is reported.
|
|
type TokenCounter func(string) int
|
|
|
|
// defaultTokenCounter falls back to a rough chars/4 estimate when no
|
|
// real counter is wired.
|
|
func defaultTokenCounter(s string) int { return (len(s) + 3) / 4 }
|
|
|
|
// RankerResult holds aggregated metrics for a single ranker over a
|
|
// fixture run. All maps are keyed by k or tier.
|
|
type RankerResult struct {
|
|
Name string `json:"name"`
|
|
Cases int `json:"cases"`
|
|
Hits map[int]int `json:"hits"`
|
|
Recall map[int]float64 `json:"recall"`
|
|
MeanRRank float64 `json:"mean_reciprocal_rank"`
|
|
PerTier map[Tier]TierMetrics `json:"per_tier,omitempty"`
|
|
P50Micros int64 `json:"p50_micros"`
|
|
P95Micros int64 `json:"p95_micros"`
|
|
MaxMicros int64 `json:"max_micros"`
|
|
MeanTokens float64 `json:"mean_tokens_returned"`
|
|
// Misses records cases where none of Expected appeared in top-K
|
|
// at the biggest K in Ks. Populated for diagnostics / judge mode.
|
|
Misses []Miss `json:"misses,omitempty"`
|
|
// Note: skipped is true when the ranker was registered but had no
|
|
// usable backend (e.g. semantic ranker with no embedder). Zero-
|
|
// filled results help keep the report table shape stable.
|
|
Skipped string `json:"skipped,omitempty"`
|
|
}
|
|
|
|
// Miss is a single per-case diagnostic row for when a ranker didn't
|
|
// surface any expected ID in top-K. Rank holds the 1-based position of
|
|
// the first-hit expected ID, or 0 when no expected ID appeared at all
|
|
// — lets callers distinguish "found at position 3" from "never found".
|
|
type Miss struct {
|
|
CaseID string `json:"case_id"`
|
|
Query string `json:"query"`
|
|
Expected []string `json:"expected"`
|
|
Top []string `json:"top"` // top-K IDs the ranker returned
|
|
Rank int `json:"rank,omitempty"`
|
|
// JudgedHit is populated by a post-hoc LLM-judge pass: true when
|
|
// the judge accepts any entry in Top as answering the Query. Nil
|
|
// pointer means "not judged."
|
|
JudgedHit *bool `json:"judged_hit,omitempty"`
|
|
}
|
|
|
|
// TierMetrics is a per-tier recall slice.
|
|
type TierMetrics struct {
|
|
Cases int `json:"cases"`
|
|
Hits map[int]int `json:"hits"`
|
|
Recall map[int]float64 `json:"recall"`
|
|
}
|
|
|
|
// Report bundles the results of a fixture run across every ranker.
|
|
type Report struct {
|
|
Fixture string `json:"fixture"`
|
|
Cases int `json:"cases"`
|
|
PerTier map[Tier]int `json:"per_tier_cases"`
|
|
Rankers []RankerResult `json:"rankers"`
|
|
GortexRev string `json:"gortex_rev,omitempty"`
|
|
}
|
|
|
|
// Run evaluates every ranker against every case in the fixture and
|
|
// returns an aggregated report. Limits retrieval to the largest K in Ks.
|
|
func Run(fixture Fixture, rankers []Ranker, tc TokenCounter) Report {
|
|
if tc == nil {
|
|
tc = defaultTokenCounter
|
|
}
|
|
|
|
maxK := 0
|
|
for _, k := range Ks {
|
|
if k > maxK {
|
|
maxK = k
|
|
}
|
|
}
|
|
|
|
report := Report{
|
|
Fixture: fixture.Name,
|
|
Cases: len(fixture.Cases),
|
|
PerTier: make(map[Tier]int),
|
|
}
|
|
for _, c := range fixture.Cases {
|
|
report.PerTier[c.Tier]++
|
|
}
|
|
|
|
for _, r := range rankers {
|
|
report.Rankers = append(report.Rankers, evalOne(fixture, r, tc, maxK))
|
|
}
|
|
return report
|
|
}
|
|
|
|
// evalOne runs a single ranker across the fixture and aggregates.
|
|
func evalOne(fixture Fixture, r Ranker, tc TokenCounter, maxK int) RankerResult {
|
|
res := RankerResult{
|
|
Name: r.Name,
|
|
Cases: len(fixture.Cases),
|
|
Hits: make(map[int]int, len(Ks)),
|
|
Recall: make(map[int]float64, len(Ks)),
|
|
PerTier: make(map[Tier]TierMetrics),
|
|
}
|
|
// Initialise per-tier buckets so the JSON shape is stable. The stock
|
|
// tiers are always included for schema consistency; any additional
|
|
// tier seen in the fixture (e.g. "di") is added on the fly so the
|
|
// harness doesn't panic when fixtures grow new categories.
|
|
for _, tier := range []Tier{TierExact, TierConcept, TierMultiHop} {
|
|
res.PerTier[tier] = TierMetrics{
|
|
Hits: make(map[int]int, len(Ks)),
|
|
Recall: make(map[int]float64, len(Ks)),
|
|
}
|
|
}
|
|
for _, c := range fixture.Cases {
|
|
if _, ok := res.PerTier[c.Tier]; c.Tier != "" && !ok {
|
|
res.PerTier[c.Tier] = TierMetrics{
|
|
Hits: make(map[int]int, len(Ks)),
|
|
Recall: make(map[int]float64, len(Ks)),
|
|
}
|
|
}
|
|
}
|
|
|
|
var rrSum, tokensSum float64
|
|
latencies := make([]time.Duration, 0, len(fixture.Cases))
|
|
|
|
for _, c := range fixture.Cases {
|
|
expected := stringSet(c.Expected)
|
|
|
|
start := time.Now()
|
|
ranked := r.Search(c.Query, maxK)
|
|
elapsed := time.Since(start)
|
|
latencies = append(latencies, elapsed)
|
|
|
|
// Token-return metric: tokenize the ranked ID list as a proxy
|
|
// for "how many tokens does this ranker's top-K cost to return?"
|
|
// Gives a rough Pareto knob (recall vs cost).
|
|
var payload string
|
|
for _, id := range ranked {
|
|
payload += id + "\n"
|
|
}
|
|
tokensSum += float64(tc(payload))
|
|
|
|
firstHit := -1
|
|
for i, id := range ranked {
|
|
if expected[id] {
|
|
firstHit = i
|
|
break
|
|
}
|
|
}
|
|
for _, k := range Ks {
|
|
if firstHit >= 0 && firstHit < k {
|
|
res.Hits[k]++
|
|
}
|
|
}
|
|
if firstHit >= 0 {
|
|
rrSum += 1.0 / float64(firstHit+1)
|
|
}
|
|
// Capture a row when the case missed outright OR landed outside
|
|
// the top slot. Rank=0 means "no expected ID in top-K at all"
|
|
// (the hard miss we've always tracked); Rank>=2 means "found
|
|
// but not at rank 1" — surfaces ordering bugs the aggregate
|
|
// R@1 number hides.
|
|
if firstHit < 0 || firstHit > 0 {
|
|
topCopy := append([]string(nil), ranked...)
|
|
expCopy := append([]string(nil), c.Expected...)
|
|
rank := 0
|
|
if firstHit >= 0 {
|
|
rank = firstHit + 1
|
|
}
|
|
res.Misses = append(res.Misses, Miss{
|
|
CaseID: c.ID,
|
|
Query: c.Query,
|
|
Expected: expCopy,
|
|
Top: topCopy,
|
|
Rank: rank,
|
|
})
|
|
}
|
|
|
|
// Per-tier bookkeeping.
|
|
tier := c.Tier
|
|
if tier == "" {
|
|
tier = TierExact
|
|
}
|
|
tm := res.PerTier[tier]
|
|
tm.Cases++
|
|
for _, k := range Ks {
|
|
if firstHit >= 0 && firstHit < k {
|
|
tm.Hits[k]++
|
|
}
|
|
}
|
|
res.PerTier[tier] = tm
|
|
}
|
|
|
|
if res.Cases > 0 {
|
|
for _, k := range Ks {
|
|
res.Recall[k] = float64(res.Hits[k]) / float64(res.Cases)
|
|
}
|
|
res.MeanRRank = rrSum / float64(res.Cases)
|
|
res.MeanTokens = tokensSum / float64(res.Cases)
|
|
}
|
|
for tier, tm := range res.PerTier {
|
|
if tm.Cases > 0 {
|
|
for _, k := range Ks {
|
|
tm.Recall[k] = float64(tm.Hits[k]) / float64(tm.Cases)
|
|
}
|
|
}
|
|
res.PerTier[tier] = tm
|
|
}
|
|
|
|
res.P50Micros, res.P95Micros, res.MaxMicros = latencyPercentiles(latencies)
|
|
return res
|
|
}
|
|
|
|
// latencyPercentiles sorts a copy and returns p50/p95/max in microseconds.
|
|
func latencyPercentiles(lats []time.Duration) (int64, int64, int64) {
|
|
if len(lats) == 0 {
|
|
return 0, 0, 0
|
|
}
|
|
sorted := append([]time.Duration(nil), lats...)
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
|
p := func(pct float64) time.Duration {
|
|
idx := int(float64(len(sorted)-1) * pct)
|
|
return sorted[idx]
|
|
}
|
|
toMicros := func(d time.Duration) int64 { return d.Microseconds() }
|
|
return toMicros(p(0.50)), toMicros(p(0.95)), toMicros(sorted[len(sorted)-1])
|
|
}
|
|
|
|
// stringSet turns a slice into a membership map.
|
|
func stringSet(s []string) map[string]bool {
|
|
m := make(map[string]bool, len(s))
|
|
for _, v := range s {
|
|
m[v] = true
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Markdown renders the report as a Markdown document. Stable ordering so
|
|
// the output is diffable across runs.
|
|
func Markdown(report Report) string {
|
|
var b strings.Builder
|
|
b.WriteString("# Gortex retrieval recall\n\n")
|
|
if report.GortexRev != "" {
|
|
fmt.Fprintf(&b, "_Fixture: `%s` · rev: `%s` · %d cases_\n\n",
|
|
report.Fixture, report.GortexRev, report.Cases)
|
|
} else {
|
|
fmt.Fprintf(&b, "_Fixture: `%s` · %d cases_\n\n",
|
|
report.Fixture, report.Cases)
|
|
}
|
|
if len(report.PerTier) > 0 {
|
|
tierKeys := make([]Tier, 0, len(report.PerTier))
|
|
for t := range report.PerTier {
|
|
tierKeys = append(tierKeys, t)
|
|
}
|
|
sort.Slice(tierKeys, func(i, j int) bool { return tierKeys[i] < tierKeys[j] })
|
|
b.WriteString("Tier distribution:")
|
|
for _, t := range tierKeys {
|
|
fmt.Fprintf(&b, " `%s`=%d", t, report.PerTier[t])
|
|
}
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
b.WriteString("## Overall\n\n")
|
|
b.WriteString("| ranker | R@1 | R@5 | R@20 | MRR | p50 µs | p95 µs | tokens/q |\n")
|
|
b.WriteString("|--------|-----|-----|------|-----|--------|--------|----------|\n")
|
|
|
|
rankers := append([]RankerResult(nil), report.Rankers...)
|
|
sort.Slice(rankers, func(i, j int) bool { return rankers[i].Name < rankers[j].Name })
|
|
for _, r := range rankers {
|
|
if r.Skipped != "" {
|
|
fmt.Fprintf(&b, "| %s | — | — | — | — | — | — | — (skipped: %s) |\n", r.Name, r.Skipped)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "| %s | %s | %s | %s | %.3f | %d | %d | %.0f |\n",
|
|
r.Name,
|
|
pct(r.Recall[1]),
|
|
pct(r.Recall[5]),
|
|
pct(r.Recall[20]),
|
|
r.MeanRRank,
|
|
r.P50Micros,
|
|
r.P95Micros,
|
|
r.MeanTokens,
|
|
)
|
|
}
|
|
|
|
b.WriteString("\n## Per tier (R@5)\n\n")
|
|
b.WriteString("| ranker | exact | concept | multi_hop |\n")
|
|
b.WriteString("|--------|-------|---------|-----------|\n")
|
|
for _, r := range rankers {
|
|
if r.Skipped != "" {
|
|
fmt.Fprintf(&b, "| %s | — | — | — |\n", r.Name)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
|
|
r.Name,
|
|
pct(r.PerTier[TierExact].Recall[5]),
|
|
pct(r.PerTier[TierConcept].Recall[5]),
|
|
pct(r.PerTier[TierMultiHop].Recall[5]),
|
|
)
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func pct(f float64) string { return fmt.Sprintf("%5.1f%%", f*100) }
|