@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 +59 -0
- package/dist/core/drift.js +98 -0
- package/dist/eval/harness.js +55 -0
- package/dist/store/hunchStore.js +9 -6
- package/package.json +1 -1
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
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
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.
|
|
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.",
|