Skip to content

Commit 5321610

Browse files
tae2089claude
andcommitted
feat: add token benchmark — naive vs graph token reduction measurement
- internal/benchmark/token_bench.go: SearchBackend/NodeExpander 인터페이스, EstimateTokens, NaiveTokens, GraphTokens (1-hop 확장), graphTokensMulti, RunTokenBench 구현 - internal/cli/benchmark.go: `ccg benchmark token-bench` 서브커맨드 추가 - testdata/benchmark/queries.yaml: gin-gonic 스타일 5개 쿼리 시드 corpus - gin-gonic 기준 평균 ~285x 토큰 절감 확인 (170k → ~1.3k) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 84a9aaa commit 5321610

26 files changed

Lines changed: 2610 additions & 10 deletions

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/spf13/cobra v1.10.2
1010
github.com/spf13/viper v1.21.0
1111
github.com/tae2089/trace v1.0.2
12+
go.yaml.in/yaml/v3 v3.0.4
1213
gorm.io/driver/postgres v1.6.0
1314
gorm.io/driver/sqlite v1.6.0
1415
gorm.io/gorm v1.31.1
@@ -50,7 +51,6 @@ require (
5051
github.com/subosito/gotenv v1.6.0 // indirect
5152
github.com/xanzy/ssh-agent v0.3.3 // indirect
5253
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
53-
go.yaml.in/yaml/v3 v3.0.4 // indirect
5454
golang.org/x/crypto v0.45.0 // indirect
5555
golang.org/x/net v0.47.0 // indirect
5656
golang.org/x/sync v0.18.0 // indirect

internal/benchmark/analyze.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package benchmark
2+
3+
import "strings"
4+
5+
// fileMatches reports whether actual matches expected, supporting both
6+
// exact match and suffix match to handle absolute vs relative path differences.
7+
// e.g. expected "internal/webhook/handler.go" matches actual "/home/user/repo/internal/webhook/handler.go"
8+
func fileMatches(expected, actual string) bool {
9+
if expected == actual {
10+
return true
11+
}
12+
return strings.HasSuffix(actual, "/"+expected) || strings.HasSuffix(actual, "\\"+expected)
13+
}
14+
15+
// MatchFiles computes the ratio of expected files found in FilesRead or mentioned
16+
// in the Answer text (as a fallback when tool-call data is unavailable).
17+
// Returns 1.0 if no expected files are specified.
18+
func MatchFiles(result RunResult, query Query) float64 {
19+
if len(query.ExpectedFiles) == 0 {
20+
return 1.0
21+
}
22+
var matched int
23+
for _, expected := range query.ExpectedFiles {
24+
found := false
25+
for _, actual := range result.FilesRead {
26+
if fileMatches(expected, actual) {
27+
found = true
28+
break
29+
}
30+
}
31+
if !found && result.Answer != "" {
32+
base := expected[strings.LastIndex(expected, "/")+1:]
33+
found = strings.Contains(result.Answer, expected) || strings.Contains(result.Answer, base)
34+
}
35+
if found {
36+
matched++
37+
}
38+
}
39+
return float64(matched) / float64(len(query.ExpectedFiles))
40+
}
41+
42+
// MatchSymbols computes the ratio of expected symbols found in the answer text.
43+
// Returns 1.0 if no expected symbols are specified.
44+
func MatchSymbols(result RunResult, query Query) float64 {
45+
if len(query.ExpectedSymbols) == 0 {
46+
return 1.0
47+
}
48+
var matched int
49+
for _, sym := range query.ExpectedSymbols {
50+
if strings.Contains(result.Answer, sym) {
51+
matched++
52+
}
53+
}
54+
return float64(matched) / float64(len(query.ExpectedSymbols))
55+
}
56+
57+
// CountCcgToolCalls returns the number of tool calls using mcp__ccg__ prefix.
58+
func CountCcgToolCalls(result RunResult) int {
59+
var count int
60+
for _, tc := range result.ToolCalls {
61+
if strings.HasPrefix(tc.Tool, "mcp__ccg__") {
62+
count++
63+
}
64+
}
65+
return count
66+
}
67+
68+
// ComputeMatch derives a MatchResult from a single RunResult and its Query.
69+
func ComputeMatch(result RunResult, query Query) MatchResult {
70+
return MatchResult{
71+
QueryID: result.QueryID,
72+
FileHitRatio: MatchFiles(result, query),
73+
SymbolHitRatio: MatchSymbols(result, query),
74+
TotalToolCalls: len(result.ToolCalls),
75+
CcgToolCalls: CountCcgToolCalls(result),
76+
TotalInputTokens: result.InputTokens,
77+
}
78+
}
79+
80+
// AnalyzeRun computes MatchResult for every result in the run against the corpus.
81+
// Results with no matching query are skipped.
82+
func AnalyzeRun(run *BenchmarkRun, corpus *Corpus) []MatchResult {
83+
queryMap := make(map[string]Query, len(corpus.Queries))
84+
for _, q := range corpus.Queries {
85+
queryMap[q.ID] = q
86+
}
87+
var matches []MatchResult
88+
for _, r := range run.Results {
89+
q, ok := queryMap[r.QueryID]
90+
if !ok {
91+
continue
92+
}
93+
matches = append(matches, ComputeMatch(r, q))
94+
}
95+
return matches
96+
}

internal/benchmark/analyze_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package benchmark_test
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/tae2089/code-context-graph/internal/benchmark"
8+
)
9+
10+
func TestMatchFiles_AllMatch(t *testing.T) {
11+
result := benchmark.RunResult{
12+
FilesRead: []string{"internal/webhook/handler.go", "internal/webhook/syncqueue.go"},
13+
}
14+
query := benchmark.Query{
15+
ExpectedFiles: []string{"internal/webhook/handler.go", "internal/webhook/syncqueue.go"},
16+
}
17+
ratio := benchmark.MatchFiles(result, query)
18+
if ratio != 1.0 {
19+
t.Errorf("FileHitRatio: got %.3f, want 1.0", ratio)
20+
}
21+
}
22+
23+
func TestMatchFiles_PartialMatch(t *testing.T) {
24+
result := benchmark.RunResult{
25+
FilesRead: []string{"internal/webhook/handler.go"},
26+
}
27+
query := benchmark.Query{
28+
ExpectedFiles: []string{"internal/webhook/handler.go", "internal/webhook/syncqueue.go", "internal/webhook/auth.go"},
29+
}
30+
ratio := benchmark.MatchFiles(result, query)
31+
want := 1.0 / 3.0
32+
if ratio < want-0.001 || ratio > want+0.001 {
33+
t.Errorf("FileHitRatio: got %.3f, want %.3f", ratio, want)
34+
}
35+
}
36+
37+
func TestMatchFiles_NoExpected(t *testing.T) {
38+
result := benchmark.RunResult{FilesRead: []string{"any.go"}}
39+
query := benchmark.Query{ExpectedFiles: nil}
40+
ratio := benchmark.MatchFiles(result, query)
41+
if ratio != 1.0 {
42+
t.Errorf("FileHitRatio: got %.3f, want 1.0 when no expected files", ratio)
43+
}
44+
}
45+
46+
func TestMatchFiles_AbsolutePathSuffix(t *testing.T) {
47+
result := benchmark.RunResult{
48+
FilesRead: []string{"/Users/user/repo/internal/webhook/handler.go"},
49+
}
50+
query := benchmark.Query{
51+
ExpectedFiles: []string{"internal/webhook/handler.go"},
52+
}
53+
ratio := benchmark.MatchFiles(result, query)
54+
if ratio != 1.0 {
55+
t.Errorf("FileHitRatio: got %.3f, want 1.0 for absolute path suffix match", ratio)
56+
}
57+
}
58+
59+
func TestMatchSymbols_InAnswer(t *testing.T) {
60+
result := benchmark.RunResult{Answer: "WebhookHandler handles push events via SyncQueue"}
61+
query := benchmark.Query{ExpectedSymbols: []string{"WebhookHandler", "SyncQueue"}}
62+
ratio := benchmark.MatchSymbols(result, query)
63+
if ratio != 1.0 {
64+
t.Errorf("SymbolHitRatio: got %.3f, want 1.0", ratio)
65+
}
66+
}
67+
68+
func TestMatchSymbols_PartialMatch(t *testing.T) {
69+
result := benchmark.RunResult{Answer: "WebhookHandler handles events"}
70+
query := benchmark.Query{ExpectedSymbols: []string{"WebhookHandler", "SyncQueue"}}
71+
ratio := benchmark.MatchSymbols(result, query)
72+
if ratio != 0.5 {
73+
t.Errorf("SymbolHitRatio: got %.3f, want 0.5", ratio)
74+
}
75+
}
76+
77+
func TestCountCcgToolCalls(t *testing.T) {
78+
result := benchmark.RunResult{
79+
ToolCalls: []benchmark.ToolCall{
80+
{Tool: "mcp__ccg__search"},
81+
{Tool: "mcp__ccg__get_node"},
82+
{Tool: "Read"},
83+
{Tool: "Grep"},
84+
},
85+
}
86+
count := benchmark.CountCcgToolCalls(result)
87+
if count != 2 {
88+
t.Errorf("CcgToolCalls: got %d, want 2", count)
89+
}
90+
}
91+
92+
func TestComputeMatch_FullResult(t *testing.T) {
93+
result := benchmark.RunResult{
94+
QueryID: "q1",
95+
FilesRead: []string{"internal/webhook/handler.go"},
96+
Answer: "WebhookHandler 구현이 " + strings.Repeat("x", 10),
97+
InputTokens: 200,
98+
ToolCalls: []benchmark.ToolCall{
99+
{Tool: "mcp__ccg__search"},
100+
{Tool: "Read"},
101+
},
102+
}
103+
query := benchmark.Query{
104+
ID: "q1",
105+
ExpectedFiles: []string{"internal/webhook/handler.go"},
106+
ExpectedSymbols: []string{"WebhookHandler"},
107+
}
108+
m := benchmark.ComputeMatch(result, query)
109+
if m.QueryID != "q1" {
110+
t.Errorf("QueryID: got %q", m.QueryID)
111+
}
112+
if m.FileHitRatio != 1.0 {
113+
t.Errorf("FileHitRatio: got %.3f, want 1.0", m.FileHitRatio)
114+
}
115+
if m.SymbolHitRatio != 1.0 {
116+
t.Errorf("SymbolHitRatio: got %.3f, want 1.0", m.SymbolHitRatio)
117+
}
118+
if m.TotalToolCalls != 2 {
119+
t.Errorf("TotalToolCalls: got %d, want 2", m.TotalToolCalls)
120+
}
121+
if m.CcgToolCalls != 1 {
122+
t.Errorf("CcgToolCalls: got %d, want 1", m.CcgToolCalls)
123+
}
124+
if m.TotalInputTokens != 200 {
125+
t.Errorf("TotalInputTokens: got %d, want 200", m.TotalInputTokens)
126+
}
127+
}
128+
129+
func TestAnalyzeRun_MultipleResults(t *testing.T) {
130+
run := &benchmark.BenchmarkRun{
131+
Mode: "with-ccg",
132+
Results: []benchmark.RunResult{
133+
{QueryID: "q1", FilesRead: []string{"a.go"}, Answer: "Foo"},
134+
{QueryID: "q2", FilesRead: []string{"b.go"}, Answer: "Bar"},
135+
},
136+
}
137+
corpus := &benchmark.Corpus{
138+
Queries: []benchmark.Query{
139+
{ID: "q1", ExpectedFiles: []string{"a.go"}, ExpectedSymbols: []string{"Foo"}},
140+
{ID: "q2", ExpectedFiles: []string{"b.go"}, ExpectedSymbols: []string{"Bar"}},
141+
},
142+
}
143+
matches := benchmark.AnalyzeRun(run, corpus)
144+
if len(matches) != 2 {
145+
t.Errorf("expected 2 matches, got %d", len(matches))
146+
}
147+
}

