@davesheffer/hunch 0.22.0 → 0.24.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,8 @@ 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";
46
+ import { computeDrift } from "../core/drift.js";
45
47
  import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
46
48
  import { constraintId } from "../core/ids.js";
47
49
  import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
@@ -409,6 +411,49 @@ program
409
411
  }
410
412
  store.close();
411
413
  });
414
+ // ---- eval (retrieval quality; measures the graph-stream lift) --------------
415
+ program
416
+ .command("eval")
417
+ .description("Measure retrieval quality (Recall@k, MRR) over a golden set, and A/B the dependency-graph stream.")
418
+ .requiredOption("--file <path>", "golden set JSON: [{ query, expected: [refs], note? }]")
419
+ .option("--k <n>", "top-k cutoff", "10")
420
+ .option("--semantic", "also blend the semantic stream (requires `hunch embed`; default is deterministic FTS + graph)")
421
+ .action(async (opts) => {
422
+ const { store } = storeFor();
423
+ store.reindex(); // reflect any out-of-band JSON edits before scoring
424
+ let cases;
425
+ try {
426
+ cases = loadGoldenSet(readFileSync(opts.file, "utf8"));
427
+ }
428
+ catch (e) {
429
+ store.close();
430
+ return fail(`could not load golden set: ${e.message}`);
431
+ }
432
+ if (!cases.length) {
433
+ store.close();
434
+ return fail("golden set is empty");
435
+ }
436
+ const k = Math.max(1, parseInt(opts.k, 10) || 10);
437
+ // Default is deterministic (FTS + graph, no model). --semantic only adds the
438
+ // semantic leg when embeddings actually exist; otherwise it's still FTS + graph.
439
+ const embedder = opts.semantic ? await selectEmbedder() : undefined;
440
+ const lift = await evaluateGraphLift(store, cases, { k, embedder });
441
+ const pct = (x) => `${(x * 100).toFixed(1)}%`;
442
+ const dpt = (x) => `${x >= 0 ? "+" : ""}${(x * 100).toFixed(1)}pt`;
443
+ const dnum = (x) => `${x >= 0 ? "+" : ""}${x.toFixed(3)}`;
444
+ console.log(`Eval over ${cases.length} case(s), k=${k}${opts.semantic ? " (semantic + graph + FTS)" : " (FTS + graph)"}\n`);
445
+ console.log(` Recall@${k} MRR hit-rate`);
446
+ console.log(` graph OFF ${pct(lift.off.recallAtK).padStart(7)} ${lift.off.mrr.toFixed(3)} ${pct(lift.off.hitRate)}`);
447
+ console.log(` graph ON ${pct(lift.on.recallAtK).padStart(7)} ${lift.on.mrr.toFixed(3)} ${pct(lift.on.hitRate)}`);
448
+ console.log(` graph LIFT ${dpt(lift.recallDelta).padStart(7)} ${dnum(lift.mrrDelta)}`);
449
+ const misses = lift.on.perCase.filter((c) => c.found === 0);
450
+ if (misses.length) {
451
+ console.log(`\n ${misses.length} case(s) with no expected hit — curate or tune:`);
452
+ for (const m of misses.slice(0, 10))
453
+ console.log(` · "${m.query}"`);
454
+ }
455
+ store.close();
456
+ });
412
457
  // ---- embed (opt-in semantic search) ---------------------------------------
413
458
  program
414
459
  .command("embed")
@@ -1229,6 +1274,20 @@ program
1229
1274
  }
1230
1275
  // Windows: detect/heal the Claude Code ~/.claude.json drive-letter case-split
1231
1276
  // that silently hides the hunch_* MCP tools. No-op (silent) off Windows.
1277
+ // Memory drift: deterministic, advisory smoke detector for memory that has
1278
+ // fallen out of sync with the code/docs (dead file refs, dangling supersedes,
1279
+ // docs still marked "proposed"). Never blocks; never auto-fixes.
1280
+ const drift = computeDrift(store, root);
1281
+ if (drift.findings.length) {
1282
+ console.log(`drift: ⚠ ${drift.findings.length} finding(s) — memory may be out of sync with the code:`);
1283
+ for (const f of drift.findings.slice(0, 20))
1284
+ console.log(` · [${f.kind}] ${f.id} — ${f.detail}`);
1285
+ if (drift.findings.length > 20)
1286
+ console.log(dim(` … and ${drift.findings.length - 20} more`));
1287
+ }
1288
+ else {
1289
+ console.log(`drift: ✓ no stale refs, dangling supersedes, or stale "proposed" docs`);
1290
+ }
1232
1291
  reportClaudeConfigHeal();
