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