Skip to content

Commit 08fad27

Browse files
committed
Improve symbol coverage and bound search scoring; require v2 cases
1 parent b9a3942 commit 08fad27

15 files changed

Lines changed: 291 additions & 266 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ Default ignores: `.venv`, `site-packages`, `__pycache__`, `node_modules`, `.env*
142142
6. **No log levels** — `LogInfof`/`LogWarnf`/`LogErrorf` always print; use stderr for warnings/errors
143143
7. **Watch behavior** — Snapshot persistence is shutdown-only (in-memory updates during runtime, flush on graceful shutdown)
144144
8. **Symbol-feeding** — Symbols extracted before synonyms, passed to LLM for domain-accurate generation
145-
9. **Bench case format** — v2 supports categories, v1 still works, `LoadCasesAuto` handles both
145+
9. **Bench case format** — version 2 with categories only; `LoadEvalCases` is shared by bench and eval
146146
10. **Scoring** — Exact basename +15 (highest), confidence gap truncation reduces noise
147147
11. **Hybrid search** — Use `--hybrid` on `search-hints` to fill gaps in index results. Ripgrep scans file contents for query tokens and merges unmatched files at score=1. Useful when the index misses files whose content literally contains the query words but whose symbols/synonyms don't connect. Add `--memory=false` if using a snapshot (non-watch) index.
148148
12. **`--agent` flag** — Blocks `init`/`watch`/`sync`/`clean` for safety in automated flows

‎README.md‎

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,36 +48,37 @@ Remote LLM endpoints must use HTTPS; HTTP is accepted only on loopback.
4848

4949
`ctxt init` walks the filesystem tree and builds `.ctxt/ctx_index.json` — a single recursive JSON tree where every entry has `full_path`, `type`, optional `symbols`, optional `synonyms`, and nested `children` for directories.
5050

51-
**Symbols:** Every source file gets scanned with language-specific extractors that pull out exported symbols — functions, types, variables, classes, constants. These become a `symbols` array on the file node. The default extractor is `auto` (tree-sitter with regex fallback). Supported languages: Go (go/parser), Python/JavaScript/TypeScript/Rust/Svelte/Astro (tree-sitter), Vue/Ruby (regex fallback).
51+
**Symbols:** Every source file gets scanned with language-specific extractors that pull out named declarations, including Go receiver methods — functions, types, variables, classes, constants. These become a `symbols` array on the file node. The default extractor is `auto` (tree-sitter with regex fallback). Supported languages: Go (go/parser), Python/JavaScript/TypeScript/Rust/Svelte/Astro (tree-sitter), Vue/Ruby (regex fallback).
5252

5353
**Synonyms:** Each node (file or directory) gets 5–12 LLM-generated synonyms via batched API calls to an OpenAI-compatible endpoint. For example, `Skeletons.tsx → ["skeletons", "loading", "placeholder", "animation"]`. Names are smart-batched (up to 60 per request, 10 parallel by default). The prompt includes extracted symbols and, for JS/TS files, ESM imports to generate contextual terms. Use `--offline` to guarantee that no LLM requests are made.
5454

5555
**Bootstrap diff:** On subsequent runs, `watch` diffs the filesystem against the existing snapshot using file modification times and sizes. Timestamp-preserving edits with a different size are detected; edits preserving both size and timestamp require a full rebuild. Deleted files are removed, new files are added, modified files are re-extracted. Watch bootstrap reuses cached synonyms; run `ctxt sync` to fill gaps and refresh changed symbol/import context. Synonym cache keys use the full project-relative path (e.g., `billing/routes/index.ts`) so identically named files in separate features remain distinct.
5656

5757
### Search mechanics
5858

59-
When you run `search-hints "product review rating"`, the query is lowercased and split into tokens. Each token is scored against every node in the tree using matchers that each contribute points:
59+
When you run `search-hints "product review rating"`, the query is lowercased and split into tokens. Each token is scored against every node in the tree using the matchers below. Each token receives points for its strongest path match, strongest synonym match, and up to three distinct symbol matches, so repeated mentions of one concept cannot inflate a file’s score:
6060

6161
| Matcher | Points | Description |
6262
|---------|--------|-------------|
6363
| exact | +12 | Token exactly matches a directory basename |
6464
| basename | +7 | Token matches the file's basename |
65-
| exact basename | +15 | Token exactly matches the full filename (with or without extension) |
65+
| exact basename | +15 | Entire query exactly matches the full filename (with or without extension) |
6666
| path segment | +4 | Token matches any segment in the full path |
6767
| segment prefix | +5 | Token is a prefix of a path segment |
6868
| syn-exact | +8 | Token exactly matches a synonym |
6969
| syn overlap | +5 | Token partially overlaps a synonym |
70-
| sym exact | +8 | Token exactly matches a symbol name |
70+
| sym exact | +24 | Token exactly matches a symbol name |
7171
| sym contains | +5 | Token is contained in a symbol name |
7272
| sym token | +4 | Token matches a camelCase/PascalCase split token in a symbol |
7373

7474
The `--explain` flag reveals this breakdown:
7575

7676
```
77-
basename contains +7: skeleton + syn exact +8: loading + sym contains +5: PageSkeleton = total 148
77+
syn exact +8: loading
78+
sym contains +5: PageSkeleton
7879
```
7980

80-
Results are ranked by total score. Low-signal short/common words are filtered from query and synonym matching to reduce noise. Results are truncated at confidence gaps — if there's a 50%+ score drop between consecutive results, lower-scoring results are omitted even if `--limit` would include them. Exact filename/symbol lookups can return just one strong match; conceptual queries retain up to three alternatives. Zero-score candidates are never returned.
81+
Matching additional distinct query terms adds +4 per term after the first; generated stems do not count as extra query terms. Exact symbol matches outweigh three partial symbol matches. Results are ranked by total score. Low-signal short/common words are filtered from query and synonym matching to reduce noise. Results are truncated at confidence gaps — if there's a 50%+ score drop between consecutive results, lower-scoring results are omitted even if `--limit` would include them. Exact filename/symbol lookups can return just one strong match; conceptual queries retain up to three alternatives. Zero-score candidates are never returned.
8182

8283
### Watch mode
8384

@@ -178,7 +179,7 @@ Benchmark Hit@1/3/5 + MRR from manual query cases.
178179
ctxt eval --cases ctx_cases.json --json
179180
```
180181