1233
1292
  store.close();
1234
1293
  });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Memory drift checks (roadmap #7, dec_2a53072620). Deterministic, model-free
3
+ * comparisons of the curated graph against the actual code/docs — a smoke detector
4
+ * for stale memory, NOT a robot that rewrites it. Advisory only: `hunch doctor`
5
+ * prints findings; nothing blocks and nothing is auto-fixed. Each check maps to drift
6
+ * observed in practice:
7
+ * - dead-ref: an in-force decision points at a file that no longer exists.
8
+ * - supersede: A claims to supersede B, but B was never properly closed.
9
+ * - doc-stale: a doc marked "proposed / not yet implemented" references shipped code.
10
+ */
11
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
12
+ import { join, extname } from "node:path";
13
+ const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
14
+ const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
15
+ export function computeDrift(store, root) {
16
+ const findings = [];
17
+ const decisions = store.recs("decisions");
18
+ const byId = new Map(decisions.map((d) => [d.id, d]));
19
+ for (const d of decisions) {
20
+ // 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
21
+ // a since-deleted file is legitimate history, not drift.
22
+ const inForce = d.status !== "superseded" && !d.superseded_by;
23
+ if (inForce) {
24
+ for (const f of d.related_files ?? []) {
25
+ if (!f || f.includes("*"))
26
+ continue; // skip globs / empties
27
+ if (!existsSync(join(root, f))) {
28
+ findings.push({ kind: "dead-ref", id: d.id, detail: `references missing file "${f}"` });
29
+ }
30
+ }
31
+ }
32
+ // 2. SUPERSEDE-INTEGRITY — a contradiction class: A.supersedes = B, but B is
33
+ // either gone or still in force (the private-supersede bug shape).
34
+ if (d.supersedes) {
35
+ const target = byId.get(d.supersedes);
36
+ if (!target) {
37
+ findings.push({ kind: "supersede", id: d.id, detail: `supersedes "${d.supersedes}", which does not exist` });
38
+ }
39
+ else if (target.status !== "superseded" || target.superseded_by !== d.id) {
40
+ findings.push({
41
+ kind: "supersede",
42
+ id: d.id,
43
+ detail: `supersedes "${d.supersedes}", but it is still in force (status=${target.status}, superseded_by=${target.superseded_by ?? "null"})`,
44
+ });
45
+ }
46
+ }
47
+ }
48
+ // 3. DOC-STALE — a doc that still advertises "proposed / not implemented" while
49
+ // referencing code that exists. Heuristic + advisory; scoped to the repo's own
50
+ // markdown (node_modules and sub-projects skipped).
51
+ for (const doc of markdownDocs(root)) {
52
+ const text = safeRead(doc.path);
53
+ if (!STALE_MARKER.test(text.slice(0, 1500)))
54
+ continue;
55
+ const existing = (text.match(SRC_REF) ?? []).find((r) => existsSync(join(root, r)));
56
+ if (existing) {
57
+ findings.push({ kind: "doc-stale", id: doc.rel, detail: `marked proposed/not-implemented but references shipped code (${existing})` });
58
+ }
59
+ }
60
+ return { findings };
61
+ }
62
+ function safeRead(path) {
63
+ try {
64
+ return readFileSync(path, "utf8");
65
+ }
66
+ catch {
67
+ return "";
68
+ }
69
+ }
70
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".hunch", ".hunch-private", "dist", "vscode-extension", "site"]);
71
+ /** Bounded walk for repo markdown (root + docs/, depth-limited; heavy/irrelevant trees skipped). */
72
+ function markdownDocs(root) {
73
+ const out = [];
74
+ const walk = (dir, rel, depth) => {
75
+ if (depth > 4)
76
+ return;
77
+ let entries;
78
+ try {
79
+ entries = readdirSync(dir, { withFileTypes: true });
80
+ }
81
+ catch {
82
+ return;
83
+ }
84
+ for (const e of entries) {
85
+ if (e.isDirectory()) {
86
+ if (e.name.startsWith(".") || SKIP_DIRS.has(e.name))
87
+ continue;
88
+ walk(join(dir, e.name), rel ? `${rel}/${e.name}` : e.name, depth + 1);
89
+ }
90
+ else if (extname(e.name) === ".md") {
91
+ out.push({ path: join(dir, e.name), rel: rel ? `${rel}/${e.name}` : e.name });
92
+ }
93
+ }
94
+ };
95
+ walk(root, "", 0);
96
+ return out;
97
+ }
98
+ //# sourceMappingURL=drift.js.map
@@ -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.24.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.",