@davesheffer/hunch 0.22.0 → 0.23.0

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.
package/dist/cli/index.js CHANGED
@@ -42,6 +42,7 @@ import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
42
42
  import { formatContext } from "../core/format.js";
43
43
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
44
44
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
45
+ import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
45
46
  import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
46
47
  import { constraintId } from "../core/ids.js";
47
48
  import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
@@ -409,6 +410,49 @@ program
409
410
  }
410
411
  store.close();
411
412
  });
413
+ // ---- eval (retrieval quality; measures the graph-stream lift) --------------
414
+ program
415
+ .command("eval")
416
+ .description("Measure retrieval quality (Recall@k, MRR) over a golden set, and A/B the dependency-graph stream.")
417
+ .requiredOption("--file <path>", "golden set JSON: [{ query, expected: [refs], note? }]")
418
+ .option("--k <n>", "top-k cutoff", "10")
419
+ .option("--semantic", "also blend the semantic stream (requires `hunch embed`; default is deterministic FTS + graph)")
420
+ .action(async (opts) => {
421
+ const { store } = storeFor();
422
+ store.reindex(); // reflect any out-of-band JSON edits before scoring
423
+ let cases;
424
+ try {
425
+ cases = loadGoldenSet(readFileSync(opts.file, "utf8"));
426
+ }
427
+ catch (e) {
428
+ store.close();
429
+ return fail(`could not load golden set: ${e.message}`);
430
+ }
431
+ if (!cases.length) {
432
+ store.close();
433
+ return fail("golden set is empty");
434
+ }
435
+ const k = Math.max(1, parseInt(opts.k, 10) || 10);
436
+ // Default is deterministic (FTS + graph, no model). --semantic only adds the
437
+ // semantic leg when embeddings actually exist; otherwise it's still FTS + graph.
438
+ const embedder = opts.semantic ? await selectEmbedder() : undefined;
439
+ const lift = await evaluateGraphLift(store, cases, { k, embedder });
440
+ const pct = (x) => `${(x * 100).toFixed(1)}%`;
441
+ const dpt = (x) => `${x >= 0 ? "+" : ""}${(x * 100).toFixed(1)}pt`;
442
+ const dnum = (x) => `${x >= 0 ? "+" : ""}${x.toFixed(3)}`;
443
+ console.log(`Eval over ${cases.length} case(s), k=${k}${opts.semantic ? " (semantic + graph + FTS)" : " (FTS + graph)"}\n`);
444
+ console.log(` Recall@${k} MRR hit-rate`);
445
+ console.log(` graph OFF ${pct(lift.off.recallAtK).padStart(7)} ${lift.off.mrr.toFixed(3)} ${pct(lift.off.hitRate)}`);
446
+ console.log(` graph ON ${pct(lift.on.recallAtK).padStart(7)} ${lift.on.mrr.toFixed(3)} ${pct(lift.on.hitRate)}`);
447
+ console.log(` graph LIFT ${dpt(lift.recallDelta).padStart(7)} ${dnum(lift.mrrDelta)}`);
448
+ const misses = lift.on.perCase.filter((c) => c.found === 0);
449
+ if (misses.length) {
450
+ console.log(`\n ${misses.length} case(s) with no expected hit — curate or tune:`);
451
+ for (const m of misses.slice(0, 10))
452
+ console.log(` · "${m.query}"`);
453
+ }
454
+ store.close();
455
+ });
412
456
  // ---- embed (opt-in semantic search) ---------------------------------------
413
457
  program
414
458
  .command("embed")
