@davesheffer/hunch 0.21.1 → 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 +44 -0
- package/dist/eval/harness.js +55 -0
- package/dist/store/hunchStore.js +82 -19
- package/package.json +1 -1
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
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -296,23 +296,35 @@ export class HunchStore {
|
|
|
296
296
|
* the lean install and fallback regressions are unaffected. Pass
|
|
297
297
|
* `embedder: null` to FORCE FTS-only without auto-selecting. */
|
|
298
298
|
async hybridSearch(query, limit = 12, opts = {}) {
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
// Explicit `embedder: null` forces pure FTS-only (no semantic, no graph) — the
|
|
300
|
+
// documented escape hatch and the lean-fallback regression guard.
|
|
301
|
+
if (opts.embedder === null)
|
|
301
302
|
return this.search(query, limit);
|
|
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;
|
|
302
307
|
const fts = this.search(query, Math.max(limit, 50));
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
let sem = [];
|
|
309
|
+
if (embedder && this.semanticReady(embedder)) {
|
|
310
|
+
try {
|
|
311
|
+
// The semantic leg (query embedding + decode + cosine) is guarded: any failure —
|
|
312
|
+
// model load, a corrupt/dim-mismatched vector — degrades to lexical + graph.
|
|
313
|
+
const [qvec] = await embedder.embed([query]);
|
|
314
|
+
if (qvec)
|
|
315
|
+
sem = this.cosineRank(qvec, embedder.id, 50);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
sem = [];
|
|
319
|
+
}
|
|
312
320
|
}
|
|
313
|
-
|
|
321
|
+
// The graph stream is model-free, so it contributes even on a lean (no-embeddings)
|
|
322
|
+
// install. With neither semantic nor graph signal, return pure FTS so the
|
|
323
|
+
// zero-fusion-overhead fast path is preserved.
|
|
324
|
+
const graph = this.graphExpand([...fts, ...sem], 50, gw);
|
|
325
|
+
if (!sem.length && !graph.length)
|
|
314
326
|
return fts.slice(0, limit);
|
|
315
|
-
|
|
327
|
+
return this.rrfFuse(fts, sem, graph, limit, gw);
|
|
316
328
|
}
|
|
317
329
|
/** Brute-force exact cosine top-n over stored vectors for one model. Vectors are
|
|
318
330
|
* pre-normalized, so cosine == dot product. Scoped to `dim = qvec.length` so a
|
|
@@ -347,10 +359,12 @@ export class HunchStore {
|
|
|
347
359
|
return { ref: s.ref, kind: s.kind, title: m?.title ?? s.ref, snippet: (m?.body ?? "").slice(0, 120), score: s.score };
|
|
348
360
|
});
|
|
349
361
|
}
|
|
350
|
-
/** Rank-based Reciprocal Rank Fusion of the FTS and
|
|
351
|
-
* raw scores) erase the bm25-vs-cosine scale mismatch; a small lexical
|
|
352
|
-
* keeps exact symbol/path matches from being displaced by paraphrase
|
|
353
|
-
|
|
362
|
+
/** Rank-based Reciprocal Rank Fusion of the FTS, semantic, and graph lists. Ranks
|
|
363
|
+
* (not raw scores) erase the bm25-vs-cosine-vs-graph scale mismatch; a small lexical
|
|
364
|
+
* weight keeps exact symbol/path matches from being displaced by paraphrase or
|
|
365
|
+
* neighbor hits. An empty list contributes nothing, so 2-stream behavior is exactly
|
|
366
|
+
* preserved when graph (or sem) is absent. */
|
|
367
|
+
rrfFuse(fts, sem, graph, limit, graphWeight = RRF_W_GRAPH) {
|
|
354
368
|
const acc = new Map();
|
|
355
369
|
const add = (list, weight) => list.forEach((hit, i) => {
|
|
356
370
|
const e = acc.get(hit.ref) ?? { hit, score: 0 };
|
|
@@ -359,8 +373,51 @@ export class HunchStore {
|
|
|
359
373
|
});
|
|
360
374
|
add(fts, RRF_W_FTS);
|
|
361
375
|
add(sem, RRF_W_SEM);
|
|
376
|
+
add(graph, graphWeight);
|
|
362
377
|
return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, limit).map((e) => ({ ...e.hit, score: e.score }));
|
|
363
378
|
}
|
|
379
|
+
/** Graph retrieval stream (roadmap #1): 1-hop expansion over the dependency graph
|
|
380
|
+
* from the lexical/semantic seed hits. For each seed SYMBOL, surface its direct
|
|
381
|
+
* neighbors (callers/callees, importers/imported, container) — the cross-file
|
|
382
|
+
* evidence a "why" question needs but that neither bm25 nor cosine reaches. Each
|
|
383
|
+
* neighbor accrues GAMMA-decayed support per linking seed (one pulled in by several
|
|
384
|
+
* top seeds ranks higher); seeds themselves are excluded, so this only ADDS context.
|
|
385
|
+
* Deterministic, model-free (runs on a lean install too), one indexed query per seed. */
|
|
386
|
+
graphExpand(seeds, n, weight = RRF_W_GRAPH) {
|
|
387
|
+
if (weight <= 0)
|
|
388
|
+
return [];
|
|
389
|
+
const symSeeds = seeds.filter((h) => h.ref.startsWith("sym_"));
|
|
390
|
+
if (!symSeeds.length)
|
|
391
|
+
return [];
|
|
392
|
+
const seen = new Set(seeds.map((h) => h.ref)); // never re-surface a seed
|
|
393
|
+
const nbStmt = this.db.prepare(
|
|
394
|
+
/* sql */ `
|
|
395
|
+
SELECT e."to" AS nb FROM edges e WHERE e."from" = ? AND e.type IN ('calls','depends_on','imports','contains')
|
|
396
|
+
UNION
|
|
397
|
+
SELECT e."from" AS nb FROM edges e WHERE e."to" = ? AND e.type IN ('calls','depends_on','imports','contains')`);
|
|
398
|
+
const score = new Map();
|
|
399
|
+
symSeeds.forEach((h, i) => {
|
|
400
|
+
const contrib = GRAPH_GAMMA / (RRF_K + i + 1);
|
|
401
|
+
for (const r of nbStmt.all(h.ref, h.ref)) {
|
|
402
|
+
if (seen.has(r.nb))
|
|
403
|
+
continue;
|
|
404
|
+
score.set(r.nb, (score.get(r.nb) ?? 0) + contrib);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
if (!score.size)
|
|
408
|
+
return [];
|
|
409
|
+
const top = [...score.entries()].sort((a, b) => b[1] - a[1]).slice(0, n);
|
|
410
|
+
// Hydrate title/snippet from the FTS table in ONE query (mirrors cosineRank).
|
|
411
|
+
const placeholders = top.map(() => "?").join(",");
|
|
412
|
+
const meta = new Map();
|
|
413
|
+
for (const row of this.db.prepare(`SELECT ref, title, body FROM search WHERE ref IN (${placeholders})`).all(...top.map(([ref]) => ref))) {
|
|
414
|
+
meta.set(row.ref, { title: row.title, body: row.body });
|
|
415
|
+
}
|
|
416
|
+
return top.map(([ref, s]) => {
|
|
417
|
+
const m = meta.get(ref);
|
|
418
|
+
return { ref, kind: ref.startsWith("cmp_") ? "component" : "symbol", title: m?.title ?? ref, snippet: (m?.body ?? "").slice(0, 120), score: s };
|
|
419
|
+
});
|
|
420
|
+
}
|
|
364
421
|
/** All decisions/bugs/constraints/symbols/components touching a file path or
|
|
365
422
|
* symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
|
|
366
423
|
* return only decisions/constraints whose valid-time window contained that
|
|
@@ -971,11 +1028,17 @@ function round(n) {
|
|
|
971
1028
|
return Math.round(n * 100) / 100;
|
|
972
1029
|
}
|
|
973
1030
|
// --- semantic-search helpers ---------------------------------------------
|
|
974
|
-
/** RRF tuning (env-overridable). Lexical weight ≥ semantic
|
|
975
|
-
* ties
|
|
1031
|
+
/** RRF tuning (env-overridable). Lexical weight ≥ semantic ≥ graph: exact matches
|
|
1032
|
+
* win ties, semantic adds paraphrase recall, and the graph stream adds the
|
|
1033
|
+
* dependency-neighbor evidence a "why" question needs across files. The graph
|
|
1034
|
+
* weight is conservative + measurement-gated (set HUNCH_RRF_W_GRAPH=0 to disable);
|
|
1035
|
+
* GAMMA only decays a neighbor's cross-seed support, so its absolute value is
|
|
1036
|
+
* normalized away by the rank-based fusion. */
|
|
976
1037
|
const RRF_K = numEnv("HUNCH_RRF_K", 60);
|
|
977
1038
|
const RRF_W_FTS = numEnv("HUNCH_RRF_W_FTS", 1);
|
|
978
1039
|
const RRF_W_SEM = numEnv("HUNCH_RRF_W_SEM", 0.7);
|
|
1040
|
+
const RRF_W_GRAPH = numEnv("HUNCH_RRF_W_GRAPH", 0.5);
|
|
1041
|
+
const GRAPH_GAMMA = numEnv("HUNCH_GRAPH_GAMMA", 0.25);
|
|
979
1042
|
function numEnv(name, dflt) {
|
|
980
1043
|
const v = Number(process.env[name]);
|
|
981
1044
|
return Number.isFinite(v) && v > 0 ? v : dflt;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "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.",
|