@nanonets/graft 0.12.1 → 0.15.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.
Files changed (242) hide show
  1. package/README.md +20 -7
  2. package/dist/ai/crux.d.ts.map +1 -1
  3. package/dist/ai/crux.js +22 -1
  4. package/dist/ai/crux.js.map +1 -1
  5. package/dist/ai/llm/factory.d.ts +5 -1
  6. package/dist/ai/llm/factory.d.ts.map +1 -1
  7. package/dist/ai/llm/factory.js +8 -0
  8. package/dist/ai/llm/factory.js.map +1 -1
  9. package/dist/ai/llm/litellm.d.ts +34 -0
  10. package/dist/ai/llm/litellm.d.ts.map +1 -0
  11. package/dist/ai/llm/litellm.js +45 -0
  12. package/dist/ai/llm/litellm.js.map +1 -0
  13. package/dist/ai/llm/openai.d.ts.map +1 -1
  14. package/dist/ai/llm/openai.js +66 -7
  15. package/dist/ai/llm/openai.js.map +1 -1
  16. package/dist/ai/llm/recover-tool.d.ts +19 -0
  17. package/dist/ai/llm/recover-tool.d.ts.map +1 -0
  18. package/dist/ai/llm/recover-tool.js +109 -0
  19. package/dist/ai/llm/recover-tool.js.map +1 -0
  20. package/dist/ai/providers.d.ts.map +1 -1
  21. package/dist/ai/providers.js +2 -0
  22. package/dist/ai/providers.js.map +1 -1
  23. package/dist/ai/synthesize.d.ts.map +1 -1
  24. package/dist/ai/synthesize.js +19 -5
  25. package/dist/ai/synthesize.js.map +1 -1
  26. package/dist/app/checkout.d.ts +28 -0
  27. package/dist/app/checkout.d.ts.map +1 -0
  28. package/dist/app/checkout.js +100 -0
  29. package/dist/app/checkout.js.map +1 -0
  30. package/dist/app/events.d.ts +37 -0
  31. package/dist/app/events.d.ts.map +1 -0
  32. package/dist/app/events.js +59 -0
  33. package/dist/app/events.js.map +1 -0
  34. package/dist/app/identity.d.ts +54 -0
  35. package/dist/app/identity.d.ts.map +1 -0
  36. package/dist/app/identity.js +96 -0
  37. package/dist/app/identity.js.map +1 -0
  38. package/dist/app/main.d.ts +2 -0
  39. package/dist/app/main.d.ts.map +1 -0
  40. package/dist/app/main.js +56 -0
  41. package/dist/app/main.js.map +1 -0
  42. package/dist/app/pages.d.ts +39 -0
  43. package/dist/app/pages.d.ts.map +1 -0
  44. package/dist/app/pages.js +86 -0
  45. package/dist/app/pages.js.map +1 -0
  46. package/dist/app/queue.d.ts +40 -0
  47. package/dist/app/queue.d.ts.map +1 -0
  48. package/dist/app/queue.js +68 -0
  49. package/dist/app/queue.js.map +1 -0
  50. package/dist/app/review.d.ts +23 -0
  51. package/dist/app/review.d.ts.map +1 -0
  52. package/dist/app/review.js +129 -0
  53. package/dist/app/review.js.map +1 -0
  54. package/dist/app/server.d.ts +40 -0
  55. package/dist/app/server.d.ts.map +1 -0
  56. package/dist/app/server.js +117 -0
  57. package/dist/app/server.js.map +1 -0
  58. package/dist/ask/ask.d.ts +42 -0
  59. package/dist/ask/ask.d.ts.map +1 -1
  60. package/dist/ask/ask.js +442 -57
  61. package/dist/ask/ask.js.map +1 -1
  62. package/dist/ask/file-rank.d.ts +72 -0
  63. package/dist/ask/file-rank.d.ts.map +1 -0
  64. package/dist/ask/file-rank.js +160 -0
  65. package/dist/ask/file-rank.js.map +1 -0
  66. package/dist/ask/file-selection.d.ts +22 -0
  67. package/dist/ask/file-selection.d.ts.map +1 -0
  68. package/dist/ask/file-selection.js +66 -0
  69. package/dist/ask/file-selection.js.map +1 -0
  70. package/dist/ask/fuse.d.ts +16 -0
  71. package/dist/ask/fuse.d.ts.map +1 -1
  72. package/dist/ask/fuse.js +21 -4
  73. package/dist/ask/fuse.js.map +1 -1
  74. package/dist/ask/graphrank.d.ts +18 -0
  75. package/dist/ask/graphrank.d.ts.map +1 -1
  76. package/dist/ask/graphrank.js +65 -19
  77. package/dist/ask/graphrank.js.map +1 -1
  78. package/dist/blast/blast-cli.d.ts +5 -0
  79. package/dist/blast/blast-cli.d.ts.map +1 -1
  80. package/dist/blast/blast-cli.js +16 -1
  81. package/dist/blast/blast-cli.js.map +1 -1
  82. package/dist/blast/blast.d.ts +9 -0
  83. package/dist/blast/blast.d.ts.map +1 -1
  84. package/dist/blast/blast.js.map +1 -1
  85. package/dist/blast/evidence.d.ts +51 -0
  86. package/dist/blast/evidence.d.ts.map +1 -0
  87. package/dist/blast/evidence.js +162 -0
  88. package/dist/blast/evidence.js.map +1 -0
  89. package/dist/blast/owners.d.ts +85 -0
  90. package/dist/blast/owners.d.ts.map +1 -0
  91. package/dist/blast/owners.js +272 -0
  92. package/dist/blast/owners.js.map +1 -0
  93. package/dist/blast/render.d.ts +11 -2
  94. package/dist/blast/render.d.ts.map +1 -1
  95. package/dist/blast/render.js +125 -4
  96. package/dist/blast/render.js.map +1 -1
  97. package/dist/blast/viz.d.ts +20 -0
  98. package/dist/blast/viz.d.ts.map +1 -1
  99. package/dist/blast/viz.js +13 -157
  100. package/dist/blast/viz.js.map +1 -1
  101. package/dist/claude/format.js +1 -1
  102. package/dist/claude/format.js.map +1 -1
  103. package/dist/claude/hooks.d.ts.map +1 -1
  104. package/dist/claude/hooks.js +99 -11
  105. package/dist/claude/hooks.js.map +1 -1
  106. package/dist/claude/settings-merge.d.ts +11 -0
  107. package/dist/claude/settings-merge.d.ts.map +1 -1
  108. package/dist/claude/settings-merge.js +24 -10
  109. package/dist/claude/settings-merge.js.map +1 -1
  110. package/dist/claude/state.d.ts +14 -1
  111. package/dist/claude/state.d.ts.map +1 -1
  112. package/dist/claude/state.js +1 -1
  113. package/dist/claude/state.js.map +1 -1
  114. package/dist/claude/stats.d.ts.map +1 -1
  115. package/dist/claude/stats.js +2 -1
  116. package/dist/claude/stats.js.map +1 -1
  117. package/dist/claude/statusline.d.ts +5 -3
  118. package/dist/claude/statusline.d.ts.map +1 -1
  119. package/dist/claude/statusline.js +6 -4
  120. package/dist/claude/statusline.js.map +1 -1
  121. package/dist/claude/sync-run.d.ts.map +1 -1
  122. package/dist/claude/sync-run.js +10 -2
  123. package/dist/claude/sync-run.js.map +1 -1
  124. package/dist/claude/tally.d.ts +23 -0
  125. package/dist/claude/tally.d.ts.map +1 -0
  126. package/dist/claude/tally.js +138 -0
  127. package/dist/claude/tally.js.map +1 -0
  128. package/dist/cli.js +138 -1
  129. package/dist/cli.js.map +1 -1
  130. package/dist/context/build.d.ts.map +1 -1
  131. package/dist/context/build.js +23 -4
  132. package/dist/context/build.js.map +1 -1
  133. package/dist/context/check.d.ts.map +1 -1
  134. package/dist/context/check.js +2 -1
  135. package/dist/context/check.js.map +1 -1
  136. package/dist/context/node-file.d.ts +2 -1
  137. package/dist/context/node-file.d.ts.map +1 -1
  138. package/dist/context/node-file.js +37 -2
  139. package/dist/context/node-file.js.map +1 -1
  140. package/dist/engine.d.ts +2 -0
  141. package/dist/engine.d.ts.map +1 -1
  142. package/dist/engine.js +1 -0
  143. package/dist/engine.js.map +1 -1
  144. package/dist/graph/bindings.d.ts +25 -3
  145. package/dist/graph/bindings.d.ts.map +1 -1
  146. package/dist/graph/bindings.js +168 -2
  147. package/dist/graph/bindings.js.map +1 -1
  148. package/dist/graph/build.d.ts +4 -0
  149. package/dist/graph/build.d.ts.map +1 -1
  150. package/dist/graph/build.js +7 -4
  151. package/dist/graph/build.js.map +1 -1
  152. package/dist/graph/check.d.ts +3 -0
  153. package/dist/graph/check.d.ts.map +1 -1
  154. package/dist/graph/check.js +58 -11
  155. package/dist/graph/check.js.map +1 -1
  156. package/dist/graph/enrich.d.ts.map +1 -1
  157. package/dist/graph/enrich.js +29 -9
  158. package/dist/graph/enrich.js.map +1 -1
  159. package/dist/graph/extract.d.ts +22 -2
  160. package/dist/graph/extract.d.ts.map +1 -1
  161. package/dist/graph/extract.js +1444 -44
  162. package/dist/graph/extract.js.map +1 -1
  163. package/dist/graph/fingerprint.d.ts +6 -1
  164. package/dist/graph/fingerprint.d.ts.map +1 -1
  165. package/dist/graph/fingerprint.js +7 -3
  166. package/dist/graph/fingerprint.js.map +1 -1
  167. package/dist/graph/generic.d.ts.map +1 -1
  168. package/dist/graph/generic.js +39 -7
  169. package/dist/graph/generic.js.map +1 -1
  170. package/dist/graph/invariants.d.ts +3 -2
  171. package/dist/graph/invariants.d.ts.map +1 -1
  172. package/dist/graph/invariants.js +6 -3
  173. package/dist/graph/invariants.js.map +1 -1
  174. package/dist/graph/queries/dart.scm +42 -0
  175. package/dist/graph/queries/lua.scm +36 -0
  176. package/dist/graph/queries/nix.scm +32 -0
  177. package/dist/graph/refresh.d.ts.map +1 -1
  178. package/dist/graph/refresh.js +6 -2
  179. package/dist/graph/refresh.js.map +1 -1
  180. package/dist/graph/resolve.d.ts +1 -1
  181. package/dist/graph/resolve.d.ts.map +1 -1
  182. package/dist/graph/resolve.js +211 -26
  183. package/dist/graph/resolve.js.map +1 -1
  184. package/dist/graph/scopes.d.ts.map +1 -1
  185. package/dist/graph/scopes.js +2 -1
  186. package/dist/graph/scopes.js.map +1 -1
  187. package/dist/graph/source-files.d.ts +7 -2
  188. package/dist/graph/source-files.d.ts.map +1 -1
  189. package/dist/graph/source-files.js +19 -6
  190. package/dist/graph/source-files.js.map +1 -1
  191. package/dist/graph/traverse-cli.d.ts +13 -2
  192. package/dist/graph/traverse-cli.d.ts.map +1 -1
  193. package/dist/graph/traverse-cli.js +25 -4
  194. package/dist/graph/traverse-cli.js.map +1 -1
  195. package/dist/graph/workspace-cli.d.ts +2 -0
  196. package/dist/graph/workspace-cli.d.ts.map +1 -1
  197. package/dist/graph/workspace-cli.js +3 -0
  198. package/dist/graph/workspace-cli.js.map +1 -1
  199. package/dist/graph/workspace.d.ts.map +1 -1
  200. package/dist/graph/workspace.js +26 -2
  201. package/dist/graph/workspace.js.map +1 -1
  202. package/dist/hosts/mcp-config.d.ts +12 -0
  203. package/dist/hosts/mcp-config.d.ts.map +1 -1
  204. package/dist/hosts/mcp-config.js +54 -5
  205. package/dist/hosts/mcp-config.js.map +1 -1
  206. package/dist/hosts/registry.d.ts.map +1 -1
  207. package/dist/hosts/registry.js +20 -0
  208. package/dist/hosts/registry.js.map +1 -1
  209. package/dist/hosts/retract.d.ts +47 -0
  210. package/dist/hosts/retract.d.ts.map +1 -0
  211. package/dist/hosts/retract.js +473 -0
  212. package/dist/hosts/retract.js.map +1 -0
  213. package/dist/ingest/fs.d.ts +19 -5
  214. package/dist/ingest/fs.d.ts.map +1 -1
  215. package/dist/ingest/fs.js +35 -9
  216. package/dist/ingest/fs.js.map +1 -1
  217. package/dist/telemetry/contract.d.ts.map +1 -1
  218. package/dist/telemetry/contract.js +7 -2
  219. package/dist/telemetry/contract.js.map +1 -1
  220. package/dist/telemetry/sessions.d.ts.map +1 -1
  221. package/dist/telemetry/sessions.js +6 -0
  222. package/dist/telemetry/sessions.js.map +1 -1
  223. package/dist/util/state.d.ts +25 -2
  224. package/dist/util/state.d.ts.map +1 -1
  225. package/dist/util/state.js +24 -2
  226. package/dist/util/state.js.map +1 -1
  227. package/dist/viewer/app.js +2 -1
  228. package/dist/viewer/style.css +14 -0
  229. package/dist/viz/assemble.d.ts +17 -0
  230. package/dist/viz/assemble.d.ts.map +1 -1
  231. package/dist/viz/assemble.js.map +1 -1
  232. package/package.json +19 -4
  233. package/scripts/graph-quality.mjs +3 -3
  234. package/scripts/tally-audit.mjs +140 -0
  235. package/dist/ai/llm/claude-cli.d.ts +0 -60
  236. package/dist/ai/llm/claude-cli.d.ts.map +0 -1
  237. package/dist/ai/llm/claude-cli.js +0 -417
  238. package/dist/ai/llm/claude-cli.js.map +0 -1
  239. package/dist/util/num.d.ts +0 -3
  240. package/dist/util/num.d.ts.map +0 -1
  241. package/dist/util/num.js +0 -20
  242. package/dist/util/num.js.map +0 -1