@@ -0,0 +1,55 @@
1
+ /** Score a golden set: Recall@k, MRR, hit-rate. Deterministic when no embedder. */
2
+ export async function evaluateRetrieval(store, cases, opts = {}) {
3
+ const k = opts.k ?? 10;
4
+ const perCase = [];
5
+ for (const c of cases) {
6
+ const hits = await store.hybridSearch(c.query, k, { embedder: opts.embedder, graphWeight: opts.graphWeight });
7
+ const top = hits.slice(0, k).map((h) => h.ref);
8
+ const expected = new Set(c.expected);
9
+ let found = 0;
10
+ let rr = 0;
11
+ top.forEach((ref, i) => {
12
+ if (!expected.has(ref))
13
+ return;
14
+ found++;
15
+ if (rr === 0)
16
+ rr = 1 / (i + 1); // first expected hit sets the reciprocal rank
17
+ });
18
+ perCase.push({
19
+ query: c.query,
20
+ expected: c.expected.length,
21
+ found,
22
+ recall: c.expected.length ? found / c.expected.length : 0,
23
+ rr,
24
+ });
25
+ }
26
+ const n = perCase.length || 1;
27
+ return {
28
+ n: perCase.length,
29
+ k,
30
+ recallAtK: perCase.reduce((s, r) => s + r.recall, 0) / n,
31
+ mrr: perCase.reduce((s, r) => s + r.rr, 0) / n,
32
+ hitRate: perCase.reduce((s, r) => s + (r.found > 0 ? 1 : 0), 0) / n,
33
+ perCase,
34
+ };
35
+ }
36
+ /** Compare graph-OFF vs graph-ON on the same golden set — the #1 lift measurement. */
37
+ export async function evaluateGraphLift(store, cases, opts = {}) {
38
+ const off = await evaluateRetrieval(store, cases, { ...opts, graphWeight: 0 });
39
+ // graphWeight undefined -> hybridSearch uses the configured default; an explicit
40
+ // value tunes it. Either way "on" is whatever ships, "off" is the baseline.
41
+ const on = await evaluateRetrieval(store, cases, opts);
42
+ return { off, on, recallDelta: on.recallAtK - off.recallAtK, mrrDelta: on.mrr - off.mrr };
43
+ }
44
+ /** Parse + validate a golden-set JSON string (array of {query, expected[]}). */
45
+ export function loadGoldenSet(raw) {
46
+ const data = JSON.parse(raw);
47
+ if (!Array.isArray(data))
48
+ throw new Error("golden set must be a JSON array of { query, expected[] }");
49
+ return data.map((c, i) => {
50
+ if (!c || typeof c.query !== "string" || !Array.isArray(c.expected))
51
+ throw new Error(`golden case ${i} must be { query: string, expected: string[] }`);
52
+ return { query: c.query, expected: c.expected.map(String), note: typeof c.note === "string" ? c.note : undefined };
53
+ });
54
+ }
55
+ //# sourceMappingURL=harness.js.map
@@ -301,6 +301,9 @@ export class HunchStore {
301
301
  if (opts.embedder === null)
302
302
  return this.search(query, limit);
303
303
  const embedder = opts.embedder !== undefined ? opts.embedder : await selectEmbedder();
304
+ // graphWeight override lets the eval harness A/B the graph stream (0 = off) on one
305
+ // store without re-loading the module const; defaults to the configured weight.
306
+ const gw = opts.graphWeight ?? RRF_W_GRAPH;
304
307
  const fts = this.search(query, Math.max(limit, 50));
305
308
  let sem = [];
306
309
  if (embedder && this.semanticReady(embedder)) {
@@ -318,10 +321,10 @@ export class HunchStore {
318
321
  // The graph stream is model-free, so it contributes even on a lean (no-embeddings)
319
322
  // install. With neither semantic nor graph signal, return pure FTS so the
320
323
  // zero-fusion-overhead fast path is preserved.
321
- const graph = this.graphExpand([...fts, ...sem], 50);
324
+ const graph = this.graphExpand([...fts, ...sem], 50, gw);
322
325
  if (!sem.length && !graph.length)
323
326
  return fts.slice(0, limit);
324
- return this.rrfFuse(fts, sem, graph, limit);
327
+ return this.rrfFuse(fts, sem, graph, limit, gw);
325
328
  }
326
329
  /** Brute-force exact cosine top-n over stored vectors for one model. Vectors are
327
330
  * pre-normalized, so cosine == dot product. Scoped to `dim = qvec.length` so a
@@ -361,7 +364,7 @@ export class HunchStore {
361
364
  * weight keeps exact symbol/path matches from being displaced by paraphrase or
362
365
  * neighbor hits. An empty list contributes nothing, so 2-stream behavior is exactly
363
366
  * preserved when graph (or sem) is absent. */
364
- rrfFuse(fts, sem, graph, limit) {
367
+ rrfFuse(fts, sem, graph, limit, graphWeight = RRF_W_GRAPH) {
365
368
  const acc = new Map();
366
369
  const add = (list, weight) => list.forEach((hit, i) => {
367
370
  const e = acc.get(hit.ref) ?? { hit, score: 0 };
@@ -370,7 +373,7 @@ export class HunchStore {
370
373
  });
371
374
  add(fts, RRF_W_FTS);
372
375
  add(sem, RRF_W_SEM);
373
- add(graph, RRF_W_GRAPH);
376
+ add(graph, graphWeight);
374
377
  return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, limit).map((e) => ({ ...e.hit, score: e.score }));
375
378
  }
376
379
  /** Graph retrieval stream (roadmap #1): 1-hop expansion over the dependency graph
@@ -380,8 +383,8 @@ export class HunchStore {
380
383
  * neighbor accrues GAMMA-decayed support per linking seed (one pulled in by several
381
384
  * top seeds ranks higher); seeds themselves are excluded, so this only ADDS context.
382
385
  * Deterministic, model-free (runs on a lean install too), one indexed query per seed. */
383
- graphExpand(seeds, n) {
384
- if (RRF_W_GRAPH <= 0)
386
+ graphExpand(seeds, n, weight = RRF_W_GRAPH) {
387
+ if (weight <= 0)
385
388
  return [];
386
389
  const symSeeds = seeds.filter((h) => h.ref.startsWith("sym_"));
387
390
  if (!symSeeds.length)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",