@rubytech/create-maxy-code 0.1.132 → 0.1.138

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 (128) hide show
  1. package/dist/__tests__/installer-specialist-registration.test.js +5 -40
  2. package/dist/index.js +24 -28
  3. package/dist/specialist-registration.js +0 -15
  4. package/package.json +1 -1
  5. package/payload/platform/lib/graph-search/dist/boosts.d.ts +39 -0
  6. package/payload/platform/lib/graph-search/dist/boosts.d.ts.map +1 -0
  7. package/payload/platform/lib/graph-search/dist/boosts.js +57 -0
  8. package/payload/platform/lib/graph-search/dist/boosts.js.map +1 -0
  9. package/payload/platform/lib/graph-search/dist/dedup.d.ts +29 -0
  10. package/payload/platform/lib/graph-search/dist/dedup.d.ts.map +1 -0
  11. package/payload/platform/lib/graph-search/dist/dedup.js +97 -0
  12. package/payload/platform/lib/graph-search/dist/dedup.js.map +1 -0
  13. package/payload/platform/lib/graph-search/dist/index.d.ts +70 -8
  14. package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -1
  15. package/payload/platform/lib/graph-search/dist/index.js +323 -62
  16. package/payload/platform/lib/graph-search/dist/index.js.map +1 -1
  17. package/payload/platform/lib/graph-search/dist/query-expansion.d.ts +37 -0
  18. package/payload/platform/lib/graph-search/dist/query-expansion.d.ts.map +1 -0
  19. package/payload/platform/lib/graph-search/dist/query-expansion.js +101 -0
  20. package/payload/platform/lib/graph-search/dist/query-expansion.js.map +1 -0
  21. package/payload/platform/lib/graph-search/dist/route.d.ts +29 -0
  22. package/payload/platform/lib/graph-search/dist/route.d.ts.map +1 -0
  23. package/payload/platform/lib/graph-search/dist/route.js +53 -0
  24. package/payload/platform/lib/graph-search/dist/route.js.map +1 -0
  25. package/payload/platform/lib/graph-search/dist/rrf-fusion.d.ts +31 -0
  26. package/payload/platform/lib/graph-search/dist/rrf-fusion.d.ts.map +1 -0
  27. package/payload/platform/lib/graph-search/dist/rrf-fusion.js +57 -0
  28. package/payload/platform/lib/graph-search/dist/rrf-fusion.js.map +1 -0
  29. package/payload/platform/lib/graph-search/src/__tests__/boosts.test.ts +59 -0
  30. package/payload/platform/lib/graph-search/src/__tests__/dedup.test.ts +83 -0
  31. package/payload/platform/lib/graph-search/src/__tests__/query-expansion.test.ts +96 -0
  32. package/payload/platform/lib/graph-search/src/__tests__/route.test.ts +62 -0
  33. package/payload/platform/lib/graph-search/src/__tests__/rrf-fusion.test.ts +39 -0
  34. package/payload/platform/lib/graph-search/src/boosts.ts +61 -0
  35. package/payload/platform/lib/graph-search/src/dedup.ts +108 -0
  36. package/payload/platform/lib/graph-search/src/index.ts +407 -65
  37. package/payload/platform/lib/graph-search/src/query-expansion.ts +129 -0
  38. package/payload/platform/lib/graph-search/src/route.ts +70 -0
  39. package/payload/platform/lib/graph-search/src/rrf-fusion.ts +62 -0
  40. package/payload/platform/neo4j/schema.cypher +80 -1
  41. package/payload/platform/plugins/admin/hooks/session-end-retrospective.sh +4 -2
  42. package/payload/platform/plugins/admin/mcp/dist/index.js +6 -4
  43. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  44. package/payload/platform/plugins/docs/references/internals.md +36 -10
  45. package/payload/platform/plugins/docs/references/neo4j.md +63 -0
  46. package/payload/platform/plugins/docs/references/session-retrospective.md +11 -4
  47. package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js +2 -2
  48. package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js.map +1 -1
  49. package/payload/platform/plugins/memory/mcp/dist/index.js +114 -5
  50. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  51. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.d.ts +2 -0
  52. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.d.ts.map +1 -0
  53. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.js +25 -0
  54. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.js.map +1 -0
  55. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.d.ts +2 -0
  56. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.d.ts.map +1 -0
  57. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.js +70 -0
  58. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.js.map +1 -0
  59. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.d.ts +2 -0
  60. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.d.ts.map +1 -0
  61. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.js +50 -0
  62. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.js.map +1 -0
  63. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts +33 -0
  64. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts.map +1 -0
  65. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js +99 -0
  66. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js.map +1 -0
  67. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.d.ts +21 -0
  68. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.d.ts.map +1 -1
  69. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.js +24 -0
  70. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.js.map +1 -1
  71. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.d.ts +13 -0
  72. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.d.ts.map +1 -0
  73. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.js +151 -0
  74. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.js.map +1 -0
  75. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.d.ts +55 -0
  76. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.d.ts.map +1 -0
  77. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.js +290 -0
  78. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.js.map +1 -0
  79. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.d.ts +19 -0
  80. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.d.ts.map +1 -0
  81. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.js +102 -0
  82. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.js.map +1 -0
  83. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js +18 -1
  84. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js.map +1 -1
  85. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js +5 -0
  86. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js.map +1 -1
  87. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +2 -0
  88. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -1
  89. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts.map +1 -1
  90. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js +14 -0
  91. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js.map +1 -1
  92. package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.d.ts +29 -0
  93. package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.d.ts.map +1 -0
  94. package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.js +22 -0
  95. package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.js.map +1 -0
  96. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts +31 -1
  97. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
  98. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +182 -50
  99. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
  100. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  101. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +76 -1
  102. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  103. package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.d.ts +30 -0
  104. package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.d.ts.map +1 -0
  105. package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.js +129 -0
  106. package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.js.map +1 -0
  107. package/payload/platform/plugins/memory/mcp/scripts/backfill-typed-edges.ts +72 -0
  108. package/payload/platform/plugins/memory/mcp/scripts/generate-edge-docs.ts +75 -0
  109. package/payload/platform/plugins/memory/mcp/vitest.config.ts +5 -0
  110. package/payload/platform/plugins/memory/references/schema-base.md +44 -0
  111. package/payload/platform/scripts/seed-neo4j.sh +49 -0
  112. package/payload/platform/templates/agents/admin/IDENTITY.md +4 -3
  113. package/payload/platform/templates/specialists/agents/database-operator.md +9 -1
  114. package/payload/server/{adminuser-self-heal-QAWOZ3JV.js → adminuser-self-heal-YC47O34W.js} +2 -1
  115. package/payload/server/{chunk-JSBRDJBE.js → chunk-HYQNUVGO.js} +9 -1
  116. package/payload/server/{chunk-HCYM5FLU.js → chunk-T3DJD5QR.js} +3 -1
  117. package/payload/server/maxy-edge.js +3 -2
  118. package/payload/server/public/assets/{admin-FcRHAL-3.js → admin-BM9aXUUh.js} +1 -1
  119. package/payload/server/public/assets/{data-Ds37mflX.js → data-Ds2ECtfP.js} +1 -1
  120. package/payload/server/public/assets/{graph-CmWRhaiS.js → graph-BzxObKMM.js} +1 -1
  121. package/payload/server/public/assets/{graph-labels-Ch2r00Gt.js → graph-labels-Dwgep_nr.js} +1 -1
  122. package/payload/server/public/assets/{page-BcHhJXUt.js → page-BlkBsjP2.js} +1 -1
  123. package/payload/server/public/assets/page-h_SA5SVQ.js +51 -0
  124. package/payload/server/public/data.html +3 -3
  125. package/payload/server/public/graph.html +3 -3
  126. package/payload/server/public/index.html +4 -4
  127. package/payload/server/server.js +803 -76
  128. package/payload/server/public/assets/page-BOtNny_4.js +0 -51