package/dist/ask/ask.js CHANGED
@@ -22,8 +22,10 @@ import { loadGraphCached, loadAskIndexCached } from "../graph/load.js";
22
22
  import { assertPrefixIndexed, pathUnderPrefix, scopeLabel, scopeOf, scopesHereClause, scopesOfGraph, } from "../graph/scopes.js";
23
23
  import { normalizePathPrefix } from "../util/paths.js";
24
24
  import { resolveSymbol } from "../graph/traverse.js";
25
- import { rankScopesAndFuse } from "./fuse.js";
26
- import { personalizedPageRank } from "./graphrank.js";
25
+ import { combineComparableScopes, rankScopesAndFuse, } from "./fuse.js";
26
+ import { rankFilesBounded, } from "./file-rank.js";
27
+ import { fileFirstRoundRobin, roundRobinQueues } from "./file-selection.js";
28
+ import { personalizedPageRank, personalizedPageRankPrepared, preparePageRankPartitions, } from "./graphrank.js";
27
29
  import { readSourceFile } from "../util/source.js";
28
30
  import { counts, tokenize } from "./index-file.js";
29
31
  /** Max source lines to inline per hit — a definition longer than this is
@@ -140,6 +142,25 @@ function bm25(query, doc, idf, dl, avgdl) {
140
142
  }
141
143
  return s;
142
144
  }
145
+ /** Exact query terms awarded by the existing lexical fields. Bounded ranking uses
146
+ * presence only: raw name/path/BM25 magnitudes cannot enter the file residual. */
147
+ function matchedLexicalTerms(query, name, path, body) {
148
+ const terms = new Set();
149
+ for (const term of query.keys())
150
+ if (name.has(term) || path.has(term) || body.has(term))
151
+ terms.add(term);
152
+ return terms;
153
+ }
154
+ /** NAME-only query terms behind file-level strong coverage. This intentionally
155
+ * mirrors {@link strongShare}'s plural folding while excluding file nodes at
156
+ * the call site. */
157
+ function matchedStrongTerms(query, name) {
158
+ const terms = new Set();
159
+ for (const term of query.keys())
160
+ if (hasTerm(name, term))
161
+ terms.add(term);
162
+ return terms;
163
+ }
143
164
  // ── Structural intent ──────────────────────────────────────────────────────