internal/benchmark/compare.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package benchmark
2+
3+
// Compare builds a ComparisonReport from a with-ccg run and an optional without-ccg run.
4+
// Matches contains with-ccg metrics; MatchesWithout contains without-ccg metrics when provided.
5+
func Compare(withCCG *BenchmarkRun, withoutCCG *BenchmarkRun, corpus *Corpus) *ComparisonReport {
6+
report := &ComparisonReport{
7+
WithCCG: withCCG,
8+
WithoutCCG: withoutCCG,
9+
Matches: AnalyzeRun(withCCG, corpus),
10+
}
11+
if withoutCCG != nil {
12+
report.MatchesWithout = AnalyzeRun(withoutCCG, corpus)
13+
}
14+
return report
15+
}

internal/benchmark/compare_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package benchmark_test
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/tae2089/code-context-graph/internal/benchmark"
8+
)
9+
10+
func makeRun(mode string, results []benchmark.RunResult) *benchmark.BenchmarkRun {
11+
return &benchmark.BenchmarkRun{Mode: mode, RunAt: time.Now(), Results: results}
12+
}
13+
14+
func TestCompare_BothRuns(t *testing.T) {
15+
corpus := &benchmark.Corpus{
16+
Queries: []benchmark.Query{
17+
{ID: "q1", ExpectedFiles: []string{"a.go"}, ExpectedSymbols: []string{"Foo"}},
18+
},
19+
}
20+
withCCG := makeRun("with-ccg", []benchmark.RunResult{
21+
{QueryID: "q1", FilesRead: []string{"a.go"}, Answer: "Foo is here", InputTokens: 100},
22+
})
23+
withoutCCG := makeRun("without-ccg", []benchmark.RunResult{
24+
{QueryID: "q1", FilesRead: []string{}, Answer: "not sure", InputTokens: 200},
25+
})
26+
report := benchmark.Compare(withCCG, withoutCCG, corpus)
27+
if report.WithCCG == nil {
28+
t.Error("WithCCG should not be nil")
29+
}
30+
if report.WithoutCCG == nil {
31+
t.Error("WithoutCCG should not be nil")
32+
}
33+
if len(report.Matches) == 0 {
34+
t.Error("expected at least one match result")
35+
}
36+
}
37+
38+
func TestCompare_OnlyWithCCG(t *testing.T) {
39+
corpus := &benchmark.Corpus{
40+
Queries: []benchmark.Query{
41+
{ID: "q1", Description: "test"},
42+
},
43+
}
44+
withCCG := makeRun("with-ccg", []benchmark.RunResult{
45+
{QueryID: "q1", Answer: "answer"},
46+
})
47+
report := benchmark.Compare(withCCG, nil, corpus)
48+
if report.WithCCG == nil {
49+
t.Error("WithCCG should not be nil")
50+
}
51+
if report.WithoutCCG != nil {
52+
t.Errorf("WithoutCCG should be nil, got %+v", report.WithoutCCG)
53+
}
54+
}
55+
56+
func TestCompare_TokenDiff(t *testing.T) {
57+
corpus := &benchmark.Corpus{
58+
Queries: []benchmark.Query{{ID: "q1"}},
59+
}
60+
withCCG := makeRun("with-ccg", []benchmark.RunResult{
61+
{QueryID: "q1", InputTokens: 50},
62+
})
63+
withoutCCG := makeRun("without-ccg", []benchmark.RunResult{
64+
{QueryID: "q1", InputTokens: 300},
65+
})
66+
report := benchmark.Compare(withCCG, withoutCCG, corpus)
67+
if len(report.Matches) == 0 {
68+
t.Fatal("no matches")
69+
}
70+
if report.Matches[0].TotalInputTokens != 50 {
71+
t.Errorf("Matches should reflect with-ccg tokens (50), got %d", report.Matches[0].TotalInputTokens)
72+
}
73+
}
74+
75+
func TestCompare_FileHitImprovement(t *testing.T) {
76+
corpus := &benchmark.Corpus{
77+
Queries: []benchmark.Query{
78+
{ID: "q1", ExpectedFiles: []string{"a.go", "b.go"}},
79+
},
80+
}
81+
withCCG := makeRun("with-ccg", []benchmark.RunResult{
82+
{QueryID: "q1", FilesRead: []string{"a.go", "b.go"}},
83+
})
84+
withoutCCG := makeRun("without-ccg", []benchmark.RunResult{
85+
{QueryID: "q1", FilesRead: []string{"a.go"}},
86+
})
87+
report := benchmark.Compare(withCCG, withoutCCG, corpus)
88+
if len(report.Matches) == 0 {
89+
t.Fatal("no matches")
90+
}
91+
if report.Matches[0].FileHitRatio != 1.0 {
92+
t.Errorf("with-ccg FileHitRatio should be 1.0, got %.3f", report.Matches[0].FileHitRatio)
93+
}
94+
}

0 commit comments

Comments
 (0)