@@ -41,9 +41,41 @@
41
41
 
42
42
  import { int, type Session } from "neo4j-driver";
43
43
  import { notTrashed } from "../../graph-trash/dist/index.js";
44
+ import { fuseRrf } from "./rrf-fusion.js";
45
+ import { expandQuery } from "./query-expansion.js";
46
+ import { applyCompiledTruthBoost, applyBacklinkBoost } from "./boosts.js";
47
+ import { dedup as dedupHits, DEFAULT_LAYERS, type DedupLayer } from "./dedup.js";
48
+ import { pickIndexMix, type RetrievalClass } from "./route.js";
44
49
 
45
50
  const VECTOR_WEIGHT = 0.7;
46
51
  const BM25_WEIGHT = 0.3;
52
+
53
+ /**
54
+ * Per-enhancement feature flags (Task 308). All read from env at the
55
+ * boundary of each `hybrid()` call so flips do not require a process
56
+ * restart. Per-call `HybridParams` fields override the env defaults so
57
+ * tests and A/B harnesses can opt in without touching the environment.
58
+ *
59
+ * Defaults are OFF — unflagged callers see exactly the baseline
60
+ * weighted-sum + nodeId-only-dedup baseline. Flip a flag on at the
61
+ * environment level after the soak window passes.
62
+ */
63
+ function envFlag(name: string): boolean {
64
+ const v = process.env[name];
65
+ return v === "1" || v === "true" || v === "yes";
66
+ }
67
+ function readFlags() {
68
+ return {
69
+ rrf: envFlag("MAXY_GS_RRF"),
70
+ expansion: envFlag("MAXY_GS_EXPANSION"),
71
+ boosts: envFlag("MAXY_GS_BOOSTS"),
72
+ dedup: envFlag("MAXY_GS_DEDUP"),
73
+ route: envFlag("MAXY_GS_ROUTE"),
74
+ };
75
+ }
76
+
77
+ export type { DedupLayer, RetrievalClass };
78
+ export { DEFAULT_LAYERS };
47
79
  /**
48
80
  * Name of the universal fulltext index. Mirrors the `CREATE FULLTEXT
49
81
  * INDEX` declaration in `platform/neo4j/schema.cypher`. The doctrine test
@@ -108,7 +140,17 @@ export interface ScoredNode {
108
140
  bm25Hit: boolean;
109
141
  }
110
142
 
111
- export type SearchMode = "hybrid" | "bm25";
143
+ /**
144
+ * `"hybrid"` = weighted-sum fusion path (the baseline; the
145
+ * brief calls this `weighted-sum`).
146
+ * `"rrf"` = RRF fusion path (Task 308, flagged via `MAXY_GS_RRF`).
147
+ * `"bm25"` = degraded path after embed failure with `degradeOnEmbedFailure`.
148
+ *
149
+ * UI callers (e.g. `platform/ui/app/graph/page.tsx`) pattern-match on
150
+ * `"bm25"` to render the Ollama-down banner; non-`"bm25"` values collapse
151
+ * to the normal banner, so adding `"rrf"` is wire-compatible.
152
+ */
153
+ export type SearchMode = "hybrid" | "rrf" | "bm25";
112
154
 