144
165
  const INCOMING = /\b(caller|callers|calls?\s+into|who\s+calls|what\s+calls|called\s+by|used\s+by|uses)\b/;
145
166
  const OUTGOING = /\b(callee|callees|what\s+does\s+\w+\s+call|calls\s+what|imports?|depends\s+on)\b/;
@@ -282,7 +303,18 @@ function matchedIdfShare(q, fields, idf, dfltIdf) {
282
303
  }
283
304
  return total > 0 ? matched / total : 0;
284
305
  }
285
- function lexical(query, corpus, limit, graphRank, inPrefix) {
306
+ /** Normalized query-term IDF mass used by file-level union coverage. Presence
307
+ * comes from the exact term-contribution maps, so plural folding cannot
308
+ * manufacture evidence that the lexical scorer did not award. */
309
+ function normalizedQueryWeights(q, idf, dfltIdf) {
310
+ let total = 0;
311
+ for (const t of q.keys())
312
+ total += idf.get(t) ?? dfltIdf;
313
+ if (total <= 0)
314
+ return new Map();
315
+ return new Map([...q.keys()].map((term) => [term, (idf.get(term) ?? dfltIdf) / total]));
316
+ }
317
+ function lexical(query, corpus, limit, graphRank, inPrefix, fileFirst = true, fileComplement = false, fileTopLock = false, includeRankingMetadata = false) {
286
318
  // Binary query terms: a word repeated in a pasted issue body counts once, so a
287
319
  // ranty description can't linearly amplify an incidental word.
288
320
  const q = new Map([...counts(tokenize(query)).keys()].map((t) => [t, 1]));
@@ -382,6 +414,10 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
382
414
  // Parallel to matchedOf, but over NAME+PATH only (body dropped) — the
383
415
  // match-strength signal exported as `coverageStrong`.
384
416
  const matchedStrongOf = new Map();
417
+ // Final listwise selection groups code hits by exact source path. Concepts
418
+ // use singleton pseudo-groups so they remain ordinary ranked candidates
419
+ // without masquerading as every file listed in their sources.
420
+ const selectionGroupOf = new Map();
385
421
  // ── Concepts (prose nodes; not in the wiring graph) ──
386
422
  const conceptHits = [];
387
423
  let maxConcept = 0;
@@ -399,6 +435,10 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
399
435
  };
400
436
  matchedOf.set(hit, matchedIdfShare(q, [name, body], idf, dfltIdf));
401
437
  matchedStrongOf.set(hit, matchedIdfShare(q, [name], idf, dfltIdf)); // concepts have no path field
438
+ // A concept is one singleton partition even if two source entries reuse
439
+ // the same slug; include its stable encounter index to prevent accidental
440
+ // grouping by frontmatter alone.
441
+ selectionGroupOf.set(hit, `concept:${c.slug}:${conceptHits.length}`);
402
442
  conceptHits.push(hit);
403
443
  }
