chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Token-efficiency benchmark
|
||||
|
||||
Reproducible 3-pipeline comparison of how many tokens an agent has
|
||||
to ingest to find the same information, plus recall@k by token
|
||||
budget. Pipelines:
|
||||
|
||||
1. **ripgrep + full-read** — `rg --files-with-matches <pattern>`
|
||||
followed by reading every hit file completely. The naive
|
||||
shell-script baseline: cheap to set up, expensive to consume.
|
||||
2. **ripgrep + context** — `rg -n -B 50 -A 50 <pattern>`. Captures
|
||||
the surrounding ±50 lines per hit, which is what a more
|
||||
thoughtful agent might do.
|
||||
3. **gortex** — `search_symbols` → `get_symbol_source` on the top-K
|
||||
results. Matches the path the savings dashboard rewards.
|
||||
|
||||
For each query, the harness counts the tiktoken bytes the pipeline
|
||||
returns and computes recall@2k and recall@10k against a hand-curated
|
||||
ground-truth set (per-query expected file paths). The headline
|
||||
median is honest about query mix — NL queries that don't appear
|
||||
verbatim in code show no ripgrep matches and therefore inflate
|
||||
gortex's relative cost. The per-row data shows the real picture:
|
||||
gortex achieves 1.00 recall@2k on identifier queries where ripgrep
|
||||
gets 0.00, because the file the agent actually wants doesn't fit in
|
||||
2k tokens when read whole.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
# Default: against the gortex repo itself
|
||||
go run ./bench/token-efficiency
|
||||
|
||||
# Against a different corpus
|
||||
go run ./bench/token-efficiency --repo ~/code/myrepo \
|
||||
--queries my-queries.json --groundtruth my-truth.json
|
||||
|
||||
# CI gate: gortex median tokens must be <50% of ripgrep+full-read
|
||||
go run ./bench/token-efficiency --strict --budget-ratio 0.5
|
||||
|
||||
# JSON output for downstream tooling
|
||||
go run ./bench/token-efficiency --format json --json bench/results/tokens-eff.json
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
- `-repo PATH` — indexed corpus (default `.`)
|
||||
- `-queries PATH` — JSON query set (default `queries.json` in this
|
||||
directory)
|
||||
- `-groundtruth PATH` — JSON per-query expected file paths (default
|
||||
`groundtruth.json`)
|
||||
- `-top-k N` — gortex pipeline candidate count (default 5)
|
||||
- `-out PATH` — markdown output (default stdout)
|
||||
- `-json PATH` — companion JSON metrics output
|
||||
- `-format markdown|json` — primary output format
|
||||
- `-budget-ratio R` — fail when gortex median tokens > R × ripgrep
|
||||
full-read median (default 0.5; 0 disables)
|
||||
- `-strict` — exit 1 on budget violation
|
||||
- `-skip-ripgrep` — render only the gortex column (useful in CI
|
||||
without rg on PATH)
|
||||
|
||||
## Extending the ground truth
|
||||
|
||||
Each entry in `groundtruth.json` is a query → expected file paths
|
||||
map. The recall computation counts a query as "answered" when the
|
||||
pipeline returns any of the expected files within the token budget.
|
||||
Add new entries by appending to the `queries` map; the harness
|
||||
picks them up on the next run.
|
||||
|
||||
Curation rules:
|
||||
- Expected paths are repo-root-relative (no leading `./`)
|
||||
- A query with no entry in `groundtruth.json` scores 0 on recall by
|
||||
definition — keep the query set and the truth set in sync
|
||||
- Verify entries by running `gortex search_symbols <query>` against
|
||||
the corpus and confirming the top result is in the expected list
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"comment": "Per-query expected file paths (relative to the indexed repo root). A pipeline scores recall@k as |intersection(expected, returned_within_budget)| / |expected|. Curated against the gortex repo at the time the benchmark first ran; extending the bench means adding rows here. Multiple expected paths per query means any subset is a partial hit.",
|
||||
"queries": {
|
||||
"AddObservation": [
|
||||
"internal/savings/store.go"
|
||||
],
|
||||
"IsSymbolQuery": [
|
||||
"internal/search/rerank/query_kind.go"
|
||||
],
|
||||
"FileCoherenceSignal": [
|
||||
"internal/search/rerank/signals_file_coherence.go"
|
||||
],
|
||||
"alphaFuse": [
|
||||
"internal/search/hybrid.go"
|
||||
],
|
||||
"savings dashboard rendering": [
|
||||
"cmd/gortex/savings.go",
|
||||
"internal/savings/events.go"
|
||||
],
|
||||
"rerank pipeline default signals": [
|
||||
"internal/search/rerank/pipeline.go"
|
||||
],
|
||||
"Indexer Index method": [
|
||||
"internal/indexer/indexer.go"
|
||||
],
|
||||
"graph build edges": [
|
||||
"internal/graph/graph.go"
|
||||
],
|
||||
"MCP server start": [
|
||||
"internal/mcp/server.go",
|
||||
"cmd/gortex/mcp.go"
|
||||
],
|
||||
"token counting tiktoken": [
|
||||
"internal/tokens/tokens.go"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Command token-efficiency runs the 3-pipeline token-economy
|
||||
// benchmark: ripgrep+full-read vs ripgrep+context vs gortex
|
||||
// (search_symbols + get_symbol_source). See bench/token-efficiency/
|
||||
// README.md for the contract and the methodology footnote it places
|
||||
// in BENCHMARK.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// benchRow captures one query's outcome across all three pipelines.
|
||||
type benchRow struct {
|
||||
Query string `json:"query"`
|
||||
Expected []string `json:"expected"`
|
||||
RipgrepFull pipelineResult `json:"ripgrep_full"`
|
||||
RipgrepCtx pipelineResult `json:"ripgrep_context"`
|
||||
Gortex pipelineResult `json:"gortex"`
|
||||
// Recall@k by token budget. 2k is the "agent reads two pages of
|
||||
// context" frame; 10k is "agent reads a quarter of a window".
|
||||
RecallAt2kFull float64 `json:"recall_at_2k_full"`
|
||||
RecallAt2kCtx float64 `json:"recall_at_2k_context"`
|
||||
RecallAt2kGortex float64 `json:"recall_at_2k_gortex"`
|
||||
RecallAt10kFull float64 `json:"recall_at_10k_full"`
|
||||
RecallAt10kCtx float64 `json:"recall_at_10k_context"`
|
||||
RecallAt10kGortex float64 `json:"recall_at_10k_gortex"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
repo := flag.String("repo", ".", "indexed repository path (queries run against this corpus)")
|
||||
queriesPath := flag.String("queries", "bench/token-efficiency/queries.json", "JSON query set")
|
||||
truthPath := flag.String("groundtruth", "bench/token-efficiency/groundtruth.json", "JSON per-query expected file paths")
|
||||
out := flag.String("out", "", "markdown output path (default stdout)")
|
||||
jsonOut := flag.String("json", "", "optional JSON metrics output")
|
||||
format := flag.String("format", "markdown", "markdown | json")
|
||||
topK := flag.Int("top-k", 5, "candidates per query for the gortex pipeline")
|
||||
budgetRatio := flag.Float64("budget-ratio", 0.5, "fail when gortex median tokens > budget-ratio × ripgrep+full-read median (0 disables)")
|
||||
strict := flag.Bool("strict", false, "exit 1 when the budget gate trips")
|
||||
skipRipgrep := flag.Bool("skip-ripgrep", false, "skip ripgrep pipelines (e.g. when rg isn't installed)")
|
||||
flag.Parse()
|
||||
|
||||
absRepo, err := filepath.Abs(*repo)
|
||||
if err != nil {
|
||||
die("repo path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(absRepo); err != nil {
|
||||
die("repo path: %v", err)
|
||||
}
|
||||
|
||||
queries, err := loadQueries(*queriesPath)
|
||||
if err != nil {
|
||||
die("queries: %v", err)
|
||||
}
|
||||
truth, err := loadGroundTruth(*truthPath)
|
||||
if err != nil {
|
||||
die("groundtruth: %v", err)
|
||||
}
|
||||
|
||||
// Detect ripgrep up front so we can short-circuit cleanly.
|
||||
rgAvailable := !*skipRipgrep
|
||||
if rgAvailable {
|
||||
if _, err := exec.LookPath("rg"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[token-eff] ripgrep not on PATH; --skip-ripgrep implied\n")
|
||||
rgAvailable = false
|
||||
}
|
||||
}
|
||||
|
||||
// Index the repo once for the gortex pipeline.
|
||||
fmt.Fprintf(os.Stderr, "[token-eff] indexing %s...\n", absRepo)
|
||||
indexed, err := indexRepoForBench(absRepo)
|
||||
if err != nil {
|
||||
die("index: %v", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[token-eff] indexed %d nodes\n", len(indexed.graph.AllNodes()))
|
||||
|
||||
// Run each query across the available pipelines.
|
||||
rows := make([]benchRow, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
row := benchRow{Query: q, Expected: truth[q]}
|
||||
if rgAvailable {
|
||||
row.RipgrepFull = runRipgrepFullRead(absRepo, q)
|
||||
row.RipgrepCtx = runRipgrepContext(absRepo, q)
|
||||
}
|
||||
row.Gortex = runGortex(absRepo, q, indexed, *topK)
|
||||
row.RecallAt2kFull = recallAtBudget(row.RipgrepFull, row.Expected, 2_000)
|
||||
row.RecallAt2kCtx = recallAtBudget(row.RipgrepCtx, row.Expected, 2_000)
|
||||
row.RecallAt2kGortex = recallAtBudget(row.Gortex, row.Expected, 2_000)
|
||||
row.RecallAt10kFull = recallAtBudget(row.RipgrepFull, row.Expected, 10_000)
|
||||
row.RecallAt10kCtx = recallAtBudget(row.RipgrepCtx, row.Expected, 10_000)
|
||||
row.RecallAt10kGortex = recallAtBudget(row.Gortex, row.Expected, 10_000)
|
||||
rows = append(rows, row)
|
||||
fmt.Fprintf(os.Stderr, "[token-eff] %-45s full=%-7d ctx=%-7d gortex=%d\n",
|
||||
q, row.RipgrepFull.Tokens, row.RipgrepCtx.Tokens, row.Gortex.Tokens)
|
||||
}
|
||||
|
||||
// Render primary output.
|
||||
var primary []byte
|
||||
switch strings.ToLower(*format) {
|
||||
case "markdown", "md":
|
||||
primary = []byte(renderMarkdown(rows, rgAvailable))
|
||||
case "json":
|
||||
primary = mustMarshalJSON(rows)
|
||||
default:
|
||||
die("unknown --format %q", *format)
|
||||
}
|
||||
if err := writeOutput(*out, primary); err != nil {
|
||||
die("write primary: %v", err)
|
||||
}
|
||||
|
||||
// Optional companion JSON.
|
||||
if *jsonOut != "" {
|
||||
if err := writeOutput(*jsonOut, mustMarshalJSON(rows)); err != nil {
|
||||
die("write json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Budget gate.
|
||||
if *strict && *budgetRatio > 0 && rgAvailable {
|
||||
medianFull := medianTokensFn(rows, func(r benchRow) int { return r.RipgrepFull.Tokens })
|
||||
medianGortex := medianTokensFn(rows, func(r benchRow) int { return r.Gortex.Tokens })
|
||||
if medianFull > 0 {
|
||||
ratio := float64(medianGortex) / float64(medianFull)
|
||||
if ratio > *budgetRatio {
|
||||
die("budget gate: gortex median tokens (%d) / ripgrep+full-read median (%d) = %.2f > limit %.2f",
|
||||
medianGortex, medianFull, ratio, *budgetRatio)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- rendering ------------------------------------------------------
|
||||
|
||||
// renderMarkdown produces the per-query table plus a summary line.
|
||||
// When ripgrep is unavailable the ripgrep columns render as "—" so a
|
||||
// CI without rg still gets a meaningful gortex-only table.
|
||||
func renderMarkdown(rows []benchRow, rgAvailable bool) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, "# Token-efficiency benchmark")
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, "_Tokens per response (lower is better) and recall@k-by-token-budget for three retrieval pipelines: ripgrep+full-read (naive agent baseline), ripgrep+context (±50 lines around hit), and gortex (search_symbols + get_symbol_source)._")
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, "| query | tokens (rg+full) | tokens (rg+ctx) | tokens (gortex) | recall@2k rg+full / rg+ctx / gortex | recall@10k rg+full / rg+ctx / gortex |")
|
||||
fmt.Fprintln(&b, "|-------|----------------:|----------------:|---------------:|------------------------------------|--------------------------------------|")
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %d | %s / %s / %.2f | %s / %s / %.2f |\n",
|
||||
truncate(r.Query, 38),
|
||||
tokensCell(r.RipgrepFull, rgAvailable),
|
||||
tokensCell(r.RipgrepCtx, rgAvailable),
|
||||
r.Gortex.Tokens,
|
||||
recallCell(r.RecallAt2kFull, rgAvailable),
|
||||
recallCell(r.RecallAt2kCtx, rgAvailable),
|
||||
r.RecallAt2kGortex,
|
||||
recallCell(r.RecallAt10kFull, rgAvailable),
|
||||
recallCell(r.RecallAt10kCtx, rgAvailable),
|
||||
r.RecallAt10kGortex,
|
||||
)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, summaryLine(rows, rgAvailable))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func tokensCell(p pipelineResult, available bool) string {
|
||||
if !available {
|
||||
return "—"
|
||||
}
|
||||
if p.Error != "" {
|
||||
return "ERR"
|
||||
}
|
||||
return fmt.Sprintf("%d", p.Tokens)
|
||||
}
|
||||
|
||||
func recallCell(v float64, available bool) string {
|
||||
if !available {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%.2f", v)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-1] + "…"
|
||||
}
|
||||
|
||||
// summaryLine emits the headline median-tokens + recall figure that
|
||||
// real readers care about. Format:
|
||||
//
|
||||
// "Median tokens: rg+full=X / rg+ctx=Y / gortex=Z (gortex −P%).
|
||||
// Median recall@2k: rg+full=A / rg+ctx=B / gortex=C."
|
||||
func summaryLine(rows []benchRow, rgAvailable bool) string {
|
||||
if len(rows) == 0 {
|
||||
return "_no rows_"
|
||||
}
|
||||
mt := func(f func(benchRow) int) int { return medianTokensFn(rows, f) }
|
||||
mr := func(f func(benchRow) float64) float64 { return medianFloatFn(rows, f) }
|
||||
mFull := mt(func(r benchRow) int { return r.RipgrepFull.Tokens })
|
||||
mCtx := mt(func(r benchRow) int { return r.RipgrepCtx.Tokens })
|
||||
mGortex := mt(func(r benchRow) int { return r.Gortex.Tokens })
|
||||
|
||||
rec2Full := mr(func(r benchRow) float64 { return r.RecallAt2kFull })
|
||||
rec2Ctx := mr(func(r benchRow) float64 { return r.RecallAt2kCtx })
|
||||
rec2Gortex := mr(func(r benchRow) float64 { return r.RecallAt2kGortex })
|
||||
|
||||
if !rgAvailable {
|
||||
return fmt.Sprintf("**Summary (gortex only — rg not installed):** Median tokens %d. Median recall@2k %.2f.",
|
||||
mGortex, rec2Gortex)
|
||||
}
|
||||
ratio := ""
|
||||
if mFull > 0 {
|
||||
delta := (1.0 - float64(mGortex)/float64(mFull)) * 100
|
||||
ratio = fmt.Sprintf(" gortex saves %.1f%% vs rg+full.", delta)
|
||||
}
|
||||
return fmt.Sprintf("**Summary:** Median tokens — rg+full=%d / rg+ctx=%d / gortex=%d.%s Median recall@2k — rg+full=%.2f / rg+ctx=%.2f / gortex=%.2f.",
|
||||
mFull, mCtx, mGortex, ratio,
|
||||
rec2Full, rec2Ctx, rec2Gortex,
|
||||
)
|
||||
}
|
||||
|
||||
// medianTokensFn / medianFloatFn condense per-pipeline values across
|
||||
// rows into the median. Used by both the summary line and the budget
|
||||
// gate so the two stay in lock-step.
|
||||
func medianTokensFn(rows []benchRow, pick func(benchRow) int) int {
|
||||
if len(rows) == 0 {
|
||||
return 0
|
||||
}
|
||||
vs := make([]int, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
vs = append(vs, pick(r))
|
||||
}
|
||||
sort.Ints(vs)
|
||||
return vs[len(vs)/2]
|
||||
}
|
||||
|
||||
func medianFloatFn(rows []benchRow, pick func(benchRow) float64) float64 {
|
||||
if len(rows) == 0 {
|
||||
return 0
|
||||
}
|
||||
vs := make([]float64, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
vs = append(vs, pick(r))
|
||||
}
|
||||
sort.Float64s(vs)
|
||||
return vs[len(vs)/2]
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "token-eff: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func writeOutput(path string, body []byte) error {
|
||||
if path == "" {
|
||||
_, err := os.Stdout.Write(body)
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, body, 0o644)
|
||||
}
|
||||
|
||||
func mustMarshalJSON(rows []benchRow) []byte {
|
||||
b, err := json.MarshalIndent(rows, "", " ")
|
||||
if err != nil {
|
||||
die("marshal json: %v", err)
|
||||
}
|
||||
return append(b, '\n')
|
||||
}
|
||||
|
||||
func loadQueries(path string) ([]string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Queries []string `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return doc.Queries, nil
|
||||
}
|
||||
|
||||
func loadGroundTruth(path string) (map[string][]string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Queries map[string][]string `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if doc.Queries == nil {
|
||||
doc.Queries = map[string][]string{}
|
||||
}
|
||||
return doc.Queries, nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadQueries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "queries.json")
|
||||
body := `{"queries": ["alpha", "BetaSymbol", "find token"]}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadQueries(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 || got[1] != "BetaSymbol" {
|
||||
t.Errorf("got %v, want 3 queries containing BetaSymbol", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGroundTruth(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gt.json")
|
||||
body := `{"queries": {"alpha": ["a.go", "b.go"], "beta": ["c.go"]}}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadGroundTruth(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got["alpha"]) != 2 || got["alpha"][0] != "a.go" {
|
||||
t.Errorf("alpha truth = %v, want [a.go, b.go]", got["alpha"])
|
||||
}
|
||||
if len(got["beta"]) != 1 {
|
||||
t.Errorf("beta truth = %v, want 1 path", got["beta"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGroundTruth_MissingReturnsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gt.json")
|
||||
body := `{}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadGroundTruth(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Errorf("missing queries map should yield empty map, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallAtBudget(t *testing.T) {
|
||||
// Returned [a, b, c] with per-file tokens [500, 800, 1000]
|
||||
// vs expected [a, c]:
|
||||
//
|
||||
// budget 2000 → a (500), b (1300), c (2300 > 2000 → stop)
|
||||
// → hits a only (c excluded) → 1/2 = 0.5
|
||||
// budget 5000 → all included → hits a + c → 2/2 = 1.0
|
||||
// budget 0 → unbounded, all included → 1.0
|
||||
r := pipelineResult{
|
||||
Returned: []string{"a", "b", "c"},
|
||||
PerFileTokens: []int{500, 800, 1000},
|
||||
}
|
||||
expected := []string{"a", "c"}
|
||||
if got := recallAtBudget(r, expected, 2000); got != 0.5 {
|
||||
t.Errorf("recall@2000 = %.2f, want 0.5", got)
|
||||
}
|
||||
if got := recallAtBudget(r, expected, 5000); got != 1.0 {
|
||||
t.Errorf("recall@5000 = %.2f, want 1.0", got)
|
||||
}
|
||||
if got := recallAtBudget(r, expected, 0); got != 1.0 {
|
||||
t.Errorf("recall@0 (unbounded) = %.2f, want 1.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallAtBudget_EmptyExpectedReturnsZero(t *testing.T) {
|
||||
r := pipelineResult{Returned: []string{"x"}, PerFileTokens: []int{10}}
|
||||
if got := recallAtBudget(r, nil, 1000); got != 0 {
|
||||
t.Errorf("empty expected = %.2f, want 0 (no recall to measure)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMedianTokensFn(t *testing.T) {
|
||||
rows := []benchRow{
|
||||
{Gortex: pipelineResult{Tokens: 30}},
|
||||
{Gortex: pipelineResult{Tokens: 10}},
|
||||
{Gortex: pipelineResult{Tokens: 20}},
|
||||
}
|
||||
got := medianTokensFn(rows, func(r benchRow) int { return r.Gortex.Tokens })
|
||||
if got != 20 {
|
||||
t.Errorf("median = %d, want 20", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMedianFloatFn(t *testing.T) {
|
||||
rows := []benchRow{
|
||||
{RecallAt2kGortex: 0.5},
|
||||
{RecallAt2kGortex: 1.0},
|
||||
{RecallAt2kGortex: 0.25},
|
||||
}
|
||||
got := medianFloatFn(rows, func(r benchRow) float64 { return r.RecallAt2kGortex })
|
||||
if got != 0.5 {
|
||||
t.Errorf("median = %.2f, want 0.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdown_PopulatesAllColumns(t *testing.T) {
|
||||
rows := []benchRow{
|
||||
{Query: "AddObservation",
|
||||
Expected: []string{"internal/savings/store.go"},
|
||||
RipgrepFull: pipelineResult{Tokens: 31530, Returned: []string{"internal/savings/store.go"}, PerFileTokens: []int{31530}},
|
||||
RipgrepCtx: pipelineResult{Tokens: 9020, Returned: []string{"internal/savings/store.go"}, PerFileTokens: []int{9020}},
|
||||
Gortex: pipelineResult{Tokens: 972, Returned: []string{"internal/savings/store.go"}, PerFileTokens: []int{972}},
|
||||
RecallAt2kGortex: 1.0,
|
||||
},
|
||||
}
|
||||
md := renderMarkdown(rows, true)
|
||||
for _, want := range []string{
|
||||
"# Token-efficiency benchmark",
|
||||
"AddObservation",
|
||||
"31530",
|
||||
"9020",
|
||||
"972",
|
||||
"1.00",
|
||||
"**Summary:**",
|
||||
"Median tokens",
|
||||
} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("rendered markdown missing %q\n----\n%s", want, md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdown_GortexOnlyMode(t *testing.T) {
|
||||
rows := []benchRow{
|
||||
{Query: "q",
|
||||
Gortex: pipelineResult{Tokens: 100},
|
||||
RecallAt2kGortex: 0.75,
|
||||
},
|
||||
}
|
||||
md := renderMarkdown(rows, false)
|
||||
if !strings.Contains(md, "gortex only — rg not installed") {
|
||||
t.Errorf("gortex-only summary missing the rg-unavailable note:\n%s", md)
|
||||
}
|
||||
if !strings.Contains(md, "—") {
|
||||
t.Errorf("rg columns should render as — when rg not available:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustMarshalJSON_RoundTrip(t *testing.T) {
|
||||
rows := []benchRow{
|
||||
{Query: "q", Gortex: pipelineResult{Tokens: 100, Returned: []string{"a.go"}}, RecallAt2kGortex: 0.5},
|
||||
}
|
||||
raw := mustMarshalJSON(rows)
|
||||
var got []benchRow
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("round-trip: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Query != "q" || got[0].RecallAt2kGortex != 0.5 {
|
||||
t.Errorf("round-trip lost data: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := truncate("short", 10); got != "short" {
|
||||
t.Errorf("short string unchanged: got %q", got)
|
||||
}
|
||||
if got := truncate("this is a very long query string", 10); got != "this is a…" {
|
||||
t.Errorf("long string truncated: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"comment": "Query set for the token-efficiency benchmark. Each query is paired with expected target file paths in groundtruth.json. Mix of identifier-shape and natural-language queries so the headline median reflects realistic agent traffic, not just one shape.",
|
||||
"queries": [
|
||||
"AddObservation",
|
||||
"IsSymbolQuery",
|
||||
"FileCoherenceSignal",
|
||||
"alphaFuse",
|
||||
"savings dashboard rendering",
|
||||
"rerank pipeline default signals",
|
||||
"Indexer Index method",
|
||||
"graph build edges",
|
||||
"MCP server start",
|
||||
"token counting tiktoken"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// runner.go — per-query pipeline runners for the token-efficiency
|
||||
// benchmark. Three pipelines, all measured against the same indexed
|
||||
// repo:
|
||||
//
|
||||
// - ripgrep+full-read: rg --files-with-matches → cat each hit
|
||||
// - ripgrep+context: rg -n -B50 -A50 → use the printed context
|
||||
// - gortex: search_symbols → get_symbol_source on the
|
||||
// top result(s); represents the agent path
|
||||
// the savings dashboard rewards
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/zzet/gortex/internal/config"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/indexer"
|
||||
"github.com/zzet/gortex/internal/parser"
|
||||
"github.com/zzet/gortex/internal/parser/languages"
|
||||
"github.com/zzet/gortex/internal/query"
|
||||
"github.com/zzet/gortex/internal/tokens"
|
||||
)
|
||||
|
||||
// pipelineResult is the per-query outcome of one pipeline.
|
||||
type pipelineResult struct {
|
||||
// Returned is the ordered list of file paths the pipeline
|
||||
// considered relevant. Used for recall computation against
|
||||
// ground truth (which is keyed on file paths to keep the
|
||||
// comparison cross-pipeline fair — ripgrep doesn't see symbols).
|
||||
Returned []string `json:"returned"`
|
||||
// Tokens is the cumulative tiktoken count of the response
|
||||
// content the pipeline produced (the bytes a real agent would
|
||||
// have to ingest).
|
||||
Tokens int `json:"tokens"`
|
||||
// PerFileTokens lets the recall@k-by-budget calculator walk the
|
||||
// returned list in order, accumulating tokens until the budget
|
||||
// is exhausted. Aligned with Returned by index.
|
||||
PerFileTokens []int `json:"per_file_tokens"`
|
||||
// Error captures pipeline failures (e.g. ripgrep missing,
|
||||
// indexing failed). Empty string on success.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// runRipgrepFullRead executes `rg --files-with-matches` against the
|
||||
// repo, then reads each hit file fully. Mirrors the naive agent
|
||||
// strategy "grep for foo, then read every file that matches".
|
||||
func runRipgrepFullRead(repoRoot, query string) pipelineResult {
|
||||
files, err := ripgrepFilesWithMatches(repoRoot, query)
|
||||
if err != nil {
|
||||
return pipelineResult{Error: err.Error()}
|
||||
}
|
||||
out := pipelineResult{Returned: files, PerFileTokens: make([]int, 0, len(files))}
|
||||
for _, f := range files {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot, f))
|
||||
if err != nil {
|
||||
out.PerFileTokens = append(out.PerFileTokens, 0)
|
||||
continue
|
||||
}
|
||||
n := tokens.Count(string(body))
|
||||
out.Tokens += n
|
||||
out.PerFileTokens = append(out.PerFileTokens, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runRipgrepContext executes `rg -n -B 50 -A 50 <pattern>` and
|
||||
// counts the bytes the surrounding context produces. More realistic
|
||||
// than full-read because real grep-driven agents don't usually read
|
||||
// every byte of every file.
|
||||
func runRipgrepContext(repoRoot, query string) pipelineResult {
|
||||
cmd := exec.Command("rg", "-n", "-B", "50", "-A", "50", "--no-heading", query, repoRoot)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = io.Discard
|
||||
err := cmd.Run()
|
||||
// rg exit code 1 = no matches (not an error for our purposes).
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
|
||||
return pipelineResult{}
|
||||
}
|
||||
return pipelineResult{Error: err.Error()}
|
||||
}
|
||||
// Parse the output: rg prints one line per match with the file
|
||||
// prefix "<path>:<line>:<text>". Group lines by file so the
|
||||
// recall computation sees one returned-entry per file.
|
||||
files := map[string]int{}
|
||||
order := []string{}
|
||||
for line := range strings.SplitSeq(buf.String(), "\n") {
|
||||
idx := strings.Index(line, ":")
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
path := line[:idx]
|
||||
// rg with an absolute repoRoot prints absolute paths —
|
||||
// strip the prefix so recall comparison sees the same
|
||||
// shape as ripgrep --files-with-matches.
|
||||
path = strings.TrimPrefix(path, repoRoot+"/")
|
||||
if _, seen := files[path]; !seen {
|
||||
order = append(order, path)
|
||||
}
|
||||
files[path] += tokens.Count(line + "\n")
|
||||
}
|
||||
out := pipelineResult{Returned: order, PerFileTokens: make([]int, 0, len(order))}
|
||||
for _, p := range order {
|
||||
out.Tokens += files[p]
|
||||
out.PerFileTokens = append(out.PerFileTokens, files[p])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runGortex indexes the repo, runs search_symbols, and reads the
|
||||
// source of the top-K results — the path the savings dashboard
|
||||
// rewards.
|
||||
func runGortex(repoRoot, q string, indexedRepo *indexedRepo, topK int) pipelineResult {
|
||||
if indexedRepo == nil || indexedRepo.engine == nil {
|
||||
return pipelineResult{Error: "gortex repo not indexed"}
|
||||
}
|
||||
nodes := indexedRepo.engine.SearchSymbols(q, topK)
|
||||
out := pipelineResult{Returned: make([]string, 0, len(nodes)), PerFileTokens: make([]int, 0, len(nodes))}
|
||||
seen := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
// Returned uses the file path so the recall comparison
|
||||
// works against the file-path ground truth (same axis as
|
||||
// ripgrep).
|
||||
fp := n.FilePath
|
||||
if fp == "" || seen[fp] {
|
||||
continue
|
||||
}
|
||||
seen[fp] = true
|
||||
// Read the symbol's lines from disk so the token count
|
||||
// matches what an agent would ingest via get_symbol_source.
|
||||
body, err := readSymbolSource(repoRoot, n)
|
||||
if err != nil {
|
||||
out.PerFileTokens = append(out.PerFileTokens, 0)
|
||||
out.Returned = append(out.Returned, fp)
|
||||
continue
|
||||
}
|
||||
n := tokens.Count(body)
|
||||
out.Tokens += n
|
||||
out.PerFileTokens = append(out.PerFileTokens, n)
|
||||
out.Returned = append(out.Returned, fp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// indexedRepo bundles a graph + engine for the gortex pipeline so we
|
||||
// pay the index cost once per repo regardless of how many queries
|
||||
// run.
|
||||
type indexedRepo struct {
|
||||
graph *graph.Graph
|
||||
engine *query.Engine
|
||||
}
|
||||
|
||||
// indexRepoForBench builds a fresh gortex index of the repo. Same
|
||||
// shape as the reference-repo perf bench: stderr-logger, real
|
||||
// parser registry, no extras.
|
||||
func indexRepoForBench(root string) (*indexedRepo, error) {
|
||||
g := graph.New()
|
||||
reg := parser.NewRegistry()
|
||||
languages.RegisterAll(reg)
|
||||
cfg := config.Config{}
|
||||
idx := indexer.New(g, reg, cfg.Index, zap.NewNop())
|
||||
if _, err := idx.Index(root); err != nil {
|
||||
return nil, fmt.Errorf("index %s: %w", root, err)
|
||||
}
|
||||
eng := query.NewEngine(g)
|
||||
eng.SetSearch(idx.Search())
|
||||
return &indexedRepo{graph: g, engine: eng}, nil
|
||||
}
|
||||
|
||||
// ripgrepFilesWithMatches runs `rg --files-with-matches <pattern>` and
|
||||
// returns the matched paths relative to repoRoot.
|
||||
func ripgrepFilesWithMatches(repoRoot, pattern string) ([]string, error) {
|
||||
cmd := exec.Command("rg", "--files-with-matches", pattern, repoRoot)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = io.Discard
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
|
||||
// No matches — clean exit, empty result.
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("rg: %w", err)
|
||||
}
|
||||
var out []string
|
||||
for line := range strings.SplitSeq(buf.String(), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
rel := strings.TrimPrefix(line, repoRoot+"/")
|
||||
out = append(out, rel)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readSymbolSource extracts the symbol's line range from its file.
|
||||
// Falls back to the whole file when StartLine/EndLine aren't set —
|
||||
// matches the fallback shape the MCP tool would have to produce.
|
||||
func readSymbolSource(repoRoot string, n *graph.Node) (string, error) {
|
||||
if n == nil || n.FilePath == "" {
|
||||
return "", fmt.Errorf("no path")
|
||||
}
|
||||
path := n.FilePath
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(repoRoot, path)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n.StartLine <= 0 || n.EndLine <= 0 || n.EndLine < n.StartLine {
|
||||
return string(body), nil
|
||||
}
|
||||
lines := strings.Split(string(body), "\n")
|
||||
start := n.StartLine - 1
|
||||
end := n.EndLine
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
if start >= end {
|
||||
return "", nil
|
||||
}
|
||||
return strings.Join(lines[start:end], "\n"), nil
|
||||
}
|
||||
|
||||
// recallAtBudget walks the pipeline's returned list in order,
|
||||
// accumulating tokens until budget is exhausted, then computes
|
||||
// |intersection(expected, returned_within_budget)| / |expected|.
|
||||
// Returns 0 when expected is empty (no ground truth = no recall to
|
||||
// measure, not 100%).
|
||||
func recallAtBudget(r pipelineResult, expected []string, budgetTokens int) float64 {
|
||||
if len(expected) == 0 {
|
||||
return 0
|
||||
}
|
||||
exp := map[string]bool{}
|
||||
for _, e := range expected {
|
||||
exp[e] = true
|
||||
}
|
||||
cumulative := 0
|
||||
hits := 0
|
||||
for i, ret := range r.Returned {
|
||||
if i >= len(r.PerFileTokens) {
|
||||
break
|
||||
}
|
||||
next := cumulative + r.PerFileTokens[i]
|
||||
if budgetTokens > 0 && next > budgetTokens {
|
||||
// Including this entry would blow the budget; stop.
|
||||
break
|
||||
}
|
||||
cumulative = next
|
||||
if exp[ret] {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
return float64(hits) / float64(len(expected))
|
||||
}
|
||||
Reference in New Issue
Block a user