@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 +44 -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,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
|
@@ -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.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.",
|