404
444
  }
@@ -412,9 +452,36 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
412
452
  };
413
453
  const docsById = new Map(symbolDocs.map((d) => [d.n.id, d]));
414
454
  const symbolHits = [];
455
+ const baselineSymbolHits = [];
456
+ const baselineHitById = new Map();
457
+ const fileQueueHitsByGroup = new Map();
458
+ const baselineQueueHitsByGroup = new Map();
459
+ const fileGroups = [];
460
+ const needsFileQueues = fileComplement && (fileTopLock || includeRankingMetadata);
461
+ const makeSymbolHit = (id, hitScore, scope) => {
462
+ const n = byId.get(id);
463
+ if (!n)
464
+ return null;
465
+ const hit = {
466
+ kind: "symbol",
467
+ title: `${n.name} · ${n.kind}`,
468
+ pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
469
+ snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
470
+ score: hitScore,
471
+ ...(scope === undefined ? {} : { scope }),
472
+ };
473
+ const d = docsById.get(id);
474
+ matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
475
+ matchedStrongOf.set(hit, d ? strongShare(q, n, d, idf, dfltIdf) : 0);
476
+ selectionGroupOf.set(hit, `file:${n.path}`);
477
+ return hit;
478
+ };
479
+ const symbolTitle = (id) => {
480
+ const n = byId.get(id);
481
+ return n ? `${n.name} · ${n.kind}` : id;
482
+ };
415
483
  // The single-vs-multi-scope branch keys on `scopesOfGraph` ONLY: one scope
416
- // (or no graph) takes the existing path below completely untouched — that
417
- // branch is a byte-level regression guarantee for single-scope repos.
484
+ // (or no graph) keeps the original local scoring path.
418
485
  const scopes = graph ? scopesOfGraph(graph) : null;
419
486
  let scopeMeta;
420
487
  // The BM25 length prior, corpus-global like `idf`/`dfltIdf` above. Hoisted out
@@ -427,6 +494,18 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
427
494
  : symbolDocs.length
428
495
  ? symbolDocs.reduce((a, d) => a + bodyLen(d.body), 0) / symbolDocs.length
429
496
  : 0;
