grepmax 0.26.47 → 0.26.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "name": "grepmax",
11
11
  "source": "./plugins/grepmax",
12
12
  "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
13
- "version": "0.26.47",
13
+ "version": "0.26.49",
14
14
  "author": {
15
15
  "name": "Robert Owens",
16
16
  "email": "robowens@me.com"
package/README.md CHANGED
@@ -36,6 +36,8 @@ Natural-language search that works like `grep`. Fast, local, and built for codin
36
36
 
37
37
  ## Quick Start
38
38
 
39
+ Requires **Node.js 22.12.0 or newer**.
40
+
39
41
  ```bash
40
42
  npm install -g grepmax # 1. Install
41
43
  cd my-repo && gmax add # 2. Add + index
@@ -89,7 +91,7 @@ gmax status # All indexed projects + chunk counts
89
91
  gmax status --json # The same plus daemon, settings and workers, as JSON
90
92
  ```
91
93
 
92
- In our public benchmarks, `grepmax` can save about 20% of your LLM tokens and deliver a 30% speedup.
94
+ The recorded benchmark below showed about 20% fewer LLM tokens and a 30% speedup on its workload. Results depend on the repository, query and agent workflow.
93
95
 
94
96
  <div align="center">
95
97
  <img src="public/bench.png" alt="gmax benchmark" width="100%" style="border-radius: 8px; margin: 20px 0;" />
@@ -115,11 +117,11 @@ gmax plugin add droid # Factory Droid only
115
117
  gmax plugin remove claude # Remove specific plugin
116
118
  ```
117
119
 
118
- Plugins auto-update when you run `npm install -g grepmax@latest` — no need to re-run `gmax plugin add`.
120
+ After upgrading with `npm install -g grepmax@latest`, run `gmax plugin update` (or `gmax plugin update <client>`) to refresh integrations. Package installation does not modify your agent configuration; its postinstall script only prints this reminder.
119
121
 
120
122
  ### How it works per client
121
123
 
122
- - **Claude Code:** Plugin with hooks (SessionStart, CwdChanged, SubagentStart, PreToolUse). Model uses CLI via `Bash(gmax ... --agent)`.
124
+ - **Claude Code:** Plugin with hooks (SessionStart, SessionEnd, CwdChanged, SubagentStart, PreToolUse). Model uses CLI via `Bash(gmax ... --agent)`.
123
125
  - **OpenCode:** Tool shim with dynamic SKILL + session plugin for daemon startup. Model calls gmax tool directly.
124
126
  - **Codex:** MCP server registration + AGENTS.md skill instructions.
125
127
  - **Factory Droid:** Skills + SessionStart/SessionEnd hooks for daemon lifecycle.
@@ -153,7 +155,7 @@ Clients requesting progress receive operation-stage messages with increasing sta
153
155
  | `list_projects` | List every indexed project (name, root, status, chunks) to pick a search scope. |
154
156
  | `index_status` | Index health: chunks, files, projects, watcher status. |
155
157
  | `summarize_project` | Project overview: languages, structure, key symbols, entry points. |
156
- | `summarize_directory` | Generate LLM summaries for indexed chunks. |
158
+ | `summarize_directory` | Compatibility entry; summarization is disabled and generates no summaries. |
157
159
  | `related_files` | Dependencies and dependents by shared symbols. |
158
160
  | `recent_changes` | Recently modified indexed files. |
159
161
  | `diff_changes` | Search scoped to git changes. |
@@ -189,18 +191,6 @@ gmax "query" [options]
189
191
  | `--seed-symbol <name>` | Bias results toward an identifier you're working with (repeatable). | — |
190
192
  | `--skeleton` | Show file skeletons for top matches. | `false` |
191
193
  | `--context-for-llm` | Full function bodies + imports per result. | `false` |
192
-
193
- ## Experimental Orientation
194
-
195
- `gmax surprises --experimental` finds file pairs that are semantically similar but not already connected by the indexed static graph. Use it for architecture orientation, duplicate-logic sweeps, and cross-package drift checks, not as proof that two files are unrelated.
196
-
197
- ```bash
198
- gmax surprises --experimental --agent
199
- gmax surprises --experimental --in packages/app/src --dir-depth 4 --agent
200
- gmax surprises --experimental --exclude generated --top 10
201
- ```
202
-
203
- The CLI and MCP output include score, max similarity, pair count, representative symbols, directory buckets, top similarities, applied penalties, and `gmax skeleton` follow-up hints. On large monorepos, prefer `--in`/`--exclude`; the default scan is capped at 50,000 rows and user-provided `--max-rows` is capped at 100,000. If a narrow `--in` scope returns no findings, increase `--dir-depth` so subdirectories are compared inside the scope.
204
194
  | `--budget <tokens>` | Cap output tokens (for `--context-for-llm`). | `8000` |
205
195
  | `--explain` | Show scoring breakdown per result. | `false` |
206
196
  | `--scores` | Show relevance scores. | `false` |
@@ -213,7 +203,19 @@ The CLI and MCP output include score, max similarity, pair count, representative
213
203
  | `--all-projects` | Search every indexed project; results grouped by project. | `false` |
214
204
  | `--projects <list>` | Search only these projects (comma-separated names). | — |
215
205
  | `--exclude-projects <list>` | With `--all-projects`, skip these projects. | — |
216
- | `--min-score <n>` | Minimum relevance score. | `0` |
206
+ | `--min-score <n>` | Minimum score relative to this query’s top match, not an absolute confidence threshold. | `0` |
207
+
208
+ ## Experimental Orientation
209
+
210
+ `gmax surprises --experimental` finds file pairs that are semantically similar but not already connected by the indexed static graph. Use it for architecture orientation, duplicate-logic sweeps, and cross-package drift checks, not as proof that two files are unrelated.
211
+
212
+ ```bash
213
+ gmax surprises --experimental --agent
214
+ gmax surprises --experimental --in packages/app/src --dir-depth 4 --agent
215
+ gmax surprises --experimental --exclude generated --top 10
216
+ ```
217
+
218
+ The CLI and MCP output include score, max similarity, pair count, representative symbols, directory buckets, top similarities, applied penalties, and `gmax skeleton` follow-up hints. On large monorepos, prefer `--in`/`--exclude`; the default scan is capped at 50,000 rows and user-provided `--max-rows` is capped at 100,000. If a narrow `--in` scope returns no findings, increase `--dir-depth` so subdirectories are compared inside the scope.
217
219
 
218
220
  ## Background Daemon
219
221
 
@@ -228,7 +230,7 @@ gmax watch restart # Stop it, wait for it to exit, start a fresh one
228
230
  gmax status # See all projects + watcher status
229
231
  ```
230
232
 
231
- The daemon auto-starts when you run `gmax add`, `gmax index`, `gmax remove`, `gmax summarize`, or `gmax mcp`. It shuts down after 4 hours of inactivity, and hands off to a fresh daemon after 24 hours or once its memory footprint passes 2.5 GB. File-change batches are processed on at most four worker processes, added only when work backs up, with one kept free for searches; LanceDB compaction runs after writes rather than on every maintenance tick.
233
+ Adding or indexing a project can start the daemon automatically. MCP acquires primary-project watch leases lazily when an index-reading tool is used; discovery and catalog listing start no background work. It shuts down after 4 hours of inactivity, and hands off to a fresh daemon after 24 hours or once its memory footprint passes 2.5 GB. File-change batches are processed on up to four worker processes by default, added only when work backs up, with one kept free for searches; LanceDB compaction runs after writes rather than on every maintenance tick.
232
234
 
233
235
  `gmax watch status` reports failed files, native watcher recovery or polling, dropped-event counts, and the last complete filesystem reconciliation. Polling scans run every five minutes, so recent edits can lag. Search and MCP responses retain health warnings even after the file queue drains. A complete reconciliation means the filesystem scan completed; queued embedding work can still be pending.
234
236
 
@@ -277,14 +279,14 @@ On Linux the per-socket list does not exist; the only option is
277
279
 
278
280
  gmax can use a local LLM (via llama-server) for agentic codebase investigation. This is entirely opt-in and disabled by default — gmax works fine without it.
279
281
 
280
- > **Memory footprint.** `gmax llm start`, `investigate`, `review`, and `summarize` load a multi-GB
282
+ > **Memory footprint.** `gmax llm start`, `investigate`, and `review` can load a multi-GB
281
283
  > GGUF model (typical coding models run 16–21 GB). On a memory-constrained machine that can stall
282
284
  > or hang the host. Start it deliberately, not as a reflex — and if you run coding agents against
283
285
  > this repo, they should be told not to invoke these on their own initiative. The bundled skill
284
286
  > already carries that instruction.
285
287
  >
286
- > Everything else in gmax — search, `trace`, `impact`, `context`, `--context-for-llm` — runs off
287
- > the local index with no model load.
288
+ > Everything else in gmax — search, `trace`, `impact`, `context`, `--context-for-llm` — uses
289
+ > local embeddings and the index without a generative LLM.
288
290
 
289
291
  ```bash
290
292
  gmax llm on # Enable LLM features (persists to config)
@@ -330,16 +332,23 @@ The hook sends an IPC message to the daemon and returns instantly — it never b
330
332
 
331
333
  ### LLM Configuration
332
334
 
335
+ Set `GMAX_LLM_MODEL` to a GGUF file on your machine before starting; the built-in fallback path is machine-specific and may not exist. The server accepts loopback addresses only.
336
+
333
337
  | Variable | Description | Default |
334
338
  | --- | --- | --- |
335
- | `GMAX_LLM_MODEL` | Path to GGUF model file | (none) |
339
+ | `GMAX_LLM_MODEL` | Path to GGUF model file | Machine-specific fallback |
340
+ | `GMAX_LLM_HOST` | Loopback address (`localhost` maps to IPv4 loopback) | `127.0.0.1` |
336
341
  | `GMAX_LLM_BINARY` | llama-server binary | `llama-server` |
337
342
  | `GMAX_LLM_PORT` | Server port | `8079` |
338
343
  | `GMAX_LLM_IDLE_TIMEOUT` | Minutes before auto-stop | `30` |
339
344
 
345
+ ### Summary compatibility commands
346
+
347
+ The summarizer is decommissioned. `gmax summarize` and MCP `summarize_directory` remain compatibility entries backed by a no-op; they do not generate summaries. `GMAX_SUMMARIZER` does not enable them. Investigation and review use the separate optional LLM described above.
348
+
340
349
  ## Architecture
341
350
 
342
- All data lives in `~/.gmax/`:
351
+ Default shared data lives in `~/.gmax/`; configured secondary stores retain their own project indexes:
343
352
  - `lancedb/` — LanceDB vector store (centralized, all projects)
344
353
  - `cache/meta.lmdb` — file metadata cache (hashes, mtimes)
345
354
  - `cache/watchers.lmdb` — watcher/daemon registry (LMDB, crash-safe)
@@ -347,7 +356,9 @@ All data lives in `~/.gmax/`:
347
356
  - `daemon.pid` — PID file for daemon dedup
348
357
  - `logs/` — daemon and server logs (5MB rotation)
349
358
  - `config.json` — global config (model tier, embed mode)
350
- - `models/` — embedding models
359
+ - `models/` — ONNX embedding models
360
+ - `hf/` — pinned Hugging Face cache for MLX embedding models
361
+ - `watch-leases.json` — active session/CLI watch leases
351
362
  - `grammars/` — Tree-sitter grammars
352
363
  - `projects.json` — registry of indexed directories
353
364
 
@@ -378,22 +389,19 @@ gmax embeds with IBM's Granite r2 code-embedding models. Two tiers are available
378
389
  | `small` (default) | `granite-embedding-small-english-r2` | 384 | 47M |
379
390
  | `standard` | `granite-embedding-english-r2` | 768 | 149M |
380
391
 
381
- **384d is the default — and benchmarking says keep it.** On gmax's own 97-case
382
- retrieval eval (`pnpm bench:recall`), the larger 768d model scored **~10 points
392
+ **384d remains the default.** In the recorded 97-case model-tier comparison
393
+ on gmax's own repo, the larger 768d model scored **~10 points
383
394
  *worse* on Recall@10** than 384d, consistently on both the MLX GPU and ONNX CPU
384
395
  embedding paths, with and without ColBERT rerank:
385
396
 
386
- | Model | Recall@10 | MRR@10 |
397
+ | Model | Recall@10 | Reported MRR |
387
398
  | --- | --- | --- |
388
399
  | `small` / 384d | **0.72** | **0.51** |
389
400
  | `standard` / 768d | 0.62 | 0.44 |
390
401
 
391
- <sub>gmax repo, 97 cases, MLX GPU, rerank off (the shipped default). The CPU/q4
392
- path and the rerank-on path show the same ~0.10 Recall@10 gap, so it isn't a
393
- quantization artifact — the bigger model just retrieves worse on code here.</sub>
402
+ <sub>Historical gmax-repo comparison: 97 cases, MLX GPU, rerank off. CPU/q4 and rerank-on comparisons showed a similar gap. These figures are not a v0.26.48 benchmark; see the evaluation caveats below.</sub>
394
403
 
395
- Bigger isn't better for this workload: 768d roughly doubles embedding compute,
396
- worker RAM, and dense-vector storage while *lowering* recall on code search.
404
+ The larger tier performed worse on that fixture and doubles dense-vector width; compute and memory costs depend on the backend.
397
405
  Unless you have a measured reason to switch (e.g. a recall complaint on a very
398
406
  large repo — benchmark it first), stay on 384d.
399
407
 
@@ -438,11 +446,11 @@ fixtures/
438
446
  | Variable | Description | Default |
439
447
  | --- | --- | --- |
440
448
  | `GMAX_EMBED_MODE` | Force `cpu` or `gpu` | Auto-detect |
441
- | `GMAX_WORKER_THREADS` | Worker processes for embedding; overrides `gmax config --worker-threads` | `min(4, max(2, cores/2))` |
449
+ | `GMAX_WORKER_THREADS` | Worker processes for embedding; overrides `gmax config --worker-threads` | `min(cores, 4, max(2, floor(cores/2)))` |
442
450
  | `GMAX_WORKER_RSS_RECYCLE_MB` | Recycle workers that remain above this RSS; `0` disables the check | `1536` |
443
451
  | `GMAX_DEBUG` | Debug logging | Off |
444
- | `GMAX_SUMMARIZER` | Enable summarizer auto-start (`1`) | Off |
445
- | `GMAX_RERANK` | Enable ColBERT rerank (`1`) — off by default since v0.17.1 ([why](docs/known-limitations.md)) | Off |
452
+ | `GMAX_RERANK` | Force ColBERT rerank (`1`); concentrated candidates can also enable it automatically ([why](docs/known-limitations.md)) | Off |
453
+ | `GMAX_CONCENTRATION_THRESHOLD` | Top-ten candidate share in one file that enables ColBERT; set above `1` for a true rerank-off baseline | `0.7` |
446
454
  | `GMAX_ANN` | Experimental IVF_FLAT vector index (`1`); leave off unless validating recall on your corpus | Off |
447
455
 
448
456
  ## Troubleshooting
@@ -466,17 +474,17 @@ Compaction pauses index writes, waits for pending writes to commit, and opens a
466
474
 
467
475
  ### Known issues
468
476
 
469
- Some log output looks alarming but is expected and self-healing. Before filing a bug, check
477
+ For detection and recovery guidance, check
470
478
  [`docs/known-limitations.md`](docs/known-limitations.md) — it covers what each case means and
471
479
  whether you need to act.
472
480
 
473
481
  | You see | What it means |
474
482
  |---|---|
475
- | `Optimize panicked ... inverted/builder.rs` | Upstream Lance bug in incremental FTS merge ([lance#8310](https://github.com/lance-format/lance/issues/8310)). gmax rebuilds the index and retries automatically; no data loss, no action needed. |
483
+ | `Optimize panicked ... inverted/builder.rs` | The historical FTS merge defect is fixed in the shipped LanceDB 0.38.0 runtime. If it recurs on a current release, preserve the version and logs and report a regression; the rebuild guard remains a recovery tripwire. |
476
484
  | `disabling auto-rebuild until an optimize succeeds` | Transient, not a wedge. The next successful optimize clears it. Search stays available. |
477
485
  | `ANN: vector index not built` | Normal — exact search is the default. |
478
486
  | `cannot reach the daemon socket from this sandbox` (exit 2) | The shell is sandboxed. Add the two keys in [Running under the Claude Code sandbox](#running-under-the-claude-code-sandbox); `gmax doctor` warns about the same gap. |
479
- | `npm audit` reports advisories on install | Two transitive advisories (`sharp`, `adm-zip`) have no upstream fix yet; neither is on a code path gmax executes. |
487
+ | `npm audit` reports advisories on install | v0.26.48 passed production and fresh packed-consumer audits. Audit findings can change: record the installed version and dependency path and report new findings; do not assume historical advisories still apply. |
480
488
 
481
489
  ## Contributing
482
490
 
@@ -484,7 +492,7 @@ See [CLAUDE.md](CLAUDE.md) for development setup, commands, and architecture det
484
492
 
485
493
  ### Benchmarks
486
494
 
487
- Two evaluation harnesses live in the repo. Both emit stable JSON via `:json` variants.
495
+ Two retrieval evaluation harnesses emit JSON via `:json` variants. Additional token and experimental-orientation harnesses are available as `bench:tokens` and `bench:surprises`.
488
496
 
489
497
  ```bash
490
498
  pnpm bench:recall # 97-case internal eval against gmax's own repo
@@ -494,10 +502,37 @@ pnpm bench:oss:json
494
502
  GMAX_EVAL_RERANK=1 pnpm bench:oss # toggle ColBERT rerank
495
503
  ```
496
504
 
497
- The OSS bench requires the fixture repos to be indexed first — see [`docs/known-limitations.md`](docs/known-limitations.md) for the most recent rerank-on-vs-off comparison across 4 datasets / 131 cases.
505
+ The OSS harness expects indexed express, lodash and platform fixtures at the paths defined in [src/eval-oss.ts](src/eval-oss.ts). The historical comparison in [known limitations](docs/known-limitations.md#colbert-rerank-is-opt-in-shape-sensitive-helps-monolithic-files-hurts-modular-repos) combines these with the internal fixture: four datasets / 131 cases. It is not a current release acceptance run.
506
+
507
+ Both retrieval harnesses request 20 results for diagnostics, but `mrrAt10` and Recall@10 credit only ranks 1–10. Later matches retain their actual ranks and count as found/hits, with zero reciprocal-rank credit. This cutoff was corrected in source after v0.26.48; historical MRR figures recorded before the correction may include ranks 11–20 and require a new run or rescoring before comparison. Also set `GMAX_CONCENTRATION_THRESHOLD=2` for a true rerank-off comparison: `GMAX_EVAL_RERANK=0` alone does not disable automatic concentration gating. Freeze fixtures and record source, embedding/index identity and ranking configuration before drawing conclusions.
498
508
 
499
509
  `pnpm bench:recall` also drives the model-tier comparison behind the 384d default — see [Model Tier](#model-tier) for why the larger 768d model is *not* the default.
500
510
 
511
+ #### Frozen multi-repository baseline
512
+
513
+ `bench:relevance` measures reviewed local fixtures through an already-ready daemon. It does not autostart a daemon or open a fallback search store. Keep private queries, source evidence and results under the ignored `docs/measurements/` directory. Review and freeze the fixture before seeing rankings; use a new fixture version when changing ground truth.
514
+
515
+ ```bash
516
+ shasum -a 256 docs/measurements/relevance/fixture-v1.json
517
+ pnpm bench:relevance --fixture docs/measurements/relevance/fixture-v1.json \
518
+ --sha256 <reviewed-fixture-digest> \
519
+ --output docs/measurements/relevance/baseline-v1.json --repeats 2
520
+ ```
521
+
522
+ The fixture schema is in [relevance-baseline.ts](src/lib/eval/relevance-baseline.ts). Its JSON contains `schemaVersion: 1`, `createdAt`, `purpose`, `corpora` and `cases`. Each corpus has an `id`, `root` (absolute or relative to the fixture) and `language`. Each case declares an `id`, `corpus`, `split` (`dev` or `heldout`), `origin` (`curated-source` or `observed-session`), `intent`, `query` and one or more `expected` targets. Targets require an exact relative `file`, definition `symbol`, one-based `startLine`/`endLine` and the SHA-256 of the source file.
523
+
524
+ A hit requires the exact file and either the definition symbol or overlap with the declared line range. Choose answer-bearing ranges and reviewed alternatives: a declaration-only target can miss a useful function-body chunk. The October 4 baseline intentionally preserves its frozen declaration targets, so its 57.5% Recall@10 / 0.3275 MRR@10 describes target retrieval, not general answer quality. It covers 40 curated cases across TypeScript, JavaScript, Python and Swift; both repetitions produced identical ranks. These are not observed user-session misses or a ranking acceptance gate.
525
+
526
+ The report records source/index hashes, registry embedding identity, daemon snapshots, exclusions, per-case ranks, aggregate metrics and ordinary-session latency. A sibling `.samples.jsonl` checkpoints each completed request; the final JSON applies run-wide exclusions and is authoritative. Changed source/cache hashes, missing targets, unsettled indexes, search warnings and daemon replacement invalidate samples. The runner refuses to overwrite artifacts and exits 2 when any samples are excluded.
527
+
528
+ Requests use `rerank: false`, but the daemon's concentration gate can still enable reranking. Client environment settings cannot change an existing daemon's settings, and `scoreBreakdown.rerank` also holds the fallback base score when reranking is off.
529
+
530
+ Source now supports `--diagnostics`. This requires a ready daemon advertising `capabilities.searchDiagnostics: 1`; v0.26.48's installed release does not advertise it. The evaluator refuses incompatible daemons before measuring and excludes responses missing requested diagnostics. Each sample records FTS availability/failure, effective settings and allowlisted daemon ranking environment, concentration-gate evaluation/activation and whether reranking was actually invoked.
531
+
532
+ The candidate trace contains pointers and stage ranks for at most the first 200 post-seed fusion candidates, plus full stage counts and an explicit truncation flag. Ranks cover vector/FTS, RRF, seeded fusion, stage-one selection, pooled filtering, selected rerank batch, final scoring, overlap deduplication and final display. Rerank-batch membership does not imply execution; use `gate.rerankInvoked`. Zero at a later stage means a traced candidate was removed; a target missing from a truncated trace may lie outside the reported head. Diagnostics add no database reads and preserve ranking/results. The frozen October 4 artifacts retain their original unknowns. Review ground truth and collect new diagnostic artifacts before tuning.
533
+
534
+ Each traced candidate also has an `outcome`: returned, stage-one/pooled cut, deduplicated, per-file limit or display limit. Display limit means the production loop filled its result window before examining that candidate; it does not assert that the candidate would pass the per-file cap.
535
+
501
536
  ## Attribution
502
537
 
503
538
  grepmax is built upon the foundation of [mgrep](https://github.com/mixedbread-ai/mgrep) by MixedBread. See the [NOTICE](NOTICE) file for details.
package/dist/eval-oss.js CHANGED
@@ -57,6 +57,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
57
57
  var _a, _b, _c, _d;
58
58
  var _e;
59
59
  Object.defineProperty(exports, "__esModule", { value: true });
60
+ exports.evaluateOss = evaluateOss;
60
61
  // Same precaution as src/eval.ts — pin worker pool to 1 to avoid ONNX
61
62
  // concurrency issues during the back-to-back search runs.
62
63
  (_a = (_e = process.env).GMAX_WORKER_COUNT) !== null && _a !== void 0 ? _a : (_e.GMAX_WORKER_COUNT = "1");
@@ -346,8 +347,8 @@ function evaluateOss(response, c, timeMs) {
346
347
  // expected defined-symbol for the drift-robust match branch.
347
348
  chunkMatches(chunk, c.expectedFile, c.expectedLine, c.query));
348
349
  const rank = idx + 1; // 0 = miss
349
- const rr = rank > 0 ? 1 / rank : 0;
350
350
  const recall10 = rank > 0 && rank <= 10 ? 1 : 0;
351
+ const rr = recall10 ? 1 / rank : 0;
351
352
  return {
352
353
  id: c.id,
353
354
  query: c.query,
@@ -0,0 +1,337 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ /** Frozen local multi-corpus baseline, using the existing daemon with no fallback. */
46
+ const node_child_process_1 = require("node:child_process");
47
+ const fs = __importStar(require("node:fs"));
48
+ const path = __importStar(require("node:path"));
49
+ const node_util_1 = require("node:util");
50
+ const lmdb_1 = require("lmdb");
51
+ const config_1 = require("./config");
52
+ const relevance_baseline_1 = require("./lib/eval/relevance-baseline");
53
+ const daemon_client_1 = require("./lib/utils/daemon-client");
54
+ const project_registry_1 = require("./lib/utils/project-registry");
55
+ function git(root) {
56
+ try {
57
+ return {
58
+ head: (0, node_child_process_1.execFileSync)("git", ["rev-parse", "HEAD"], {
59
+ cwd: root,
60
+ encoding: "utf8",
61
+ stdio: ["ignore", "pipe", "ignore"],
62
+ }).trim(),
63
+ dirty: (0, node_child_process_1.execFileSync)("git", ["status", "--porcelain"], {
64
+ cwd: root,
65
+ encoding: "utf8",
66
+ stdio: ["ignore", "pipe", "ignore"],
67
+ }).trim().length > 0,
68
+ };
69
+ }
70
+ catch (_a) {
71
+ return null;
72
+ }
73
+ }
74
+ function run() {
75
+ return __awaiter(this, void 0, void 0, function* () {
76
+ var _a, _b, _c, _d;
77
+ var _e, _f;
78
+ const { values } = (0, node_util_1.parseArgs)({
79
+ options: {
80
+ fixture: { type: "string" },
81
+ sha256: { type: "string" },
82
+ output: { type: "string" },
83
+ repeats: { type: "string", default: "2" },
84
+ help: { type: "boolean" },
85
+ diagnostics: { type: "boolean", default: false },
86
+ },
87
+ });
88
+ if (values.help) {
89
+ console.log("pnpm bench:relevance --fixture <json> --sha256 <frozen-sha256> --output <new-json> [--repeats 2] [--diagnostics]");
90
+ return;
91
+ }
92
+ if (!values.fixture || !values.sha256 || !values.output)
93
+ throw new Error("Required: --fixture, --sha256, --output");
94
+ if (fs.existsSync(values.output))
95
+ throw new Error("Output exists; use a new artifact path");
96
+ const checkpoint = `${values.output}.samples.jsonl`;
97
+ if (fs.existsSync(checkpoint))
98
+ throw new Error("Checkpoint exists; use a new artifact path");
99
+ const repeats = Number(values.repeats);
100
+ if (!Number.isInteger(repeats) || repeats < 1 || repeats > 5)
101
+ throw new Error("Repeats must be 1–5");
102
+ const fixturePath = path.resolve(values.fixture);
103
+ const fixture = (0, relevance_baseline_1.parseFrozenFixture)(fs.readFileSync(fixturePath), values.sha256);
104
+ const roots = new Map(fixture.corpora.map((r) => [
105
+ r.id,
106
+ fs.realpathSync(path.resolve(path.dirname(fixturePath), r.root)),
107
+ ]));
108
+ const startedAt = new Date().toISOString();
109
+ const ping = yield (0, daemon_client_1.sendDaemonCommand)({ cmd: "ping" }, { timeoutMs: 5000 });
110
+ if (!ping.ok || ping.ready !== true)
111
+ throw new Error("An already-ready daemon is required; no autostart/fallback");
112
+ if (values.diagnostics &&
113
+ ((_a = ping.capabilities) === null || _a === void 0 ? void 0 : _a.searchDiagnostics) !== 1)
114
+ throw new Error("Daemon lacks searchDiagnostics v1; install/restart a compatible daemon before measuring diagnostics");
115
+ const before = yield (0, daemon_client_1.sendDaemonCommand)({ cmd: "status" });
116
+ if (!before.ok)
117
+ throw new Error("Cannot capture daemon status");
118
+ const registry = (0, project_registry_1.listProjects)();
119
+ const cache = (0, lmdb_1.open)({
120
+ path: path.join(config_1.PATHS.globalRoot, "cache/meta.lmdb"),
121
+ compression: true,
122
+ readOnly: true,
123
+ });
124
+ const samples = [];
125
+ const preflight = [];
126
+ fs.writeFileSync(checkpoint, "", { flag: "wx" });
127
+ try {
128
+ for (const corpus of fixture.corpora) {
129
+ const root = roots.get(corpus.id);
130
+ const project = registry.find((p) => p.root === root);
131
+ if ((project === null || project === void 0 ? void 0 : project.status) !== "indexed")
132
+ throw new Error(`Corpus is not indexed: ${corpus.id}`);
133
+ const files = [
134
+ ...new Set(fixture.cases
135
+ .filter((c) => c.corpus === corpus.id)
136
+ .flatMap((c) => c.expected.map((t) => t.file))),
137
+ ];
138
+ const rows = yield (0, daemon_client_1.sendDaemonCommand)({
139
+ cmd: "rows.locate",
140
+ projectRoot: root,
141
+ limit: 500,
142
+ select: ["path", "defined_symbols", "start_line", "end_line"],
143
+ matches: files.map((file) => ({ kind: "path", path: file })),
144
+ });
145
+ if (!rows.ok || !Array.isArray(rows.rows))
146
+ throw new Error(`Target inventory failed: ${corpus.id}`);
147
+ preflight.push({
148
+ corpus: corpus.id,
149
+ root,
150
+ git: git(root),
151
+ project,
152
+ files,
153
+ targetRows: rows.rows,
154
+ cache: files.map((file) => (Object.assign({ file }, cache.get(path.join(root, file))))),
155
+ });
156
+ }
157
+ // Sequential execution bounds load. Interleave corpora instead of running
158
+ // all of one corpus first; repetitions are reported separately from cases.
159
+ const ordered = [];
160
+ const groups = fixture.corpora.map((r) => fixture.cases.filter((c) => c.corpus === r.id));
161
+ for (let i = 0; i < Math.max(...groups.map((g) => g.length)); i++) {
162
+ for (const group of groups)
163
+ if (group[i])
164
+ ordered.push(group[i]);
165
+ }
166
+ for (let repetition = 1; repetition <= repeats; repetition++) {
167
+ for (const c of ordered) {
168
+ const root = roots.get(c.corpus);
169
+ const exclusions = new Set();
170
+ const inventory = preflight.find((r) => r.corpus === c.corpus);
171
+ for (const t of c.expected) {
172
+ const file = path.join(root, t.file);
173
+ if (fs.realpathSync(file) !== file)
174
+ throw new Error(`Target aliases are not frozen: ${c.id}`);
175
+ for (const reason of (0, relevance_baseline_1.sourceExclusions)(t.sourceSha256, (0, relevance_baseline_1.sha256)(fs.readFileSync(file)), (_b = cache.get(file)) === null || _b === void 0 ? void 0 : _b.hash))
176
+ exclusions.add(reason);
177
+ const indexed = inventory.targetRows
178
+ .flat()
179
+ .some((row) => (0, relevance_baseline_1.matchesTarget)({
180
+ type: "text",
181
+ score: 0,
182
+ metadata: { path: String(row.path), hash: "" },
183
+ defined_symbols: row.defined_symbols,
184
+ generated_metadata: {
185
+ start_line: Number(row.start_line),
186
+ num_lines: Number(row.end_line) - Number(row.start_line) + 1,
187
+ },
188
+ }, root, t));
189
+ if (!indexed)
190
+ exclusions.add("target_not_in_index");
191
+ }
192
+ const start = performance.now();
193
+ const response = exclusions.size === 0
194
+ ? yield (0, daemon_client_1.sendDaemonCommand)({
195
+ cmd: "search",
196
+ projectRoot: root,
197
+ query: c.query,
198
+ limit: 20,
199
+ pathPrefix: `${root}${path.sep}`,
200
+ rerank: false,
201
+ explain: true,
202
+ diagnostics: values.diagnostics,
203
+ }, { timeoutMs: 60000 })
204
+ : { ok: false, error: "preflight_excluded" };
205
+ const elapsedMs = performance.now() - start;
206
+ if (values.diagnostics &&
207
+ response.ok &&
208
+ ((_c = response.diagnostics) === null || _c === void 0 ? void 0 : _c.schemaVersion) !== 1)
209
+ exclusions.add("diagnostics_missing");
210
+ if (!response.ok)
211
+ exclusions.add(String((_e = response.error) !== null && _e !== void 0 ? _e : "search_failed"));
212
+ const data = Array.isArray(response.data)
213
+ ? response.data
214
+ : [];
215
+ const state = response.indexState;
216
+ if (state &&
217
+ (state.indexing ||
218
+ state.verifying ||
219
+ state.degraded ||
220
+ Number(state.pendingFiles) > 0 ||
221
+ Number(state.failedFiles) > 0))
222
+ exclusions.add("index_unsettled");
223
+ const warnings = Array.isArray(response.warnings)
224
+ ? response.warnings
225
+ : [];
226
+ if (warnings.length)
227
+ exclusions.add("search_warning");
228
+ for (const t of c.expected) {
229
+ const file = path.join(root, t.file);
230
+ for (const reason of (0, relevance_baseline_1.sourceExclusions)(t.sourceSha256, (0, relevance_baseline_1.sha256)(fs.readFileSync(file)), (_d = cache.get(file)) === null || _d === void 0 ? void 0 : _d.hash))
231
+ exclusions.add(reason);
232
+ if (data.some((d) => { var _a; return ((_a = d.metadata) === null || _a === void 0 ? void 0 : _a.path) === file && d.metadata.hash !== t.sourceSha256; }))
233
+ exclusions.add("returned_row_hash_mismatch");
234
+ }
235
+ const ranked = { data };
236
+ samples.push(Object.assign(Object.assign({}, (0, relevance_baseline_1.scoreRelevance)(ranked, root, c)), { id: c.id, corpus: c.corpus, split: c.split, repetition,
237
+ elapsedMs, exclusions: [...exclusions], query: c.query, intent: c.intent, origin: c.origin, warnings, indexState: state !== null && state !== void 0 ? state : null, error: response.ok ? null : response.error, diagnostics: (_f = response.diagnostics) !== null && _f !== void 0 ? _f : null, results: data.map((d) => { var _a, _b; var _c, _d, _e, _f, _g; return ({
238
+ path: path.relative(root, String((_c = (_a = d.metadata) === null || _a === void 0 ? void 0 : _a.path) !== null && _c !== void 0 ? _c : "")),
239
+ hash: (_d = (_b = d.metadata) === null || _b === void 0 ? void 0 : _b.hash) !== null && _d !== void 0 ? _d : null,
240
+ symbols: (_e = d.defined_symbols) !== null && _e !== void 0 ? _e : [],
241
+ range: (_f = d.generated_metadata) !== null && _f !== void 0 ? _f : null,
242
+ score: d.score,
243
+ scoreBreakdown: (_g = d.scoreBreakdown) !== null && _g !== void 0 ? _g : null,
244
+ }); }) }));
245
+ fs.appendFileSync(checkpoint, `${JSON.stringify(samples[samples.length - 1])}\n`);
246
+ console.error(`${c.id} repeat=${repetition} rank=${samples[samples.length - 1].rank} excluded=${[...exclusions].join(",") || "none"}`);
247
+ }
248
+ }
249
+ }
250
+ finally {
251
+ cache.close();
252
+ }
253
+ const afterPing = yield (0, daemon_client_1.sendDaemonCommand)({ cmd: "ping" });
254
+ const after = yield (0, daemon_client_1.sendDaemonCommand)({ cmd: "status" });
255
+ if (!afterPing.ok ||
256
+ after.pid !== before.pid ||
257
+ afterPing.version !== ping.version) {
258
+ for (const sample of samples)
259
+ sample.exclusions.push("daemon_changed");
260
+ }
261
+ if ((0, relevance_baseline_1.sha256)(fs.readFileSync(fixturePath)) !== values.sha256)
262
+ throw new Error("Frozen fixture changed during execution");
263
+ const report = {
264
+ schemaVersion: 1,
265
+ startedAt,
266
+ finishedAt: new Date().toISOString(),
267
+ fixtureSha256: values.sha256,
268
+ fixture,
269
+ source: {
270
+ git: git(process.cwd()),
271
+ node: process.version,
272
+ evaluatorHash: (0, relevance_baseline_1.sha256)(fs.readFileSync(__filename)),
273
+ scoringHash: (0, relevance_baseline_1.sha256)(fs.readFileSync(path.join(__dirname, "lib/eval/relevance-baseline.ts"))),
274
+ searcherHash: (0, relevance_baseline_1.sha256)(fs.readFileSync(path.join(__dirname, "lib/search/searcher.ts"))),
275
+ diagnosticsHash: (0, relevance_baseline_1.sha256)(fs.readFileSync(path.join(__dirname, "lib/search/diagnostics.ts"))),
276
+ },
277
+ execution: {
278
+ transport: "existing-daemon-ipc",
279
+ requestedRerank: false,
280
+ explain: true,
281
+ requestedDiagnostics: values.diagnostics,
282
+ limit: 20,
283
+ repeats,
284
+ ping,
285
+ before,
286
+ afterPing,
287
+ after,
288
+ preflight,
289
+ diagnostics: {
290
+ fusionPool: values.diagnostics
291
+ ? "bounded-post-seed-head-200"
292
+ : "unobserved",
293
+ concentrationGate: values.diagnostics
294
+ ? "per-sample-diagnostics"
295
+ : "unobserved",
296
+ daemonRankingEnvironment: values.diagnostics
297
+ ? "allowlisted-per-sample-diagnostics"
298
+ : "unobserved",
299
+ ftsHealth: values.diagnostics
300
+ ? "per-sample-diagnostics"
301
+ : "warnings-and-score-breakdowns-only",
302
+ trueRerankOff: "not-asserted",
303
+ },
304
+ },
305
+ summary: (0, relevance_baseline_1.summarizeRelevance)(samples),
306
+ byCorpus: fixture.corpora.map((r) => (Object.assign({ corpus: r.id }, (0, relevance_baseline_1.summarizeRelevance)(samples.filter((s) => s.corpus === r.id))))),
307
+ bySplit: ["dev", "heldout"].map((split) => (Object.assign({ split }, (0, relevance_baseline_1.summarizeRelevance)(samples.filter((s) => s.split === split))))),
308
+ byIntent: [...new Set(fixture.cases.map((c) => c.intent))].map((intent) => (Object.assign({ intent }, (0, relevance_baseline_1.summarizeRelevance)(samples.filter((s) => s.intent === intent))))),
309
+ samples,
310
+ researchGateReady: false,
311
+ limitations: [
312
+ "Curated source-grounded cases are not observed user-session misses",
313
+ values.diagnostics
314
+ ? "Candidate trace is limited to the post-seed fusion head; absence from a truncated trace does not prove retrieval failure"
315
+ : "Pipeline diagnostics were not requested",
316
+ "Client environment cannot change daemon ranking settings",
317
+ "Latencies mix ordinary daemon activity and first/repeated queries",
318
+ ],
319
+ };
320
+ fs.writeFileSync(values.output, `${JSON.stringify(report, null, 2)}\n`, {
321
+ flag: "wx",
322
+ });
323
+ console.log(JSON.stringify({
324
+ output: values.output,
325
+ summary: report.summary,
326
+ byCorpus: report.byCorpus,
327
+ bySplit: report.bySplit,
328
+ }, null, 2));
329
+ if (samples.some((s) => s.exclusions.length > 0))
330
+ process.exitCode = 2;
331
+ });
332
+ }
333
+ if (require.main === module)
334
+ run().catch((error) => {
335
+ console.error(error);
336
+ process.exitCode = 1;
337
+ });
package/dist/eval.js CHANGED
@@ -527,9 +527,10 @@ function evaluateCase(response, evalCase, timeMs) {
527
527
  const avoidRank = response.data.findIndex((chunk) => { var _a, _b, _c; return (_b = (_a = chunk.metadata) === null || _a === void 0 ? void 0 : _a.path) === null || _b === void 0 ? void 0 : _b.toLowerCase().includes(((_c = evalCase.avoidPath) === null || _c === void 0 ? void 0 : _c.toLowerCase()) || "_____"); });
528
528
  const hitAvoid = evalCase.avoidPath && avoidRank >= 0 && (rank === -1 || avoidRank < rank);
529
529
  const found = rank >= 0 && !hitAvoid;
530
- const rr = found ? 1 / (rank + 1) : 0;
531
530
  const recall = found && rank < 10 ? 1 : 0;
531
+ const rr = recall ? 1 / (rank + 1) : 0;
532
532
  return {
533
+ rank: rank + 1,
533
534
  rr,
534
535
  found,
535
536
  recall,
@@ -619,7 +620,7 @@ function run() {
619
620
  console.log(`Eval results for store at: ${paths.lancedbDir}`);
620
621
  console.log("=".repeat(80));
621
622
  results.forEach((r) => {
622
- const status = r.found ? `rank ${(1 / r.rr).toFixed(0)}` : "❌ missed";
623
+ const status = r.found ? `rank ${r.rank}` : "❌ missed";
623
624
  const emoji = r.found ? (r.rr === 1 ? "🎯" : "✓") : "❌";
624
625
  console.log(`${emoji} ${r.query}`);
625
626
  console.log(` => ${status} (target: ${r.path}) [${r.timeMs.toFixed(0)}ms]`);
@@ -628,7 +629,7 @@ function run() {
628
629
  }
629
630
  });
630
631
  console.log("=".repeat(80));
631
- console.log(`MRR: ${mrr.toFixed(3)}`);
632
+ console.log(`MRR@10: ${mrr.toFixed(3)}`);
632
633
  console.log(`Recall@10: ${recallAt10.toFixed(3)}`);
633
634
  console.log(`Avg query time: ${avgTime.toFixed(0)}ms`);
634
635
  console.log(`Total time: ${totalTime.toFixed(0)}ms`);
@@ -152,6 +152,7 @@ function handleCommand(daemon, cmd, conn) {
152
152
  capabilities: {
153
153
  exclusiveGenerationRebuild: exports.EXCLUSIVE_GENERATION_REBUILD_PROTOCOL,
154
154
  readVerbs: read_verbs_1.READ_VERBS_PROTOCOL,
155
+ searchDiagnostics: 1,
155
156
  // `watch`/`unwatch` accept holder/pid/ttlMs. A daemon without this
156
157
  // treats `unwatch` as unwatch-for-everyone, so clients releasing a
157
158
  // single lease must check it first.
@@ -299,6 +300,7 @@ function handleCommand(daemon, cmd, conn) {
299
300
  pathPrefix,
300
301
  rerank: cmd.rerank === true,
301
302
  explain: cmd.explain === true,
303
+ diagnostics: cmd.diagnostics === true,
302
304
  seeds: cmd.seeds &&
303
305
  typeof cmd.seeds === "object" &&
304
306
  !Array.isArray(cmd.seeds)
@@ -103,6 +103,7 @@ function handleDaemonSearch(deps, payload, signal) {
103
103
  result = yield searcher.search(payload.query, payload.limit, {
104
104
  rerank: payload.rerank === true,
105
105
  explain: payload.explain === true,
106
+ diagnostics: payload.diagnostics === true,
106
107
  seeds: payload.seeds,
107
108
  }, payload.filters, payload.pathPrefix, undefined, signal);
108
109
  }
@@ -116,6 +117,8 @@ function handleDaemonSearch(deps, payload, signal) {
116
117
  const response = { ok: true, data: result.data };
117
118
  if ((_c = result.warnings) === null || _c === void 0 ? void 0 : _c.length)
118
119
  response.warnings = result.warnings;
120
+ if (payload.diagnostics === true && result.diagnostics)
121
+ response.diagnostics = result.diagnostics;
119
122
  // Annotate partial results when the index is still catching up, so an
120
123
  // agent can caveat or retry. Settled failures remain visible too.
121
124
  const idx = deps.getIndexState(root);
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.relevanceFixtureSchema = void 0;
37
+ exports.sha256 = sha256;
38
+ exports.parseFrozenFixture = parseFrozenFixture;
39
+ exports.matchesTarget = matchesTarget;
40
+ exports.scoreRelevance = scoreRelevance;
41
+ exports.summarizeRelevance = summarizeRelevance;
42
+ exports.sourceExclusions = sourceExclusions;
43
+ const node_crypto_1 = require("node:crypto");
44
+ const path = __importStar(require("node:path"));
45
+ const zod_1 = require("zod");
46
+ const digest = zod_1.z.string().regex(/^[a-f0-9]{64}$/);
47
+ const targetSchema = zod_1.z
48
+ .object({
49
+ file: zod_1.z.string().min(1),
50
+ symbol: zod_1.z.string().min(1),
51
+ startLine: zod_1.z.number().int().positive(),
52
+ endLine: zod_1.z.number().int().positive(),
53
+ sourceSha256: digest,
54
+ })
55
+ .strict()
56
+ .refine((t) => t.endLine >= t.startLine, "Reversed target range");
57
+ exports.relevanceFixtureSchema = zod_1.z
58
+ .object({
59
+ schemaVersion: zod_1.z.literal(1),
60
+ createdAt: zod_1.z.string().min(1),
61
+ purpose: zod_1.z.string().min(1),
62
+ corpora: zod_1.z
63
+ .array(zod_1.z
64
+ .object({
65
+ id: zod_1.z.string().min(1),
66
+ root: zod_1.z.string().min(1),
67
+ language: zod_1.z.string().min(1),
68
+ })
69
+ .strict())
70
+ .min(1),
71
+ cases: zod_1.z
72
+ .array(zod_1.z
73
+ .object({
74
+ id: zod_1.z.string().min(1),
75
+ corpus: zod_1.z.string().min(1),
76
+ split: zod_1.z.enum(["dev", "heldout"]),
77
+ origin: zod_1.z.enum(["curated-source", "observed-session"]),
78
+ intent: zod_1.z.string().min(1),
79
+ query: zod_1.z.string().min(1),
80
+ expected: zod_1.z.array(targetSchema).min(1),
81
+ })
82
+ .strict())
83
+ .min(1),
84
+ })
85
+ .strict()
86
+ .superRefine((fixture, ctx) => {
87
+ for (const values of [fixture.corpora, fixture.cases]) {
88
+ if (new Set(values.map((v) => v.id)).size !== values.length) {
89
+ ctx.addIssue({ code: "custom", message: "Duplicate fixture IDs" });
90
+ }
91
+ }
92
+ for (const c of fixture.cases) {
93
+ if (!fixture.corpora.some((r) => r.id === c.corpus)) {
94
+ ctx.addIssue({
95
+ code: "custom",
96
+ message: `Unknown corpus: ${c.corpus}`,
97
+ });
98
+ }
99
+ for (const target of c.expected) {
100
+ if (path.isAbsolute(target.file) ||
101
+ target.file.split(/[\\/]/).includes("..")) {
102
+ ctx.addIssue({
103
+ code: "custom",
104
+ message: "Targets must be contained relative paths",
105
+ });
106
+ }
107
+ }
108
+ }
109
+ });
110
+ function sha256(bytes) {
111
+ return (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
112
+ }
113
+ function parseFrozenFixture(bytes, expectedSha256) {
114
+ if (!digest.safeParse(expectedSha256).success ||
115
+ sha256(bytes) !== expectedSha256) {
116
+ throw new Error("Fixture checksum mismatch; freeze the reviewed fixture before measuring");
117
+ }
118
+ return exports.relevanceFixtureSchema.parse(JSON.parse(bytes.toString("utf8")));
119
+ }
120
+ /** Exact file plus definition symbol OR overlap with a source-grounded line range. */
121
+ function matchesTarget(chunk, root, target) {
122
+ var _a, _b, _c, _d;
123
+ if (typeof ((_a = chunk.metadata) === null || _a === void 0 ? void 0 : _a.path) !== "string")
124
+ return false;
125
+ if (path.resolve(chunk.metadata.path) !== path.resolve(root, target.file))
126
+ return false;
127
+ if ((_b = chunk.defined_symbols) === null || _b === void 0 ? void 0 : _b.includes(target.symbol))
128
+ return true;
129
+ const start = (_c = chunk.generated_metadata) === null || _c === void 0 ? void 0 : _c.start_line;
130
+ const count = (_d = chunk.generated_metadata) === null || _d === void 0 ? void 0 : _d.num_lines;
131
+ if (typeof start !== "number" || typeof count !== "number" || count <= 0)
132
+ return false;
133
+ // SearchResponse ranges start at zero; source fixtures use one-based lines.
134
+ return start + 1 <= target.endLine && start + count >= target.startLine;
135
+ }
136
+ function scoreRelevance(response, root, c) {
137
+ const index = response.data.findIndex((chunk) => c.expected.some((t) => matchesTarget(chunk, root, t)));
138
+ const rank = index + 1;
139
+ return {
140
+ rank,
141
+ rrAt10: rank > 0 && rank <= 10 ? 1 / rank : 0,
142
+ recallAt10: rank > 0 && rank <= 10 ? 1 : 0,
143
+ hitAt1: rank === 1 ? 1 : 0,
144
+ found: rank > 0,
145
+ };
146
+ }
147
+ function summarizeRelevance(samples) {
148
+ const valid = samples.filter((s) => s.exclusions.length === 0);
149
+ const times = valid.map((s) => s.elapsedMs).sort((a, b) => a - b);
150
+ const mean = (key) => valid.length
151
+ ? valid.reduce((sum, s) => sum + s[key], 0) / valid.length
152
+ : null;
153
+ return {
154
+ cases: new Set(samples.map((s) => s.id)).size,
155
+ samples: samples.length,
156
+ validSamples: valid.length,
157
+ excludedSamples: samples.length - valid.length,
158
+ mrrAt10: mean("rrAt10"),
159
+ recallAt10: mean("recallAt10"),
160
+ hitsAt1: mean("hitAt1"),
161
+ medianMs: times.length ? times[Math.ceil(times.length / 2) - 1] : null,
162
+ p95Ms: times.length ? times[Math.ceil(times.length * 0.95) - 1] : null,
163
+ };
164
+ }
165
+ function sourceExclusions(frozenHash, sourceHash, cacheHash) {
166
+ const reasons = [];
167
+ if (sourceHash !== frozenHash)
168
+ reasons.push("source_changed");
169
+ if (cacheHash !== frozenHash)
170
+ reasons.push("index_cache_hash_mismatch");
171
+ return reasons;
172
+ }
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SearchDiagnosticCollector = void 0;
4
+ const stages = [
5
+ "vector",
6
+ "fts",
7
+ "rrf",
8
+ "fusion",
9
+ "stage1",
10
+ "pooled",
11
+ "rerank",
12
+ "scored",
13
+ "dedup",
14
+ "final",
15
+ ];
16
+ const rankingKeys = [
17
+ "GMAX_PRE_K",
18
+ "GMAX_STAGE1_K",
19
+ "GMAX_STAGE2_K",
20
+ "GMAX_RERANK_TOP",
21
+ "GMAX_RERANK_BLEND",
22
+ "GMAX_MAX_PER_FILE",
23
+ "GMAX_DEF_BOOST",
24
+ "GMAX_CONCENTRATION_THRESHOLD",
25
+ "GMAX_ANCHOR_PENALTY",
26
+ "GMAX_TEST_PENALTY",
27
+ "GMAX_DOC_PENALTY",
28
+ "GMAX_PAGERANK",
29
+ "GMAX_PR_WEIGHT",
30
+ "GMAX_SEED_FILE_W",
31
+ "GMAX_SEED_SYMBOL_DEF_W",
32
+ "GMAX_SEED_SYMBOL_REF_W",
33
+ "GMAX_SEED_MAX_RANK",
34
+ "GMAX_ANN",
35
+ "GMAX_ANN_NPROBES",
36
+ "GMAX_ANN_MAX_NPROBES",
37
+ ];
38
+ const key = (r) => r.id || `${r.path}:${r.chunk_index}`;
39
+ /** Request-local pointer trace. No content, vectors or additional database reads. */
40
+ class SearchDiagnosticCollector {
41
+ constructor() {
42
+ this.ranks = new Map();
43
+ this.cohort = [];
44
+ this.total = 0;
45
+ this.fileLimited = new Set();
46
+ }
47
+ noteFileLimit(record) {
48
+ this.fileLimited.add(key(record));
49
+ }
50
+ outcome(record) {
51
+ var _a, _b, _c, _d, _e, _f;
52
+ const id = key(record);
53
+ if ((_a = this.ranks.get("final")) === null || _a === void 0 ? void 0 : _a.has(id))
54
+ return "returned";
55
+ if (!((_b = this.ranks.get("stage1")) === null || _b === void 0 ? void 0 : _b.has(id)))
56
+ return "stage1-cut";
57
+ if (!((_c = this.ranks.get("pooled")) === null || _c === void 0 ? void 0 : _c.has(id)) &&
58
+ !((_d = this.ranks.get("scored")) === null || _d === void 0 ? void 0 : _d.has(id)))
59
+ return "pooled-cut";
60
+ if (!((_e = this.ranks.get("scored")) === null || _e === void 0 ? void 0 : _e.has(id)))
61
+ return "not-scored";
62
+ if (!((_f = this.ranks.get("dedup")) === null || _f === void 0 ? void 0 : _f.has(id)))
63
+ return "deduplicated";
64
+ if (this.fileLimited.has(id))
65
+ return "per-file-limit";
66
+ // After the display window fills, remaining candidates are unexamined.
67
+ return "display-limit";
68
+ }
69
+ capture(stage, records) {
70
+ this.ranks.set(stage, new Map(records.map((r, i) => [key(r), i + 1])));
71
+ if (stage === "fusion") {
72
+ this.total = records.length;
73
+ this.cohort = records.slice(0, 200);
74
+ }
75
+ }
76
+ finish(details) {
77
+ return Object.assign(Object.assign({ schemaVersion: 1 }, details), { rankingEnvironment: Object.fromEntries(rankingKeys.map((name) => { var _a; return [name, (_a = process.env[name]) !== null && _a !== void 0 ? _a : null]; })), stages: Object.fromEntries(stages.map((s) => { var _a; var _b; return [s, (_b = (_a = this.ranks.get(s)) === null || _a === void 0 ? void 0 : _a.size) !== null && _b !== void 0 ? _b : 0]; })), trace: {
78
+ cohort: "post-seed-fusion-head",
79
+ limit: 200,
80
+ total: this.total,
81
+ truncated: this.total > this.cohort.length,
82
+ candidates: this.cohort.map((r) => { var _a; return ({
83
+ id: key(r),
84
+ path: r.path,
85
+ hash: r.hash,
86
+ startLine: r.start_line + 1,
87
+ endLine: r.end_line + 1,
88
+ parentSymbol: (_a = r.parent_symbol) !== null && _a !== void 0 ? _a : "",
89
+ outcome: this.outcome(r),
90
+ ranks: Object.fromEntries(stages.map((s) => { var _a; var _b; return [s, (_b = (_a = this.ranks.get(s)) === null || _a === void 0 ? void 0 : _a.get(key(r))) !== null && _b !== void 0 ? _b : 0]; })),
91
+ }); }),
92
+ } });
93
+ }
94
+ }
95
+ exports.SearchDiagnosticCollector = SearchDiagnosticCollector;
@@ -18,6 +18,7 @@ const filter_builder_1 = require("../utils/filter-builder");
18
18
  const logger_1 = require("../utils/logger");
19
19
  const query_timeout_1 = require("../utils/query-timeout");
20
20
  const pool_1 = require("../workers/pool");
21
+ const diagnostics_1 = require("./diagnostics");
21
22
  const intent_1 = require("./intent");
22
23
  const pagerank_1 = require("./pagerank");
23
24
  const seed_weight_1 = require("./seed-weight");
@@ -403,6 +404,14 @@ class Searcher {
403
404
  // fused scores ~30:1 so blend tuning can't recover the loss.
404
405
  let doRerank = (_a = _search_options === null || _search_options === void 0 ? void 0 : _search_options.rerank) !== null && _a !== void 0 ? _a : false;
405
406
  const explain = (_b = _search_options === null || _search_options === void 0 ? void 0 : _search_options.explain) !== null && _b !== void 0 ? _b : false;
407
+ const diagnostics = (_search_options === null || _search_options === void 0 ? void 0 : _search_options.diagnostics)
408
+ ? new diagnostics_1.SearchDiagnosticCollector()
409
+ : undefined;
410
+ const requestedRerank = doRerank;
411
+ let gateEvaluated = false;
412
+ let gateShare = null;
413
+ let gateActivated = false;
414
+ let rerankInvoked = false;
406
415
  // Aider-style seeding (Phase 4): bias candidate generation toward the
407
416
  // agent's working context. Inert unless the caller supplied seed files or
408
417
  // symbols, so the default search path is unchanged.
@@ -569,6 +578,9 @@ class Searcher {
569
578
  .sort((a, b) => b[1] - a[1])
570
579
  .map(([key]) => docMap.get(key))
571
580
  .filter(Boolean);
581
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.capture("vector", vectorResults);
582
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.capture("fts", ftsResults);
583
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.capture("rrf", fused);
572
584
  // Free raw search results — docMap holds the only needed references
573
585
  vectorResults.length = 0;
574
586
  ftsResults.length = 0;
@@ -618,16 +630,16 @@ class Searcher {
618
630
  // pools (express, platform) it regresses. So detect the concentrated regime
619
631
  // here and *add* rerank-on for it. This only ever flips doRerank false→true:
620
632
  // an explicit GMAX_RERANK=1 (doRerank already true) is never overridden off.
633
+ const envConcThreshold = Number.parseFloat((_f = process.env.GMAX_CONCENTRATION_THRESHOLD) !== null && _f !== void 0 ? _f : "");
634
+ // <= 0 (or NaN with the default) keeps the gate active at 0.7; a value > 1
635
+ // disables it (no possible share reaches it), giving a rerank-fully-off
636
+ // baseline for sweeps without touching the doRerank default. 0.7 is the
637
+ // sweep winner: highest threshold (least spurious firing) that still
638
+ // retains lodash's +0.15 MRR lift while leaving express/platform flat.
639
+ const CONCENTRATION_THRESHOLD = Number.isFinite(envConcThreshold) && envConcThreshold > 0
640
+ ? envConcThreshold
641
+ : 0.7;
621
642
  if (!doRerank) {
622
- const envConcThreshold = Number.parseFloat((_f = process.env.GMAX_CONCENTRATION_THRESHOLD) !== null && _f !== void 0 ? _f : "");
623
- // <= 0 (or NaN with the default) keeps the gate active at 0.7; a value > 1
624
- // disables it (no possible share reaches it), giving a rerank-fully-off
625
- // baseline for sweeps without touching the doRerank default. 0.7 is the
626
- // sweep winner: highest threshold (least spurious firing) that still
627
- // retains lodash's +0.15 MRR lift while leaving express/platform flat.
628
- const CONCENTRATION_THRESHOLD = Number.isFinite(envConcThreshold) && envConcThreshold > 0
629
- ? envConcThreshold
630
- : 0.7;
631
643
  // Histogram a fixed top-K window (not finalLimit) so the threshold stays
632
644
  // calibrated across callers regardless of the result window they request.
633
645
  const CONCENTRATION_K = 10;
@@ -643,16 +655,21 @@ class Searcher {
643
655
  maxBucket = count;
644
656
  }
645
657
  const share = maxBucket / window.length;
658
+ gateEvaluated = true;
659
+ gateShare = share;
646
660
  if (share >= CONCENTRATION_THRESHOLD) {
647
661
  doRerank = true;
662
+ gateActivated = true;
648
663
  }
649
664
  }
650
665
  }
666
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.capture("fusion", fused);
651
667
  // Item 8: Widen PRE_RERANK_K
652
668
  // Retrieve a wide set for Stage 1 filtering
653
669
  const envStage1 = Number.parseInt((_h = process.env.GMAX_STAGE1_K) !== null && _h !== void 0 ? _h : "", 10);
654
670
  const STAGE1_K = Number.isFinite(envStage1) && envStage1 > 0 ? envStage1 : 200;
655
671
  const topCandidates = fused.slice(0, STAGE1_K);
672
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.capture("stage1", topCandidates);
656
673
  // Free docMap — topCandidates already holds record references
657
674
  docMap.clear();
658
675
  // Item 9: Two-stage rerank
@@ -694,13 +711,44 @@ class Searcher {
694
711
  withScore.sort((a, b) => b.score - a.score);
695
712
  stage2Candidates = withScore.slice(0, STAGE2_K).map((x) => x.doc);
696
713
  }
714
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.capture("pooled", stage2Candidates);
715
+ const diagnosticDetails = () => ({
716
+ settings: {
717
+ finalLimit,
718
+ preK: PRE_RERANK_K,
719
+ rrfK: RRF_K,
720
+ stage1K: STAGE1_K,
721
+ stage2K: STAGE2_K,
722
+ rerankTop: RERANK_TOP,
723
+ fusedWeight: FUSED_WEIGHT,
724
+ pagerankEnabled,
725
+ seedsActive: seedCtx.active,
726
+ symbolQuery,
727
+ pooledFilterApplied: !!queryPooled && topCandidates.length > STAGE2_K,
728
+ },
729
+ gate: {
730
+ requestedRerank,
731
+ threshold: CONCENTRATION_THRESHOLD,
732
+ evaluated: gateEvaluated,
733
+ share: gateShare,
734
+ activated: gateActivated,
735
+ rerankInvoked,
736
+ },
737
+ fts: { available: this.ftsAvailable, searchFailed: ftsSearchFailed },
738
+ });
697
739
  if (stage2Candidates.length === 0) {
698
- return { data: [] };
740
+ return Object.assign({ data: [] }, (diagnostics
741
+ ? { diagnostics: diagnostics.finish(diagnosticDetails()) }
742
+ : {}));
699
743
  }
700
744
  const envMaxPerFile = Number.parseInt((_m = process.env.GMAX_MAX_PER_FILE) !== null && _m !== void 0 ? _m : "", 10);
701
745
  const MAX_PER_FILE = Number.isFinite(envMaxPerFile) && envMaxPerFile > 0 ? envMaxPerFile : 3;
702
- const displayWindow = Math.min(stage2Candidates.length, Math.max(finalLimit * MAX_PER_FILE, finalLimit, RERANK_TOP));
703
- const displayCandidates = stage2Candidates.slice(0, displayWindow);
746
+ // Rank the entire bounded candidate pool before trimming the result count.
747
+ // A limit-dependent cut here discarded contenders before structure boosts
748
+ // and dedup, so requesting three results could hide the top match returned
749
+ // by a ten-result request. Expensive reranking stays independently bounded;
750
+ // display columns are still fetched only for the final results below.
751
+ const displayCandidates = [...stage2Candidates];
704
752
  const rerankCandidates = stage2Candidates.slice(0, RERANK_TOP);
705
753
  // Symbol-definition promotion (1/2): membership. For a bare-symbol query,
706
754
  // ensure the chunk(s) that actually DEFINE the symbol reach the rerank set
@@ -731,6 +779,8 @@ class Searcher {
731
779
  }
732
780
  }
733
781
  }
782
+ // Selected rerank membership is separate from actual batch execution.
783
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.capture("rerank", rerankCandidates);
734
784
  // Phase B: Lazy-load colbert data only for the ~20 rerank candidates
735
785
  if (doRerank && rerankCandidates.length > 0) {
736
786
  const rerankIds = rerankCandidates
@@ -757,6 +807,7 @@ class Searcher {
757
807
  }
758
808
  const rerankScoreByKey = new Map();
759
809
  if (doRerank && rerankCandidates.length > 0) {
810
+ rerankInvoked = true;
760
811
  const scores = yield pool.rerank({
761
812
  query: queryMatrixRaw,
762
813
  docs: rerankCandidates.map((doc) => { var _a; return ({
@@ -843,8 +894,10 @@ class Searcher {
843
894
  }
844
895
  // Note: "boosted" was not previously declared -- fix to use "scored"
845
896
  scored.sort((a, b) => b.score - a.score);
897
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.capture("scored", scored.map((item) => item.record));
846
898
  // Item 11: Intelligent Deduplication
847
899
  const uniqueScored = this.deduplicateResults(scored);
900
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.capture("dedup", uniqueScored.map((item) => item.record));
848
901
  // Item 10: Per-file diversification
849
902
  const seenFiles = new Map();
850
903
  const diversified = [];
@@ -855,9 +908,13 @@ class Searcher {
855
908
  diversified.push(item);
856
909
  seenFiles.set(path, count + 1);
857
910
  }
911
+ else {
912
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.noteFileLimit(item.record);
913
+ }
858
914
  if (diversified.length >= finalLimit)
859
915
  break;
860
916
  }
917
+ diagnostics === null || diagnostics === void 0 ? void 0 : diagnostics.capture("final", diversified.map((item) => item.record));
861
918
  // Phase C: Lazy-load display columns only for the final ~10 results
862
919
  const finalIds = diversified
863
920
  .map((item) => item.record.id)
@@ -894,7 +951,7 @@ class Searcher {
894
951
  const finalResults = diversified.map((item) => (Object.assign(Object.assign({}, item.record), { _score: item.score, _breakdown: item.breakdown, vector: undefined, colbert: undefined })));
895
952
  // Item 12: Score Calibration
896
953
  const maxScore = finalResults.length > 0 ? finalResults[0]._score : 1.0;
897
- return Object.assign({ data: finalResults.map((r) => {
954
+ return Object.assign(Object.assign({ data: finalResults.map((r) => {
898
955
  const chunk = this.mapRecordToChunk(r, r._score || 0);
899
956
  // Normalize score relative to top result
900
957
  const normalized = maxScore > 0 ? r._score / maxScore : 0;
@@ -909,7 +966,11 @@ class Searcher {
909
966
  }
910
967
  chunk.confidence = confidence;
911
968
  return chunk;
912
- }) }, (!this.ftsAvailable || ftsSearchFailed
969
+ }) }, (diagnostics
970
+ ? {
971
+ diagnostics: diagnostics.finish(Object.assign(Object.assign({}, diagnosticDetails()), { settings: Object.assign(Object.assign({}, diagnosticDetails().settings), { maxPerFile: MAX_PER_FILE, definitionBoost: DEF_MATCH_BOOST }) })),
972
+ }
973
+ : {})), (!this.ftsAvailable || ftsSearchFailed
913
974
  ? {
914
975
  warnings: [
915
976
  "Full-text search unavailable — results may be less precise",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.26.47",
3
+ "version": "0.26.49",
4
4
  "author": "Robert Owens <78518764+reowens@users.noreply.github.com>",
5
5
  "homepage": "https://github.com/reowens/grepmax",
6
6
  "bugs": {
@@ -29,6 +29,7 @@
29
29
  "bench:recall:json": "GMAX_EVAL_JSON=1 npx tsx src/eval.ts",
30
30
  "bench:oss": "npx tsx src/eval-oss.ts all",
31
31
  "bench:oss:json": "GMAX_EVAL_JSON=1 npx tsx src/eval-oss.ts all",
32
+ "bench:relevance": "tsx src/eval-relevance.ts",
32
33
  "bench:tokens": "npx tsx src/eval-tokens.ts",
33
34
  "bench:tokens:json": "GMAX_EVAL_JSON=1 npx tsx src/eval-tokens.ts",
34
35
  "bench:surprises": "npx tsx src/eval-surprising-connections.ts",
@@ -76,7 +77,7 @@
76
77
  "NOTICE"
77
78
  ],
78
79
  "license": "Apache-2.0",
79
- "description": "Semantic code search for coding agents. Local embeddings, LLM summaries, call graph tracing.",
80
+ "description": "Semantic code search for coding agents. Local embeddings, call graph tracing, compact code context.",
80
81
  "dependencies": {
81
82
  "@clack/prompts": "^1.8.1",
82
83
  "@huggingface/transformers": "^4.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grepmax",
3
- "version": "0.26.47",
3
+ "version": "0.26.49",
4
4
  "description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
5
5
  "author": {
6
6
  "name": "Robert Owens",