181-
Input format (v2 with categories, backward compatible with v1):
182+
Input format (version 2 with categories):
182183
```json
183184
{
184185
"version": 2,
@@ -193,12 +194,7 @@ Input format (v2 with categories, backward compatible with v1):
193194
}
194195
```
195196

196-
v1 format (bare array) is also supported:
197-
```json
198-
[
199-
{"query": "auth middleware", "expect_any": ["internal/auth/middleware.go"]}
200-
]
201-
```
197+
Bare arrays and other case versions are rejected.
202198

203199
### `ctxt bench`
204200

@@ -316,7 +312,7 @@ search-hints → load .ctxt/ctx_index.json (or query live memory) → score toke
316312
- **`.ctxt/ctx_cache.json`** — versioned project-relative synonyms and symbol/import context fingerprints
317313
- **`.ctxt/ctx_config.toml`** — config-driven defaults
318314
- **`.ctxt/ctx_runtime.json`** — live watch discovery for memory search
319-
- **Bench/eval case files** — v2 format with categories (path-intent, symbol-lookup, concept-synonym, exact-file, narrow-scope, vague-intent); v1 bare array format is backward compatible
315+
- **Bench/eval case files** — v2 format with categories (path-intent, symbol-lookup, concept-synonym, exact-file, narrow-scope, vague-intent); requires version 2
320316