497
+ // Populated only for bounded file ranking. The baseline path pays no allocation
498
+ // or matched-term tracking cost, and their scalar lexical path remains exact.
499
+ const rawLexicalById = new Map();
500
+ const matchedTermsById = new Map();
501
+ const matchedStrongTermsById = new Map();
502
+ const fileQueryWeights = fileComplement
503
+ ? normalizedQueryWeights(q, idf, dfltIdf)
504
+ : new Map();
505
+ const spanStart = (span) => {
506
+ const match = span.match(/^L(\d+)-L\d+$/);
507
+ return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
508
+ };
430
509
  if (scopes && scopes.length > 1) {
431
510
  // ── Multi-scope repo: partition by sub-project and walk each subgraph
432
511
  // separately — so the big scope can't drown the small one — but score every
@@ -434,14 +513,25 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
434
513
  // can be ordered by score. Same scoring functions and blend as the
435
514
  // single-scope path; the combination itself lives in fuse.ts. ──
436
515
  const byScope = new Map();
516
+ const scopeById = new Map();
437
517
  for (const d of symbolDocs) {
438
518
  const s = scopeOf(d.n.path, scopes).prefix;
519
+ scopeById.set(d.n.id, s);
439
520
  const list = byScope.get(s);
440
521
  if (list)
441
522
  list.push(d);
442
523
  else
443
524
  byScope.set(s, [d]);
444
525
  }
526
+ // Lazily build every scope's PageRank topology in one node/edge pass. The
527
+ // old nodeFilter path rebuilt the full repo adjacency once per matching
528
+ // scope; on large multi-project repos that turned one query into hundreds
529
+ // of full graph scans before the participation gate could run. Waiting for
530
+ // the first walk keeps no-match and graphRank:false queries edge-scan free.
531
+ let pageRankByScope;
532
+ let collapsedComponents = [];
533
+ let collapsedComponentById;
534
+ let collapsedFileByRepresentative;
445
535
  const fusion = rankScopesAndFuse([...byScope.keys()].sort(), {
446
536
  lex: (s) => {
447
537
  const docs = byScope.get(s);
@@ -453,12 +543,20 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
453
543
  // per-scope now; the statistics are global.
454
544
  const out = new Map();
455
545
  for (const { n, name, path, body } of docs) {
546
+ const factor = testFactor(n.path);
456
547
  const total = (score(q, name, idf) * 3 +
457
548
  score(q, path, idf) * 2 +
458
549
  bm25(q, body, idf, bodyLen(body), avgBodyLen)) *
459
- testFactor(n.path);
460
- if (total > 0)
550
+ factor;
551
+ if (total > 0) {
461
552
  out.set(n.id, total);
553
+ if (fileComplement) {
554
+ rawLexicalById.set(n.id, total);
555
+ matchedTermsById.set(n.id, matchedLexicalTerms(q, name, path, body));
556
+ if (n.kind !== "file")
557
+ matchedStrongTermsById.set(n.id, matchedStrongTerms(q, name));
558
+ }
559
+ }
462
560
  }
463
561
  return out;
464
562
  },
@@ -470,35 +568,121 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
470
568
  return true;
471
569
  return false;
472
570
  }),
473
- walk: (s, seeds) => graphRank
474
- ? personalizedPageRank(graph, seeds, {
475
- nodeFilter: (id) => {
476
- const path = byId.get(id)?.path ?? "";
477
- return scopeOf(path, scopes).prefix === s && (!inPrefix || pathUnderPrefix(path, inPrefix));
478
- },
479
- })
480
- : new Map(),
571
+ walk: (s, seeds) => {
572
+ if (!graphRank)
573
+ return new Map();
574
+ pageRankByScope ??= preparePageRankPartitions(graph, (id) => scopeById.get(id));
575
+ const topology = pageRankByScope?.get(s);
576
+ return topology
577
+ ? personalizedPageRankPrepared(topology, seeds)
578
+ : new Map();
579
+ },
481
580
  rankFactor: (_s, id) => testFactor(byId.get(id)?.path ?? ""),
581
+ collapseCandidates: fileComplement
582
+ ? (candidates) => {
583
+ collapsedComponents = [...candidates];
584
+ collapsedComponentById = new Map(candidates.map((candidate) => [candidate.id, candidate]));
585
+ const fileCandidates = candidates.map((candidate) => {
586
+ const n = byId.get(candidate.id);
587
+ const rawLexical = rawLexicalById.get(candidate.id) ?? 0;
588
+ return {
589
+ id: candidate.id,
590
+ file: n?.path ?? "",
591
+ kind: n?.kind === "file" ? "file" : "symbol",
592
+ rawLexical,
593
+ lexical: candidate.lexical,
594
+ graph: candidate.graph,
595
+ rankFactor: candidate.rankFactor,
596
+ baselineScore: candidate.score,
597
+ baselineTieKey: symbolTitle(candidate.id),
598
+ matchedTerms: matchedTermsById.get(candidate.id) ?? new Set(),
599
+ matchedStrongTerms: matchedStrongTermsById.get(candidate.id) ?? new Set(),
600
+ eligible: Boolean(n && n.kind !== "file" && rawLexical > 0),
601
+ spanStart: n ? spanStart(n.span) : undefined,
602
+ };
603
+ });
604
+ const rankedFiles = rankFilesBounded(fileCandidates, fileQueryWeights);
605
+ collapsedFileByRepresentative = new Map(rankedFiles.map((file) => [file.representative.id, file]));
606
+ return rankedFiles.map((file) => {
607
+ const component = collapsedComponentById.get(file.representative.id);
608
+ return {
609
+ id: file.representative.id,
610
+ scope: component.scope,
611
+ score: file.score,
612
+ };
613
+ });
614
+ }
615
+ : undefined,
482
616
  }, GRAPH_WEIGHT, RESCUE_FLOOR);