113
155
  export interface Bm25OnlyParams {
114
156
  query: string;
@@ -155,6 +197,33 @@ export interface HybridParams extends Bm25OnlyParams {
155
197
  * default; the lib itself takes no opinion on the value.
156
198
  */
157
199
  vectorThreshold?: number;
200
+
201
+ // --- Task 308 enhancements (all optional, all default off) ---
202
+
203
+ /**
204
+ * Routing hint from the inbound-gateway classifier (Task 304). When
205
+ * the `MAXY_GS_ROUTE` flag is on, the lib picks a label filter and
206
+ * fusion-weight plan per class. Omitted ⇒ `"general"` (unflagged
207
+ * callers see no change).
208
+ */
209
+ retrievalClass?: RetrievalClass;
210
+ /** Per-call override: enable Reciprocal Rank Fusion. */
211
+ enableRrf?: boolean;
212
+ /** Per-call override: enable Haiku multi-query expansion. */
213
+ enableExpansion?: boolean;
214
+ /** Per-call override: enable compiledTruth + backlink boosts. */
215
+ enableBoosts?: boolean;
216
+ /** Per-call override: enable 4-layer dedup (default layers below). */
217
+ enableDedup?: boolean;
218
+ /** Per-call override: enable retrieval-class routing. */
219
+ enableRoute?: boolean;
220
+ /**
221
+ * Layers passed to the 4-layer dedup when `enableDedup` is true.
222
+ * Defaults to `["nodeId","slug","canonicalName","contentHash"]`.
223
+ */
224
+ dedupLayers?: DedupLayer[];
225
+ /** RRF smoothing constant. Default 60 (gbrain default). */
226
+ rrfK?: number;
158
227
  }
159
228
 
160
229
  export interface HybridResponse {
@@ -190,6 +259,21 @@ export interface HybridResponse {
190
259
  suppressed: number;
191
260
  bm25Bypass: number;
192
261
  threshold: number | null;
262
+ /**
263
+ * Per-stage counts surfaced for the caller's `[graph-search:hybrid]`
264
+ * log line (Task 308). Always present; values reflect whichever
265
+ * enhancements ran on this call. `expansions` is 1 when expansion
266
+ * was off, `boosted - fused` is 0 when boosts were no-ops, etc.
267
+ */
268
+ stageCounts: {
269
+ expansions: number;
270
+ vector: number;
271
+ bm25: number;
272
+ fused: number;
273
+ boosted: number;
274
+ deduped: number;
275
+ final: number;
276
+ };
193
277
  }
194
278
 
195
279
  export type EmbedFn = (text: string) => Promise<number[]>;
@@ -331,20 +415,29 @@ export async function bm25Only(
331
415
  * Hybrid vector + BM25 search with graph expansion.
332
416
  *
333
417
  * Sequence:
334
- * 1. embed(query) may throw if Ollama is unreachable. When
418
+ * 1. Resolve flags + route plan. Route may short-circuit (`retrievalClass=none`).
419
+ * 2. Multi-query expansion (Task 308 flag): Haiku produces 3-5 paraphrases.
420
+ * Each paraphrase is embedded and run through vector+BM25 in parallel.
421
+ * 3. embed(query) — may throw if Ollama is unreachable. When
335
422
  * `degradeOnEmbedFailure=true`, caller receives bm25Only() result
336
423
  * with `mode: "bm25"`; otherwise the throw propagates.
337
- * 2. Vector search per label (one query per vector index discovered at
424
+ * 4. Vector search per label (one query per vector index discovered at
338
425
  * boot). Nodes-by-label filter short-circuits when the requested
339
426
  * labels have no index.
340
- * 3. BM25 search on the universal `entity_search` index —
427
+ * 5. BM25 search on the universal `entity_search` index —
341
428
  * same filter semantics as vector half (scope/agent/keyword/trashed).
342
- * 4. Keyword subscriptions (when set): BM25 per keyword + property
429
+ * 6. Keyword subscriptions (when set): BM25 per keyword + property
343
430
  * lookup against `node.keywords` array. Both bypass the agentSlug
344
431
  * filter — subscriptions are scope-inclusive by design (matches
345
- * memory-search.ts comment L319).
346
- * 5. Merge: 0.7*vector + 0.3*bm25_norm, dedup by nodeId, sort.
347
- * 6. Graph expand (1 hop by default, 0 to skip) — notTrashed filter +
432
+ * memory-search.ts comment L319). Runs once, not per expansion.
433
+ * 7. Fusion: weighted-sum (`vw * vector + bw * bm25_norm`) by default,
434
+ * RRF when `MAXY_GS_RRF` is on. Route plan supplies fusion weights.
435
+ * 8. Boosts (Task 308 flag): compiledTruth bump + backlink log-bump.
436
+ * 9. Dedup (Task 308 flag): 4-layer dedup over the union, default layers
437
+ * `[nodeId, slug, canonicalName, contentHash]`.
438
+ * 10. Threshold + sort + slice. Counts (rawMerged / suppressed / bm25Bypass)
439
+ * reflect the pre-threshold and post-threshold populations.
440
+ * 11. Graph expand (1 hop by default, 0 to skip) — notTrashed filter +
348
441
  * scope + agent clauses mirrored.
349
442
  */
350
443
  export async function hybrid(
@@ -364,18 +457,62 @@ export async function hybrid(
364
457
  keywordSubscriptions,
365
458
  expandHops = 1,
366
459
  degradeOnEmbedFailure = false,
460
+ retrievalClass,
461
+ dedupLayers = DEFAULT_LAYERS,
462
+ rrfK = 60,
367
463
  } = params;
368
464
 
465
+ const envFlags = readFlags();
466
+ const useRoute = params.enableRoute ?? envFlags.route;
467
+ const useRrf = params.enableRrf ?? envFlags.rrf;
468
+ const useExpansion = params.enableExpansion ?? envFlags.expansion;
469
+ const useBoosts = params.enableBoosts ?? envFlags.boosts;
470
+ const useDedup = params.enableDedup ?? envFlags.dedup;
471
+
472
+ // --- Route plan (Task 308): pick label filter + fusion weights from class ---
473
+ const plan = useRoute
474
+ ? pickIndexMix(retrievalClass)
475
+ : { skip: false, vectorWeight: VECTOR_WEIGHT, bm25Weight: BM25_WEIGHT, labelFilter: undefined as string[] | undefined };
476
+
477
+ const emptyStageCounts = {
478
+ expansions: 1,
479
+ vector: 0,
480
+ bm25: 0,
481
+ fused: 0,
482
+ boosted: 0,
483
+ deduped: 0,
484
+ final: 0,
485
+ };
486
+
487
+ if (plan.skip) {
488
+ return {
489
+ mode: useRrf ? "rrf" : "hybrid",
490
+ results: [],
491
+ expandMs: 0,
492
+ rawMerged: 0,
493
+ suppressed: 0,
494
+ bm25Bypass: 0,
495
+ threshold: params.vectorThreshold ?? null,
496
+ stageCounts: emptyStageCounts,
497
+ };
498
+ }
499
+
500
+ // Per-call labels win over route-supplied label filter — operator-set
501
+ // filters are intentional; the route plan is a soft hint.
502
+ const effectiveLabels = (labels && labels.length > 0)
503
+ ? labels
504
+ : plan.labelFilter;
505
+
506
+ // --- Degraded path: embed fails, fall back to BM25-only ---
507
+ // Run a single embed up front so degradation is detected before any
508
+ // expansion / multi-query work spins up.
369
509
  let queryEmbedding: number[];
370
510
  try {
371
511
  queryEmbedding = await embed(query);
372
512
  } catch (err) {
373
513
  if (!degradeOnEmbedFailure) throw err;
374
514
  const msg = err instanceof Error ? err.message : String(err);
375
- const bm25Hits = await bm25Only(session, params);
376
- // even in the degraded path, surface granular components so
377
- // the /data score-line renders consistently. Vector half is 0 (no embed
378
- // happened); bm25 half is normalised across the local hit set.
515
+ const bm25Hits = await bm25Only(session, { ...params, labels: effectiveLabels });
379
516
  const normalised = normaliseBm25Scores(bm25Hits.map((h) => h.score));
380
517
  const results: SearchResult[] = bm25Hits.map((h, i) => ({
381
518
  ...h,
@@ -383,11 +520,6 @@ export async function hybrid(
383
520
  bm25Score: normalised[i] ?? 0,
384
521
  related: [],
385
522
  }));
386
- // threshold has no meaning in the degraded path: no embed
387
- // happened, so there is no vector cosine to compare against. Surface
388
- // counts in the response (rawMerged = rendered, suppressed = 0,
389
- // bm25Bypass = 0, threshold = null) so the caller's render path is
390
- // uniform across hybrid + degraded modes.
391
523
  return {
392
524
  mode: "bm25",
393
525
  results,
@@ -397,32 +529,36 @@ export async function hybrid(
397
529
  suppressed: 0,
398
530
  bm25Bypass: 0,
399
531
  threshold: null,
532
+ stageCounts: {
533
+ expansions: 1,
534
+ vector: 0,
535
+ bm25: results.length,
536
+ fused: results.length,
537
+ boosted: results.length,
538
+ deduped: results.length,
539
+ final: results.length,
540
+ },
400
541
  };
401
542
  }
402
543
 
403
544
  const labelToIndex = await discoverIndexes(session);
404
545
 
405
- const keywordFilter = buildKeywordFilter(keywords, keywordMatch);
406
- const keywordClause = keywordFilter?.clause ?? "";
407
- const keywordParams = keywordFilter?.params ?? {};
408
-
409
- const scoreMap = new Map<string, ScoredNode>();
410
-
411
- // --- Vector search (per label) ---
546
+ // --- Resolve vector indexes from the effective label set ---
412
547
  let indexesToQuery: string[];
413
- if (labels && labels.length > 0) {
414
- indexesToQuery = labels
548
+ if (effectiveLabels && effectiveLabels.length > 0) {
549
+ indexesToQuery = effectiveLabels
415
550
  .map((l) => labelToIndex.get(l))
416
551
  .filter((idx): idx is string => idx !== undefined);
417
552
  if (indexesToQuery.length === 0) {
418
553
  return {
419
- mode: "hybrid",
554
+ mode: useRrf ? "rrf" : "hybrid",
420
555
  results: [],
421
556
  expandMs: 0,
422
557
  rawMerged: 0,
423
558
  suppressed: 0,
424
559
  bm25Bypass: 0,
425
560
  threshold: params.vectorThreshold ?? null,
561
+ stageCounts: emptyStageCounts,
426
562
  };
427
563
  }
428
564
  } else {
@@ -438,59 +574,198 @@ export async function hybrid(
438
574
  : "";
439
575
  const agentParams = agentSlug ? { agentSlug } : {};
440
576
 
441
- for (const indexName of indexesToQuery) {
442
- const vectorResult = await session.run(
443
- `CALL db.index.vector.queryNodes($indexName, $limit, $embedding)
444
- YIELD node, score
577
+ const keywordFilter = buildKeywordFilter(keywords, keywordMatch);
578
+ const keywordClause = keywordFilter?.clause ?? "";
579
+ const keywordParams = keywordFilter?.params ?? {};
580
+
581
+ // --- Multi-query expansion (Task 308) ---
582
+ // Original query is always first. On expansion failure expandQuery returns
583
+ // [query] only and logs `[graph-search:expansion-failed]`. Embeddings for
584
+ // additional paraphrases are computed in parallel; embed failures on a
585
+ // paraphrase fall back to skipping the vector half for that pass.
586
+ const expansions = useExpansion
587
+ ? await expandQuery({ query, retrievalClass, accountId })
588
+ : [query];
589
+
590
+ type Pass = {
591
+ queryText: string;
592
+ embedding: number[] | null;
593
+ vectorRanked: ScoredNode[];
594
+ bm25Ranked: ScoredNode[];
595
+ };
596
+
597
+ const passes: Pass[] = await Promise.all(
598
+ expansions.map(async (qText, idx) => {
599
+ let pEmbed: number[] | null;
600
+ if (idx === 0) {
601
+ pEmbed = queryEmbedding;
602
+ } else {
603
+ try {
604
+ pEmbed = await embed(qText);
605
+ } catch {
606
+ pEmbed = null;
607
+ }
608
+ }
609
+
610
+ const vectorRanked: ScoredNode[] = [];
611
+ const vectorSeen = new Map<string, number>();
612
+ if (pEmbed) {
613
+ for (const indexName of indexesToQuery) {
614
+ const vectorResult = await session.run(
615
+ `CALL db.index.vector.queryNodes($indexName, $limit, $embedding)
616
+ YIELD node, score
617
+ WHERE node.accountId = $accountId
618
+ ${scopeClause}
619
+ ${agentClause}
620
+ AND ${notTrashed("node")}
621
+ ${keywordClause}
622
+ RETURN node, score, labels(node) AS nodeLabels, elementId(node) AS nodeId
623
+ ORDER BY score DESC
624
+ LIMIT $limit`,
625
+ {
626
+ indexName,
627
+ embedding: pEmbed,
628
+ limit: int(limit),
629
+ accountId,
630
+ ...scopeParams,
631
+ ...agentParams,
632
+ ...keywordParams,
633
+ },
634
+ );
635
+ for (const record of vectorResult.records) {
636
+ const nodeId = record.get("nodeId") as string;
637
+ const scoreRaw = record.get("score");
638
+ const score = typeof scoreRaw === "number" ? scoreRaw : Number(scoreRaw);
639
+ const existingIdx = vectorSeen.get(nodeId);
640
+ if (existingIdx !== undefined) {
641
+ const existing = vectorRanked[existingIdx];
642
+ if (score > existing.vectorScore) existing.vectorScore = score;
643
+ continue;
644
+ }
645
+ const node = record.get("node") as { properties: Record<string, unknown> };
646
+ vectorSeen.set(nodeId, vectorRanked.length);
647
+ vectorRanked.push({
648
+ nodeId,
649
+ labels: record.get("nodeLabels") as string[],
650
+ properties: plainProperties(node.properties),
651
+ vectorScore: score,
652
+ bm25Score: 0,
653
+ bm25Hit: false,
654
+ });
655
+ }
656
+ }
657
+ vectorRanked.sort((a, b) => b.vectorScore - a.vectorScore);
658
+ }
659
+
660
+ const bm25Hits = await bm25Only(session, {
661
+ ...params,
662
+ query: qText,
663
+ labels: effectiveLabels,
664
+ });
665
+ const bm25Norm = normaliseBm25Scores(bm25Hits.map((h) => h.score));
666
+ const bm25Ranked: ScoredNode[] = bm25Hits.map((h, i) => ({
667
+ nodeId: h.nodeId,
668
+ labels: h.labels,
669
+ properties: h.properties,
670
+ vectorScore: 0,
671
+ bm25Score: bm25Norm[i] ?? 0,
672
+ bm25Hit: true,
673
+ }));
674
+
675
+ return { queryText: qText, embedding: pEmbed, vectorRanked, bm25Ranked };
676
+ }),
677
+ );
678
+
679
+ // --- Fuse passes into the score map ---
680
+ // Weighted-sum: union per nodeId, taking the max vector score and max
681
+ // bm25-normalised score across passes, then combine with the route's
682
+ // weights. This preserves the baseline single-query behaviour exactly
683
+ // when expansions.length === 1.
684
+ // RRF: each pass contributes one vector-ranked list and one bm25-ranked
685
+ // list. Fusion is computed across the full union of ranked lists.
686
+ const scoreMap = new Map<string, ScoredNode>();
687
+ let totalVector = 0;
688
+ let totalBm25 = 0;
689
+
690
+ for (const pass of passes) {
691
+ totalVector += pass.vectorRanked.length;
692
+ totalBm25 += pass.bm25Ranked.length;
693
+ for (const v of pass.vectorRanked) {
694
+ const existing = scoreMap.get(v.nodeId);
695
+ if (existing) {
696
+ existing.vectorScore = Math.max(existing.vectorScore, v.vectorScore);
697
+ } else {
698
+ scoreMap.set(v.nodeId, { ...v });
699
+ }
700
+ }
701
+ for (const b of pass.bm25Ranked) {
702
+ mergeBm25Hit(scoreMap, {
703
+ nodeId: b.nodeId,
704
+ labels: b.labels,
705
+ properties: b.properties,
706
+ score: b.bm25Score,
707
+ }, b.bm25Score);
708
+ }
709
+ }
710
+
711
+ // --- Keyword subscriptions (scope-inclusive, agentSlug-bypassing) ---
712
+ // Runs once regardless of expansion count — subscriptions are keyed by
713
+ // operator config, not by the inbound query.
714
+ if (keywordSubscriptions && keywordSubscriptions.length > 0) {
715
+ for (const kw of keywordSubscriptions) {
716
+ const kwHits = await bm25Only(session, {
717
+ query: kw,
718
+ accountId,
719
+ limit,
720
+ allowedScopes,
721
+ });
722
+ if (kwHits.length === 0) continue;
723
+ const rawScores = kwHits.map((h) => h.score);
724
+ const normalised = normaliseBm25Scores(rawScores);
725
+ for (let i = 0; i < kwHits.length; i++) {
726
+ mergeBm25Hit(scoreMap, kwHits[i], normalised[i]);
727
+ }
728
+ }
729
+
730
+ const propScopeClause = allowedScopes
731
+ ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
732
+ : "";
733
+ const propResult = await session.run(
734
+ `MATCH (node)
445
735
  WHERE node.accountId = $accountId
446
- ${scopeClause}
447
- ${agentClause}
448
736
  AND ${notTrashed("node")}
449
- ${keywordClause}
450
- RETURN node, score, labels(node) AS nodeLabels, elementId(node) AS nodeId
451
- ORDER BY score DESC
737
+ AND node.keywords IS NOT NULL
738
+ AND ANY(kw IN $kwSubs WHERE ANY(nk IN node.keywords WHERE toLower(nk) = kw))
739
+ ${propScopeClause}
740
+ RETURN node, labels(node) AS nodeLabels, elementId(node) AS nodeId
452
741
  LIMIT $limit`,
453
742
  {
454
- indexName,
455
- embedding: queryEmbedding,
456
- limit: int(limit),
457
743
  accountId,
458
- ...scopeParams,
459
- ...agentParams,
460
- ...keywordParams,
744
+ kwSubs: keywordSubscriptions,
745
+ limit: int(limit),
746
+ ...(allowedScopes ? { allowedScopes } : {}),
461
747
  },
462
748
  );
463
- for (const record of vectorResult.records) {
749
+ for (const record of propResult.records) {
464
750
  const nodeId = record.get("nodeId") as string;
465
- const scoreRaw = record.get("score");
466
- const score = typeof scoreRaw === "number" ? scoreRaw : Number(scoreRaw);
467
751
  const existing = scoreMap.get(nodeId);
468
752
  if (existing) {
469
- existing.vectorScore = Math.max(existing.vectorScore, score);
753
+ existing.bm25Score = Math.max(existing.bm25Score, 1.0);
754
+ existing.bm25Hit = true;
470
755
  } else {
471
756
  const node = record.get("node") as { properties: Record<string, unknown> };
472
757
  scoreMap.set(nodeId, {
473
758
  nodeId,
474
759
  labels: record.get("nodeLabels") as string[],
475
760
  properties: plainProperties(node.properties),
476
- vectorScore: score,
477
- bm25Score: 0,
478
- bm25Hit: false,
761
+ vectorScore: 0,
762
+ bm25Score: 1.0,
763
+ bm25Hit: true,
479
764
  });
480
765
  }
481
766
  }
482
767
  }
483
768
 
484
- // --- BM25 half ---
485
- const bm25Hits = await bm25Only(session, params);
486
- if (bm25Hits.length > 0) {
487
- const rawScores = bm25Hits.map((h) => h.score);
488
- const normalised = normaliseBm25Scores(rawScores);
489
- for (let i = 0; i < bm25Hits.length; i++) {
490
- mergeBm25Hit(scoreMap, bm25Hits[i], normalised[i]);
491
- }
492
- }
493
-
494
769
  // --- Keyword subscriptions (scope-inclusive, agentSlug-bypassing) ---
495
770
  if (keywordSubscriptions && keywordSubscriptions.length > 0) {
496
771
  for (const kw of keywordSubscriptions) {
@@ -547,7 +822,69 @@ export async function hybrid(
547
822
  }
548
823
  }
549
824
 
550
- // --- Merge, threshold, rank ---
825
+ // --- Fusion ---
826
+ //
827
+ // Weighted-sum (default): combined = vw * vector + bw * bm25_norm, with
828
+ // weights from the route plan (or the 0.7/0.3 lib defaults when routing
829
+ // is off). This preserves the baseline single-query single-fusion path
830
+ // when no flags are flipped on.
831
+ //
832
+ // RRF (flagged): contributions from each pass's vector and BM25 ranked
833
+ // lists are summed across all passes; combinedScore becomes the RRF
834
+ // score. Per-half components on `ScoredNode` (vectorScore / bm25Score)
835
+ // are retained so the /data UI's score-line renders consistently.
836
+ let allMerged: Array<ScoredNode & { combinedScore: number }>;
837
+ if (useRrf) {
838
+ const rankedLists: ScoredNode[][] = [];
839
+ for (const pass of passes) {
840
+ if (pass.vectorRanked.length > 0) rankedLists.push(pass.vectorRanked);
841
+ if (pass.bm25Ranked.length > 0) rankedLists.push(pass.bm25Ranked);
842
+ }
843
+ const fused = fuseRrf(rankedLists, rrfK);
844
+ allMerged = fused.map((f) => {
845
+ // Use the score-map representative so vectorScore / bm25Score reflect
846
+ // the cross-pass max, not just whichever list ranked first. The
847
+ // representative is guaranteed to exist (scoreMap aggregated the
848
+ // union); fall back to the fusion entry if not (defensive).
849
+ const rep = scoreMap.get(f.nodeId) ?? f;
850
+ return { ...rep, combinedScore: f.rrfScore };
851
+ });
852
+ } else {
853
+ allMerged = [...scoreMap.values()].map((node) => ({
854
+ ...node,
855
+ combinedScore: plan.vectorWeight * node.vectorScore + plan.bm25Weight * node.bm25Score,
856
+ }));
857
+ }
858
+ const fusedCount = allMerged.length;
859
+
860
+ // --- Boosts (Task 308) ---
861
+ // Both are no-ops on the current corpus until Task 305 (typed-edge
862
+ // backlinks) and Task 306 (compiledTruth property) land. Reading a
863
+ // missing property as zero is the correct behaviour — the boost is
864
+ // additive over hits that have the field.
865
+ if (useBoosts) {
866
+ allMerged = applyCompiledTruthBoost(
867
+ applyBacklinkBoost(allMerged.map((m) => ({
868
+ ...m,
869
+ score: m.combinedScore,
870
+ }))),
871
+ ).map((m) => ({ ...m, combinedScore: m.score }));
872
+ }
873
+ const boostedCount = allMerged.length;
874
+
875
+ // --- Dedup (Task 308) ---
876
+ // 4-layer dedup is a strict superset of the baseline nodeId-only dedup.
877
+ // The post-fusion scoreMap is already nodeId-deduped, so when no flag
878
+ // is set this step is a no-op.
879
+ if (useDedup) {
880
+ allMerged = dedupHits(
881
+ allMerged.map((m) => ({ ...m, score: m.combinedScore })),
882
+ dedupLayers,
883
+ ).map((m) => ({ ...m, combinedScore: m.score }));
884
+ }
885
+ const dedupedCount = allMerged.length;
886
+
887
+ // --- Threshold + sort + slice ---
551
888
  //
552
889
  // vectorThreshold filters BEFORE sort+slice. Order matters:
553
890
  // slicing first would consume the slot budget with rows the threshold
@@ -562,10 +899,6 @@ export async function hybrid(
562
899
  // normalisation pins the lowest BM25 row's score to 0.0, so checking
563
900
  // bm25Score > 0 would silently drop a literal-token match that just
564
901
  // happens to be the worst-scored BM25 hit in the set.
565
- const allMerged = [...scoreMap.values()].map((node) => ({
566
- ...node,
567
- combinedScore: VECTOR_WEIGHT * node.vectorScore + BM25_WEIGHT * node.bm25Score,
568
- }));
569
902
  const rawMerged = allMerged.length;
570
903
  const threshold = params.vectorThreshold;
571
904
  let kept = allMerged;
@@ -668,13 +1001,22 @@ export async function hybrid(
668
1001
  }
669
1002
 
670
1003
  return {
671
- mode: "hybrid",
1004
+ mode: useRrf ? "rrf" : "hybrid",
672
1005
  results,
673
1006
  expandMs,
674
1007
  rawMerged,
675
1008
  suppressed,
676
1009
  bm25Bypass,
677
1010
  threshold: threshold ?? null,
1011
+ stageCounts: {
1012
+ expansions: expansions.length,
1013
+ vector: totalVector,
1014
+ bm25: totalBm25,
1015
+ fused: fusedCount,
1016
+ boosted: boostedCount,
1017
+ deduped: dedupedCount,
1018
+ final: results.length,
1019
+ },
678
1020
  };
679
1021
  }
680
1022