321317
## Project size guard
322318

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Symbol coverage and bounded ranking — 2026-10-10
2+
3+
## Method
4+
5+
Compared commit b9a3942 with the new extractor and scoring changes, using the
6+
same archived repository source and the existing 45 cases in
7+
`docs/bench_cases.json`. The baseline index used the previous extractor; the
8+
final index was rebuilt with the new extractor. Both used offline lexical
9+
synonyms, the ctxt engine, limit 10, and minimum score 1. No LLM calls were made.
10+
These cases informed scoring changes, so this is a regression check rather than
11+
an independent evaluation on unseen projects.
12+
13+
## Changes
14+
15+
- Include Go receiver methods alongside package declarations.
16+
- Include indented Python methods and named default JS/TS exports in regex mode.
17+
- Retain camelCase boundaries before splitting symbol names.
18+
- Award each token its strongest path match and strongest synonym match.
19+
- Cap symbol evidence at three distinct matches per token; exact symbol names
20+
receive +24 plus a +4 token bonus, so they outweigh three partial symbol matches (+9 each at most).
21+
- Reward additional distinct query terms with +4 each; generated stems do not
22+
count as extra terms.
23+
24+
## Results
25+
26+
| Metric | Before | After |
27+
| --- | ---: | ---: |
28+
| Hit@1 | 66.67% | 71.11% |
29+
| Hit@3 | 82.22% | 84.44% |
30+
| Hit@5 | 86.67% | 86.67% |
31+
| Recall at limit 10 | 86.67% | 88.89% |
32+
| Average noise ratio | 69.89% | 68.74% |
33+
| Average results | 6.44 | 6.40 |
34+
| Average estimated tokens | 28.51 | 25.62 |
35+
36+
| Category | Hit@1 before | Hit@1 after |
37+
| --- | ---: | ---: |
38+
| concept-synonym | 50.00% | 37.50% |
39+
| exact-file | 100.00% | 100.00% |
40+
| narrow-scope | 57.14% | 85.71% |
41+
| path-intent | 87.50% | 87.50% |
42+
| symbol-lookup | 85.71% | 100.00% |
43+
| vague-intent | 14.29% | 14.29% |
44+
45+
The correct file ranks first in 32/45 cases (previously 30), appears in the top
46+
three in 38/45 (previously 37), and appears within the limit in 40/45 (previously
47+
39). Symbol lookups improve from 6/7 to 7/7. Concept-synonym Hit@1
48+
regresses on this offline fixture; vague-intent Hit@1 stays at 14.29%; no broad claim about conceptual or LLM
49+
synonym quality is supported by this run.
50+
51+
## Validation
52+
53+
Existing Go tests and `go vet ./...` passed. Temporary CLI fixtures checked Go
54+
receiver methods and exclusion of local variables, Python methods and named
55+
JS/TS default exports in both auto and regex modes, bounded repetition of
56+
symbols/synonyms, exact versus partial symbol matches, camelCase matching, and
57+
stem coverage. Bench and eval both reject v1 bare arrays. Existing compatibility
58+
tests were updated or removed; no new test files were added.