483
- for (const rd of fusion.ranked) {
484
- const n = byId.get(rd.id);
485
- if (!n)
486
- continue;
487
- const hit = {
488
- kind: "symbol",
489
- title: `${n.name} · ${n.kind}`,
490
- pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
491
- snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
492
- score: rd.score,
493
- scope: rd.scope,
494
- };
495
- const d = docsById.get(rd.id);
496
- // Global idf here too, so per-hit coverage means the same thing in a
497
- // multi-scope repo as it does in a single-scope one — the escalation
498
- // nudge and the hook's coverage gate both read these.
499
- matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
500
- matchedStrongOf.set(hit, d ? strongShare(q, n, d, idf, dfltIdf) : 0);
501
- symbolHits.push(hit);
617
+ if (needsFileQueues) {
618
+ const baselineFusion = combineComparableScopes(collapsedComponents.map(({ id, scope, score }) => ({ id, scope, score })));
619
+ const baselineScoreById = new Map(baselineFusion.ranked.map((ranked) => [ranked.id, ranked.score]));
620
+ const baselineDisplayOrder = (a, b) => b.score - a.score || symbolTitle(a.id).localeCompare(symbolTitle(b.id));
621
+ let baselineTopRanked = baselineFusion.ranked[0];
622
+ for (const ranked of baselineFusion.ranked.slice(1))
623
+ if (baselineTopRanked && baselineDisplayOrder(ranked, baselineTopRanked) < 0)
624
+ baselineTopRanked = ranked;
625
+ const baselineHitsToBuild = includeRankingMetadata
626
+ ? [...baselineFusion.ranked].sort(baselineDisplayOrder)
627
+ : baselineTopRanked
628
+ ? [baselineTopRanked]
629
+ : [];
630
+ for (const ranked of baselineHitsToBuild) {
631
+ const hit = makeSymbolHit(ranked.id, ranked.score, ranked.scope);
632
+ if (hit) {
633
+ baselineSymbolHits.push(hit);
634
+ baselineHitById.set(ranked.id, hit);
635
+ }
636
+ }
637
+ for (const ranked of fusion.ranked) {
638
+ const file = collapsedFileByRepresentative?.get(ranked.id);
639
+ if (!file)
640
+ continue;
641
+ const materializeFileQueue = () => file.queue.flatMap((member, index) => {
642
+ const component = collapsedComponentById?.get(member.id);
643
+ if (index > 0) {
644
+ const baselineHit = baselineHitById.get(member.id);
645
+ if (baselineHit)
646
+ return [baselineHit];
647
+ }
648
+ const hit = makeSymbolHit(member.id, index === 0
649
+ ? ranked.score
650
+ : baselineScoreById.get(member.id) ?? member.baselineScore, component?.scope ?? ranked.scope);
651
+ return hit ? [hit] : [];
652
+ });
653
+ const materializeBaselineQueue = () => file.queue.flatMap((member) => {
654
+ const hit = makeSymbolHit(member.id, baselineScoreById.get(member.id) ?? member.baselineScore, collapsedComponentById?.get(member.id)?.scope ?? ranked.scope);
655
+ return hit ? [hit] : [];
656
+ });
657
+ const hits = includeRankingMetadata
658
+ ? materializeFileQueue()
659
+ : (() => {
660
+ const leader = file.queue[0];
661
+ if (!leader)
662
+ return [];
663
+ const hit = makeSymbolHit(leader.id, ranked.score, collapsedComponentById?.get(leader.id)?.scope ?? ranked.scope);
664
+ return hit ? [hit] : [];
665
+ })();
666
+ if (hits.length === 0)
667
+ continue;
668
+ const group = {
669
+ key: `file:${file.file}`,
670
+ hits,
671
+ coverage: file.unionCoverage,
672
+ coverageStrong: file.unionStrongCoverage,
673
+ };
674
+ fileQueueHitsByGroup.set(group.key, materializeFileQueue);
675
+ baselineQueueHitsByGroup.set(group.key, materializeBaselineQueue);
676
+ fileGroups.push(group);
677
+ symbolHits.push(hits[0]);
678
+ }
679
+ }
680
+ else {
681
+ for (const ranked of fusion.ranked) {
682
+ const hit = makeSymbolHit(ranked.id, ranked.score, ranked.scope);
683
+ if (hit)
684
+ symbolHits.push(hit);
685
+ }
502
686
  }
503
687
  // Label + footer only when federation actually happened (or a scope was
504
688
  // gated out and is worth mentioning) — a query matching one scope of a
@@ -516,13 +700,20 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
516
700
  for (const { n, name, path, body } of symbolDocs) {
517
701
  // Name and path are short identifiers → plain idf-weighted overlap; the body
518
702
  // is length-normalized via BM25 so long definitions don't win on bulk.
703
+ const factor = testFactor(n.path);
519
704
  const total = (score(q, name, idf) * 3 +
520
705
  score(q, path, idf) * 2 +
521
706
  bm25(q, body, idf, bodyLen(body), avgBodyLen)) *
522
- testFactor(n.path);
707
+ factor;
523
708
  if (total > 0) {
524
709
  lex.set(n.id, total);
525
710
  maxLex = Math.max(maxLex, total);
711
+ if (fileComplement) {
712
+ rawLexicalById.set(n.id, total);
713
+ matchedTermsById.set(n.id, matchedLexicalTerms(q, name, path, body));
714
+ if (n.kind !== "file")
715
+ matchedStrongTermsById.set(n.id, matchedStrongTerms(q, name));
716
+ }
526
717
  }
527
718
  }
528
719
  // ── Graph-rank: let connectivity to the matched set reorder and rescue ──
@@ -538,46 +729,226 @@ function lexical(query, corpus, limit, graphRank, inPrefix) {
538
729
  for (const [id, p] of pr)
539
730
  if (p >= RESCUE_FLOOR)
540
731
  candidates.add(id);
732
+ const singleCandidates = [];
541
733
  for (const id of candidates) {
542
734
  const n = byId.get(id);
543
735
  if (!n)
544
736
  continue;
545
- const lexN = maxLex > 0 ? (lex.get(id) ?? 0) / maxLex : 0;
737
+ const lexical = maxLex > 0 ? (lex.get(id) ?? 0) / maxLex : 0;
738
+ const graphScore = pr.get(id) ?? 0;
739
+ const rankFactor = testFactor(n.path);
546
740
  // Apply the test penalty after normalization too. Applying it only to the
547
741
  // raw lexical score is not enough: if a test is still the strongest raw
548
742
  // match, dividing by maxLex restores it to 1.0 and erases the de-rank.
549
- const blended = (lexN + GRAPH_WEIGHT * (pr.get(id) ?? 0)) * testFactor(n.path);
550
- if (blended <= 0)
743
+ const baseline = (lexical + GRAPH_WEIGHT * graphScore) * rankFactor;
744
+ if (baseline <= 0)
551
745
  continue;
552
- const hit = {
553
- kind: "symbol",
554
- title: `${n.name} · ${n.kind}`,
555
- // A file node points at the whole file (locator, no span) so `--source`
556
- // never inlines an entire file; symbol nodes keep their exact span.
557
- pointer: n.kind === "file" ? n.path : `${n.path}:${n.span}`,
558
- snippet: n.summary?.split("\n")[0].trim() ?? n.signature ?? "",
559
- score: blended,
560
- };
561
- const d = docsById.get(id);
562
- matchedOf.set(hit, d ? matchedIdfShare(q, [d.name, d.path, d.body], idf, dfltIdf) : 0);
563
- matchedStrongOf.set(hit, d ? strongShare(q, n, d, idf, dfltIdf) : 0);
564
- symbolHits.push(hit);
746
+ singleCandidates.push({ id, n, lexical, graph: graphScore, rankFactor, baseline });
747
+ }
748
+ const candidateById = fileComplement
749
+ ? new Map(singleCandidates.map((candidate) => [candidate.id, candidate]))
750
+ : undefined;
751
+ const rankedFiles = fileComplement
752
+ ? rankFilesBounded(singleCandidates.map((candidate) => {
753
+ const rawLexical = rawLexicalById.get(candidate.id) ?? 0;
754
+ return {
755
+ id: candidate.id,
756
+ file: candidate.n.path,
757
+ kind: candidate.n.kind === "file" ? "file" : "symbol",
758
+ rawLexical,
759
+ lexical: candidate.lexical,
760
+ graph: candidate.graph,
761
+ rankFactor: candidate.rankFactor,
762
+ baselineScore: candidate.baseline,
763
+ baselineTieKey: symbolTitle(candidate.id),
764
+ matchedTerms: matchedTermsById.get(candidate.id) ?? new Set(),
765
+ matchedStrongTerms: matchedStrongTermsById.get(candidate.id) ?? new Set(),
766
+ eligible: candidate.n.kind !== "file" && rawLexical > 0,
767
+ spanStart: spanStart(candidate.n.span),
768
+ };
769
+ }), fileQueryWeights)
770
+ : undefined;
771
+ if (needsFileQueues && rankedFiles) {
772
+ const baselineCandidateOrder = (a, b) => b.baseline - a.baseline ||
773
+ `${a.n.name} · ${a.n.kind}`.localeCompare(`${b.n.name} · ${b.n.kind}`);
774
+ let baselineTopCandidate = singleCandidates[0];
775
+ for (const candidate of singleCandidates.slice(1))
776
+ if (baselineTopCandidate && baselineCandidateOrder(candidate, baselineTopCandidate) < 0)
777
+ baselineTopCandidate = candidate;
778
+ const baselineCandidatesToBuild = includeRankingMetadata
779
+ ? [...singleCandidates].sort(baselineCandidateOrder)
780
+ : baselineTopCandidate
781
+ ? [baselineTopCandidate]
782
+ : [];
783
+ for (const candidate of baselineCandidatesToBuild) {
784
+ const hit = makeSymbolHit(candidate.id, candidate.baseline);
785
+ if (hit) {
786
+ baselineSymbolHits.push(hit);
787
+ baselineHitById.set(candidate.id, hit);
788
+ }
789
+ }
790
+ for (const file of rankedFiles) {
791
+ const materializeFileQueue = () => file.queue.flatMap((member, index) => {
792
+ if (index > 0) {
793
+ const baselineHit = baselineHitById.get(member.id);
794
+ if (baselineHit)
795
+ return [baselineHit];
796
+ }
797
+ const hit = makeSymbolHit(member.id, index === 0 ? file.score : member.baselineScore);
798
+ return hit ? [hit] : [];
799
+ });
800
+ const materializeBaselineQueue = () => file.queue.flatMap((member) => {
801
+ const hit = makeSymbolHit(member.id, member.baselineScore);
802
+ return hit ? [hit] : [];
803
+ });
804
+ const hits = includeRankingMetadata
805
+ ? materializeFileQueue()
806
+ : (() => {
807
+ const leader = file.queue[0];
808
+ if (!leader)
809
+ return [];
810
+ const hit = makeSymbolHit(leader.id, file.score);
811
+ return hit ? [hit] : [];
812
+ })();
813
+ if (hits.length === 0)
814
+ continue;
815
+ const group = {
816
+ key: `file:${file.file}`,
817
+ hits,
818
+ coverage: file.unionCoverage,
819
+ coverageStrong: file.unionStrongCoverage,
820
+ };
821
+ fileQueueHitsByGroup.set(group.key, materializeFileQueue);
822
+ baselineQueueHitsByGroup.set(group.key, materializeBaselineQueue);
823
+ fileGroups.push(group);
824
+ symbolHits.push(hits[0]);
825
+ }
826
+ }
827
+ else if (rankedFiles) {
828
+ for (const file of rankedFiles) {
829
+ const candidate = candidateById?.get(file.representative.id);
830
+ const hit = candidate ? makeSymbolHit(candidate.id, file.score) : null;
831
+ if (hit)
832
+ symbolHits.push(hit);
833
+ }
834
+ }
835
+ else {
836
+ for (const candidate of singleCandidates) {
837
+ const hit = makeSymbolHit(candidate.id, candidate.baseline);
838
+ if (hit)
839
+ symbolHits.push(hit);
840
+ }
565
841
  }
566
842
  }
567
843
  // Concepts and symbols live on comparable 0..~1.5 scales so they merge fairly:
568
- // concept scores are normalized to their own max, symbol scores are the
844
+ // concept scores are normalized to their own max; symbol scores use the
569
845
  // lexical-normalized + graph-weighted blend.
570
846
  for (const h of conceptHits)
571
847
  h.score = maxConcept > 0 ? h.score / maxConcept : 0;
572
848
  const scored = [...conceptHits, ...symbolHits];
573
849
  scored.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
850
+ const baselineScored = needsFileQueues
851
+ ? [...conceptHits, ...baselineSymbolHits].sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
852
+ : scored;
853
+ const conceptGroups = conceptHits.map((hit, index) => ({
854
+ key: selectionGroupOf.get(hit) ?? `concept:${index}`,
855
+ hits: [hit],
856
+ coverage: matchedOf.get(hit) ?? 0,
857
+ coverageStrong: matchedStrongOf.get(hit) ?? 0,
858
+ }));
859
+ const unlockedGroups = [...conceptGroups, ...fileGroups].sort((a, b) => b.hits[0].score - a.hits[0].score ||
860
+ a.hits[0].title.localeCompare(b.hits[0].title));
861
+ const sameHit = (a, b) => a.kind === b.kind && a.pointer === b.pointer && a.title === b.title;
862
+ let projectedGroups = unlockedGroups;
863
+ let lockedGroupKey;
864
+ if (fileTopLock && fileFirst && baselineScored[0]) {
865
+ const baselineTop = baselineScored[0];
866
+ const key = selectionGroupOf.get(baselineTop) ?? "locked:top";
867
+ lockedGroupKey = key;
868
+ const existing = unlockedGroups.find((group) => group.key === key);
869
+ const baselineQueue = includeRankingMetadata || limit > unlockedGroups.length
870
+ ? baselineQueueHitsByGroup.get(key)?.() ?? baselineScored.filter((hit) => (selectionGroupOf.get(hit) ?? "") === key)
871
+ : [];
872
+ const locked = existing
873
+ ? {
874
+ ...existing,
875
+ hits: [
876
+ baselineTop,
877
+ ...baselineQueue.filter((hit) => !sameHit(hit, baselineTop)),
878
+ ],
879
+ }
880
+ : {
881
+ key,
882
+ hits: baselineQueue.length ? baselineQueue : [baselineTop],
883
+ coverage: matchedOf.get(baselineTop) ?? 0,
884
+ coverageStrong: matchedStrongOf.get(baselineTop) ?? 0,
885
+ };
886
+ projectedGroups = [
887
+ locked,
888
+ ...unlockedGroups.filter((group) => group.key !== key),
889
+ ];
890
+ }
891
+ // Ranking always sees the complete candidate universe, but a normal bounded
892
+ // request usually needs only one leader per file. Materialize secondary span
893
+ // queues only when the requested output can actually reach a second round.
894
+ if (fileTopLock &&
895
+ fileFirst &&
896
+ !includeRankingMetadata &&
897
+ limit > projectedGroups.length) {
898
+ projectedGroups = projectedGroups.map((group) => {
899
+ if (group.key === lockedGroupKey)
900
+ return group;
901
+ const materialize = fileQueueHitsByGroup.get(group.key);
902
+ return materialize ? { ...group, hits: materialize() } : group;
903
+ });
904
+ }
905
+ // File-aware ranking feeds latent groups to the existing file-first frontier.
906
+ // Leaders are listed first so every file/concept emits once before any
907
+ // secondary span; each group's remaining queue retains baseline order.
908
+ const selected = fileTopLock && fileFirst
909
+ ? roundRobinQueues(projectedGroups.map((group) => group.hits), limit)
910
+ : fileFirst
911
+ ? fileFirstRoundRobin(scored.map((hit, index) => ({
912
+ group: selectionGroupOf.get(hit) ?? `ungrouped:${index}`,
913
+ value: hit,
914
+ })), limit)
915
+ : scored;
916
+ const top = selected[0] ?? scored[0];
917
+ if (fileTopLock && top?.scope && scopeMeta) {
918
+ scopeMeta = {
919
+ federated: [...new Set([top.scope, ...scopeMeta.federated])],
920
+ alsoMatched: scopeMeta.alsoMatched.filter((match) => match.scope !== top.scope),
921
+ };
922
+ }
923
+ const ranking = includeRankingMetadata && needsFileQueues
924
+ ? {
925
+ groups: unlockedGroups.slice(0, limit).map((group) => ({
926
+ ...group,
927
+ hits: group.hits.slice(0, limit),
928
+ baselineHits: baselineScored
929
+ .filter((hit) => selectionGroupOf.get(hit) === group.key)
930
+ .slice(0, limit),
931
+ })),
932
+ baseline: baselineScored.slice(0, limit).map((hit, index) => ({
933
+ group: selectionGroupOf.get(hit) ?? `ungrouped:${index}`,
934
+ hit,
935
+ })),
936
+ baselineCoverage: baselineScored[0]
937
+ ? matchedOf.get(baselineScored[0]) ?? 0
938
+ : undefined,
939
+ baselineCoverageStrong: baselineScored[0]
940
+ ? matchedStrongOf.get(baselineScored[0]) ?? 0
941
+ : undefined,
942
+ }
943
+ : undefined;
574
944
  return {
575
945
  query,
576
946
  mode: scored.length ? "lexical" : "empty",
577
- hits: scored.slice(0, limit),
947
+ hits: selected.slice(0, limit),
578
948
  scopes: scopeMeta,
579
- coverage: scored.length && q.size > 0 ? matchedOf.get(scored[0]) ?? 0 : undefined,
580
- coverageStrong: scored.length && q.size > 0 ? matchedStrongOf.get(scored[0]) ?? 0 : undefined,
949
+ coverage: top && q.size > 0 ? matchedOf.get(top) ?? 0 : undefined,
950
+ coverageStrong: top && q.size > 0 ? matchedStrongOf.get(top) ?? 0 : undefined,
951
+ ...(ranking ? { ranking } : {}),
581
952
  // Zero hits on a genuinely multi-scope graph names the scopes that exist,
582
953
  // so a query that missed everywhere still tells the caller where to look.
583
954
  note: scored.length
@@ -672,6 +1043,12 @@ export function ask(dir, query, opts = {}) {
672
1043
  const limit = opts.limit ?? 8;
673
1044
  const corpus = loadCorpus(outDir);
674
1045
  const graphRank = opts.graphRank ?? true;
1046
+ const fileFirst = opts.fileFirst ?? true;
1047
+ // The production path uses bounded file scoring plus an exact baseline top lock.
1048
+ // Internal/raw callers can still request the baseline by disabling
1049
+ // file-first selection. A top lock is projected only on a file-first list.
1050
+ const fileTopLock = opts.fileTopLock ?? fileFirst;
1051
+ const fileComplement = (opts.fileComplement ?? false) || fileTopLock;
675
1052
  // Normalized ONCE, up front, and this value used everywhere below
676
1053
  // (validation, filtering, structural). `ask --in frontend/` must work exactly
677
1054
  // like `--in frontend` — `scopeLabel`/the footer's own `--in <scope>/`
@@ -689,7 +1066,7 @@ export function ask(dir, query, opts = {}) {
689
1066
  result = outcome.result;
690
1067
  }
691
1068
  else {
692
- result = lexical(query, corpus, limit, graphRank, inPrefix);
1069
+ result = lexical(query, corpus, limit, graphRank, inPrefix, fileFirst, fileComplement, fileTopLock, opts.includeRankingMetadata ?? false);
693
1070
  // A structural-intent query that couldn't be answered structurally still
694
1071
  // gets a prominent note on the lexical fallback result — never silent.
695
1072
  if (outcome && "fallthroughNote" in outcome) {
@@ -698,10 +1075,18 @@ export function ask(dir, query, opts = {}) {
698
1075
  }
699
1076
  }
700
1077
  else {
701
- result = lexical(query, corpus, limit, graphRank, inPrefix);
1078
+ result = lexical(query, corpus, limit, graphRank, inPrefix, fileFirst, fileComplement, fileTopLock, opts.includeRankingMetadata ?? false);
702
1079
  }
703
1080
  if (opts.source) {
704
1081
  inlineSource(root, result.hits, corpus.graph, opts.full ?? false);
1082
+ if (result.ranking) {
1083
+ const internalHits = [
1084
+ ...result.ranking.groups.flatMap((group) => group.hits),
1085
+ ...result.ranking.groups.flatMap((group) => group.baselineHits ?? []),
1086
+ ...result.ranking.baseline.map((entry) => entry.hit),
1087
+ ];
1088
+ inlineSource(root, [...new Set(internalHits)], corpus.graph, opts.full ?? false);
1089
+ }
705
1090
  // The pack is truly substitutive only in retriever mode (spans inlined), so
706
1091
  // the "vs reading whole files" estimate is only honest here.
707
1092
  result.saved = baselineFor(result.hits, corpus.graph);