‎bench_test.go‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ func buildFixtureBench(t *testing.T) (string, *ContextIndex) {
1212
t.Helper()
1313
root := t.TempDir()
1414
files := map[string]string{
15-
"src/audio/batch.go": "package audio\nfunc BatchProcess() {}",
16-
"src/audio/stream.go": "package audio\nfunc Stream() {}",
17-
"src/search/scoring.go": "package search\nfunc Score() {}",
18-
"src/cmd/register.go": "package cmd\nfunc RegisterCommand() {}",
19-
"src/ignore/rules.go": "package ignore\nfunc Match() {}",
20-
"node_modules/lib/index.js": "module.exports = {}\n", // should be ignored
21-
".git/config": "[core]\n",
22-
"README.md": "# bench fixture\n",
15+
"src/audio/batch.go": "package audio\nfunc BatchProcess() {}",
16+
"src/audio/stream.go": "package audio\nfunc Stream() {}",
17+
"src/search/scoring.go": "package search\nfunc Score() {}",
18+
"src/cmd/register.go": "package cmd\nfunc RegisterCommand() {}",
19+
"src/ignore/rules.go": "package ignore\nfunc Match() {}",
20+
"node_modules/lib/index.js": "module.exports = {}\n", // should be ignored
21+
".git/config": "[core]\n",
22+
"README.md": "# bench fixture\n",
2323
}
2424
for path, content := range files {
2525
full := filepath.Join(root, filepath.FromSlash(path))
@@ -43,7 +43,7 @@ func buildFixtureBench(t *testing.T) (string, *ContextIndex) {
4343
func writeCasesBench(t *testing.T, cases []EvalCase) string {
4444
t.Helper()
4545
path := filepath.Join(t.TempDir(), "cases.json")
46-
data, err := json.Marshal(cases)
46+
data, err := json.Marshal(CaseFile{Version: 2, Categories: map[string]CaseCategory{"bench": {Cases: cases}}})
4747
if err != nil {
4848
t.Fatalf("marshal cases: %v", err)
4949
}
@@ -548,7 +548,7 @@ func TestCategoryReportFallback(t *testing.T) {
548548
_, index := buildFixtureBench(t)
549549
cases := []EvalCase{
550550
{Query: "batch audio processing", ExpectAny: []string{"src/audio/batch.go"}}, // No category
551-
{Query: "scoring", ExpectAny: []string{"src/search/scoring.go"}}, // No category
551+
{Query: "scoring", ExpectAny: []string{"src/search/scoring.go"}}, // No category
552552
}
553553
out := runBench(BenchInput{
554554
Index: index,

‎cases_v2_test.go‎

Lines changed: 12 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"testing"
77
)
88

9-
func TestLoadCasesV1Array(t *testing.T) {
9+
func TestLoadCasesRejectsBareArray(t *testing.T) {
1010
content := `[
1111
{"query": "test query", "expect_any": ["file1.go"]},
1212
{"query": "another query", "expect_any": ["file2.go"]}
@@ -17,21 +17,9 @@ func TestLoadCasesV1Array(t *testing.T) {
1717
t.Fatalf("write test file: %v", err)
1818
}
1919

20-
cases, err := LoadCasesAuto(path)
21-
if err != nil {
22-
t.Fatalf("LoadCasesAuto error: %v", err)
23-
}
24-
if len(cases) != 2 {
25-
t.Fatalf("expected 2 cases, got %d", len(cases))
26-
}
27-
if cases[0].Query != "test query" {
28-
t.Fatalf("unexpected query: %s", cases[0].Query)
29-
}
30-
if cases[0].Description != "" {
31-
t.Fatalf("expected empty description for v1, got %s", cases[0].Description)
32-
}
33-
if cases[0].Category != "" {
34-
t.Fatalf("expected empty category for v1, got %s", cases[0].Category)
20+
_, err := LoadEvalCases(path)
21+
if err == nil {
22+
t.Fatal("expected legacy bare array to be rejected")
3523
}
3624
}
3725

@@ -60,9 +48,9 @@ func TestLoadCasesV2Object(t *testing.T) {
6048
t.Fatalf("write test file: %v", err)
6149
}
6250

63-
cases, err := LoadCasesAuto(path)
51+
cases, err := LoadEvalCases(path)
6452
if err != nil {
65-
t.Fatalf("LoadCasesAuto error: %v", err)
53+
t.Fatalf("LoadEvalCases error: %v", err)
6654
}
6755
if len(cases) != 3 {
6856
t.Fatalf("expected 3 cases, got %d", len(cases))
@@ -105,7 +93,7 @@ func TestLoadCasesV2MissingVersion(t *testing.T) {
10593
t.Fatalf("write test file: %v", err)
10694
}
10795

108-
_, err := LoadCasesAuto(path)
96+
_, err := LoadEvalCases(path)
10997
if err == nil {
11098
t.Fatalf("expected error for missing version, got nil")
11199
}
@@ -122,33 +110,16 @@ func TestLoadCasesV2EmptyCategories(t *testing.T) {
122110
t.Fatalf("write test file: %v", err)
123111
}
124112

125-
cases, err := LoadCasesAuto(path)
113+
cases, err := LoadEvalCases(path)
126114
if err != nil {
127-
t.Fatalf("LoadCasesAuto error: %v", err)
115+
t.Fatalf("LoadEvalCases error: %v", err)
128116
}
129117
if len(cases) != 0 {
130118
t.Fatalf("expected 0 cases, got %d", len(cases))
131119
}
132120
}
133121

134-
func TestLoadCasesAutoDetectsV1(t *testing.T) {
135-
content := `[{"query": "test", "expect_any": ["file.go"]}]`
136-
tmpDir := t.TempDir()
137-
path := filepath.Join(tmpDir, "cases.json")
138-
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
139-
t.Fatalf("write test file: %v", err)
140-
}
141-
142-
cases, err := LoadCasesAuto(path)
143-
if err != nil {
144-
t.Fatalf("LoadCasesAuto error: %v", err)
145-
}
146-
if len(cases) != 1 {
147-
t.Fatalf("expected 1 case, got %d", len(cases))
148-
}
149-
}
150-
151-
func TestLoadCasesAutoDetectsV2(t *testing.T) {
122+
func TestLoadEvalCasesVersion2(t *testing.T) {
152123
content := `{
153124
"version": 2,
154125
"categories": {
@@ -166,40 +137,14 @@ func TestLoadCasesAutoDetectsV2(t *testing.T) {
166137
t.Fatalf("write test file: %v", err)
167138
}
168139

169-
cases, err := LoadCasesAuto(path)
170-
if err != nil {
171-
t.Fatalf("LoadCasesAuto error: %v", err)
172-
}
173-
if len(cases) != 1 {
174-
t.Fatalf("expected 1 case, got %d", len(cases))
175-
}
176-
if cases[0].Category != "test" {
177-
t.Fatalf("expected category 'test', got %s", cases[0].Category)
178-
}
179-
}
180-
181-
func TestEvalCaseV1UnmarshalIgnoresNewFields(t *testing.T) {
182-
content := `[{"query": "test", "expect_any": ["file.go"]}]`
183-
tmpDir := t.TempDir()
184-
path := filepath.Join(tmpDir, "cases.json")
185-
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
186-
t.Fatalf("write test file: %v", err)
187-
}
188-
189140
cases, err := LoadEvalCases(path)
190141
if err != nil {
191142
t.Fatalf("LoadEvalCases error: %v", err)
192143
}
193144
if len(cases) != 1 {
194145
t.Fatalf("expected 1 case, got %d", len(cases))
195146
}
196-
if cases[0].Query != "test" {
197-
t.Fatalf("unexpected query: %s", cases[0].Query)
198-
}
199-
if cases[0].Description != "" {
200-
t.Fatalf("expected empty description, got %s", cases[0].Description)
201-
}
202-
if cases[0].Category != "" {
203-
t.Fatalf("expected empty category, got %s", cases[0].Category)
147+
if cases[0].Category != "test" {
148+
t.Fatalf("expected category 'test', got %s", cases[0].Category)
204149
}
205150
}

‎command_bench.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Examples:
9191
return fmt.Errorf("--cases is required")
9292
}
9393

94-
cases, err := LoadCasesAuto(casesPath)
94+
cases, err := LoadEvalCases(casesPath)
9595
if err != nil {
9696
return err
9797
}

‎command_eval.go‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ Examples:
2626
ctxt eval --cases cases.json --json JSON output`,
2727
RunE: func(cmd *cobra.Command, args []string) error {
2828
var absConfigPath string
29-
if configPath != "" {
30-
var cfgErr error
31-
absConfigPath, cfgErr = filepath.Abs(configPath)
32-
if cfgErr != nil {
33-
return fmt.Errorf("resolve config path: %w", cfgErr)
29+
if configPath != "" {
30+
var cfgErr error
31+
absConfigPath, cfgErr = filepath.Abs(configPath)
32+
if cfgErr != nil {
33+
return fmt.Errorf("resolve config path: %w", cfgErr)
34+
}
3435
}
35-
}
36-
cfg, err := LoadContextingConfig(absConfigPath)
36+
cfg, err := LoadContextingConfig(absConfigPath)
3737
if err != nil {
3838
return err
3939
}
@@ -63,7 +63,7 @@ Examples:
6363
if err != nil {
6464
return err
6565
}
66-
cases, err := LoadCasesAuto(casesPath)
66+
cases, err := LoadEvalCases(casesPath)
6767
if err != nil {
6868
return err
6969
}

‎command_mcp.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ Setup: Add to your AI client's MCP config:
472472
cmd.Flags().StringVar(&flags.Model, "llm-model", "", "LLM model used for synonym generation")
473473
cmd.Flags().StringVar(&flags.APIKey, "api-key", "", "LLM API key (falls back to config api_key_env, LLM_API_KEY, OPENROUTER_API_KEY)")
474474
cmd.Flags().StringVar(&flags.Endpoint, "llm-endpoint", "", "LLM API endpoint URL")
475-
cmd.Flags().IntVar(&flags.BatchSize, "batch-size", 0, "Names per LLM request (0 = send all, legacy option)")
475+
cmd.Flags().IntVar(&flags.BatchSize, "batch-size", 0, "Names per LLM request (0 = automatic, up to 60)")
476476
cmd.Flags().IntVar(&maxBatchSize, "max-batch-size", 0, "Maximum names per LLM request (0 = send all at once, default)")
477477
cmd.Flags().IntVar(&flags.SynonymsPerName, "synonyms", defaultSynonyms, "Desired synonyms per name")
478478
cmd.Flags().IntVar(&flags.SynonymsMin, "synonyms-min", 0, "Min synonyms per name (0 = use synonyms value)")

‎command_search.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func newSearchCommand() *cobra.Command {
3131
3232
How it works:
3333
• Your query is tokenized and matched against filenames, symbols (functions, classes, types), and synonyms
34-
• Each match is scored by type: synonym=20, symbol=10, path=1
34+
• Path, synonym, and symbol matches contribute bounded scores; matching more query terms adds a coverage bonus
3535
• Results are ranked by total score and returned
3636
3737
Requires an existing index (run 'ctxt init .' first).

‎command_watch.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ Examples:
391391
cmd.Flags().StringVar(&flags.Model, "llm-model", "", "LLM model used for synonym generation")
392392
cmd.Flags().StringVar(&flags.APIKey, "api-key", "", "LLM API key (falls back to config api_key_env, LLM_API_KEY, OPENROUTER_API_KEY)")
393393
cmd.Flags().StringVar(&flags.Endpoint, "llm-endpoint", "", "LLM API endpoint URL")
394-
cmd.Flags().IntVar(&flags.BatchSize, "batch-size", 0, "Names per LLM request (0 = send all, legacy option)")
394+
cmd.Flags().IntVar(&flags.BatchSize, "batch-size", 0, "Names per LLM request (0 = automatic, up to 60)")
395395
cmd.Flags().IntVar(&maxBatchSize, "max-batch-size", 0, "Maximum names per LLM request (0 = send all at once, default)")
396396
cmd.Flags().IntVar(&flags.SynonymsPerName, "synonyms", defaultSynonyms, "Desired synonyms per name")
397397
cmd.Flags().IntVar(&flags.SynonymsMin, "synonyms-min", 0, "Min synonyms per name (0 = use synonyms value)")

0 commit comments

Comments
 (0)