diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 9a4832db6..060949536 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -269,6 +269,7 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-30 | PR-1494 | 807a3a09f5afc12e8db4f9158abe09d9c7b336c9 | PR #1494 pre-commit fail-open review | FIXED P2: legacy worktrees may skip a genuinely absent generator, while a staged deletion or rename now fails closed | docs-inventory Vitest 5 passed; shell syntax passed; Prettier test check passed; diff check passed | | 2026-07-30 | claude/x3-rag-coverage-gate-qx9j7d (PR #1463, squashed as dba7356f) | dba7356fc8dc926d951d6de6f019d5b8e000be21 | X3/#101 per-request hydration extraction from rag.ts into rag-hydration.ts | clean and landed — byte-identical move verified against pre-merge main, rag.ts 4780->4543, budget ratcheted to 4543, no back-edge (cluster referenced zero rag.ts-local symbols), both public re-exports preserved; squash captured 100% of branch content | typecheck, lint, check:knip, check:maintainability-budgets 4543/4543, focused vitest 83/83 incl rag-query-concurrency, eval:rag:offline 572/572 36 golden, format:check, verify:cheap, verify:pr-local build+bundle-scan, post-merge content verification on main | | 2026-07-30 | dba7356fc8dc926d951d6de6f019d5b8e000be21 | dba7356fc8dc926d951d6de6f019d5b8e000be21 | X3 hydration unit: per-request hydration extraction from rag.ts into rag-hydration.ts (PR #1463) | clean and landed — byte-identical move verified against pre-merge main, rag.ts 4780->4543, budget ratcheted to 4543, no back-edge, both public re-exports preserved. Supersedes the earlier row for this HEAD, which was keyed only to the slash-form branch token and so returned NOT REVIEWED on a landed-SHA lookup; it also mislabelled the unit as #101, which is the unrelated open canary-gated retrieval-parallelisation recommendation | typecheck, lint, check:knip, check:maintainability-budgets 4543/4543, focused vitest 83/83, eval:rag:offline 572/572 36 golden, format:check, verify:cheap 442 files 4625 passed, verify:pr-local, post-merge content verification on main | +| 2026-07-30 | PR #1474 | 098186866932394d2cc17983e566ae6c44b063b4 | PR #1474 full diff vs origin/main | approved | verify:cheap; eval:rag:offline; live canary 30578169116 -> 30579534353 | | 2026-07-30 | codex/coverage-scope-policy | 94f97cdb1d0543724de408f19e79d64e61c8b31a | issue 139 coverage scope policy | approved: workflow coverage breadth is deliberate and test-pinned; docs-like skills remain static-only | check:ci-scope; check:gate-manifest; check:outstanding-issues; prettier; diff check | | 2026-07-30 | codex/coverage-scope-policy | 4da2a003bc2254507662d1b8b6e9768e94371abd | issue 139 coverage scope policy post-sync | approved: late main sync preserves deliberate workflow coverage and static-only skill policy | check:ci-scope; check:outstanding-issues; check:branch-review-ledger; diff check | | 2026-07-30 | codex/archive-completed-ci-tasks | 5c902f422ceee78ef68132900fda734c1d5bc1f8 | archive issues 133 and 135 | approved: both rows were already resolved on current main and focused guards prove their contracts | check:ci-scope; check:outstanding-issues; check:branch-review-ledger; diff check | diff --git a/src/lib/rag/rag-candidate-sources.ts b/src/lib/rag/rag-candidate-sources.ts index c16d88d1d..782bcac7b 100644 --- a/src/lib/rag/rag-candidate-sources.ts +++ b/src/lib/rag/rag-candidate-sources.ts @@ -1166,20 +1166,22 @@ export async function searchIndexUnitCandidates(args: { export type MemoryCardCache = Map>; -/** With memory boosted candidates. */ -export async function withMemoryBoostedCandidates(args: { +export type MemoryBoostArtifacts = { + cards: Awaited>; + memoryChunkResults: SearchResult[]; +}; + +/** Load the memory evidence that is independent of the current candidate payload. */ +export async function loadMemoryBoostArtifacts(args: { supabase: ReturnType; query: string; - candidates: SearchResult[]; queryEmbedding?: number[]; ownerId?: string; accessScope?: RetrievalAccessScope; documentIds?: string[]; matchCount: number; cardCache?: MemoryCardCache; -}) { - // A3: the memory-card fetch is invoked at several waterfall stages. Memoize per request, - // scoped by owner/document filters because fetchMemoryCardsForQuery applies those filters. +}): Promise { const effectiveMatchCount = Math.max(args.matchCount, 48); const documentScope = args.documentIds?.length ? [...args.documentIds].sort().join(",") : "all-documents"; const cacheKey = [ @@ -1203,12 +1205,34 @@ export async function withMemoryBoostedCandidates(args: { args.cardCache?.set(cacheKey, cardsPromise); } const cards = await cardsPromise; - if (cards.length === 0) return { results: args.candidates, cards }; + const memoryChunkResults = cards.length + ? await loadChunksForMemoryCards(args.supabase, cards, retrievalAccessScopeForArgs(args)) + : []; + return { cards, memoryChunkResults }; +} - const memoryChunkResults = await loadChunksForMemoryCards(args.supabase, cards, retrievalAccessScopeForArgs(args)); - const merged = mergeSearchResults(memoryChunkResults, args.candidates); +/** Apply already-loaded memory evidence to the final hydrated candidate payload. */ +export function applyMemoryBoostArtifacts(query: string, candidates: SearchResult[], artifacts: MemoryBoostArtifacts) { + if (artifacts.cards.length === 0) return { results: candidates, cards: artifacts.cards }; + const merged = mergeSearchResults(artifacts.memoryChunkResults, candidates); return { - results: applyMemoryCardBoosts(args.query, merged, cards), - cards, + results: applyMemoryCardBoosts(query, merged, artifacts.cards), + cards: artifacts.cards, }; } + +/** With memory boosted candidates. */ +export async function withMemoryBoostedCandidates(args: { + supabase: ReturnType; + query: string; + candidates: SearchResult[]; + queryEmbedding?: number[]; + ownerId?: string; + accessScope?: RetrievalAccessScope; + documentIds?: string[]; + matchCount: number; + cardCache?: MemoryCardCache; +}) { + const artifacts = await loadMemoryBoostArtifacts(args); + return applyMemoryBoostArtifacts(args.query, args.candidates, artifacts); +} diff --git a/src/lib/rag/rag-hydration.ts b/src/lib/rag/rag-hydration.ts index f4d4916da..102f01779 100644 --- a/src/lib/rag/rag-hydration.ts +++ b/src/lib/rag/rag-hydration.ts @@ -5,6 +5,12 @@ import { normalizeImageBbox } from "@/lib/image-filtering"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { metadataText, safeRecord } from "@/lib/rag/rag-answer-text"; import { compactContextText } from "@/lib/rag/rag-source-block"; +import type { RetrievalAccessScope } from "@/lib/owner-scope"; +import { + applyMemoryBoostArtifacts, + loadMemoryBoostArtifacts, + type MemoryCardCache, +} from "@/lib/rag/rag-candidate-sources"; // Extracted from rag.ts (maturity X3 / #101): per-request hydration of retrieved // results — document ranking metadata, cached index quality, and page visual @@ -27,6 +33,40 @@ export function createDocumentRankingMetadataCache(): DocumentRankingMetadataCac }; } +/** Overlap independent metadata and memory reads, then merge them deterministically. */ +export async function hydrateCandidatesWithMetadataAndMemory(args: { + supabase: ReturnType; + query: string; + candidates: SearchResult[]; + queryEmbedding?: number[]; + ownerId?: string; + accessScope?: RetrievalAccessScope; + documentIds?: string[]; + matchCount: number; + metadataCache: DocumentRankingMetadataCache; + cardCache: MemoryCardCache; + measurePhase: (phase: string, operation: () => Promise) => Promise; +}) { + // Neither read consumes the other's result. Candidate assembly remains ordered because + // memory boosts are applied only after both promises settle, preserving the serial output. + const [metadataCandidates, memoryArtifacts] = await args.measurePhase("metadata_and_memory_hydration", () => + Promise.all([ + attachDocumentRankingMetadata(args.supabase, args.candidates, args.ownerId, args.metadataCache), + loadMemoryBoostArtifacts({ + supabase: args.supabase, + query: args.query, + queryEmbedding: args.queryEmbedding, + ownerId: args.ownerId, + accessScope: args.accessScope, + documentIds: args.documentIds, + matchCount: args.matchCount, + cardCache: args.cardCache, + }), + ]), + ); + return { ...applyMemoryBoostArtifacts(args.query, metadataCandidates, memoryArtifacts), metadataCandidates }; +} + /** Attach document ranking metadata. */ export async function attachDocumentRankingMetadata( supabase: ReturnType, diff --git a/src/lib/rag/rag.ts b/src/lib/rag/rag.ts index 4200cc327..9abf930d8 100644 --- a/src/lib/rag/rag.ts +++ b/src/lib/rag/rag.ts @@ -221,6 +221,7 @@ import { attachDocumentRankingMetadata, attachPageVisualEvidence, createDocumentRankingMetadataCache, + hydrateCandidatesWithMetadataAndMemory, type DocumentRankingMetadataCache, } from "@/lib/rag/rag-hydration"; export { attachDocumentRankingMetadata, attachPageVisualEvidence } from "@/lib/rag/rag-hydration"; @@ -2106,27 +2107,20 @@ export async function searchChunksWithTelemetry( if (documentLookupData.length > 0) { const rerankStartedAt = Date.now(); - const documentLookupCandidates = await measureSearchPhase(searchTiming, "metadata_hydration", () => - attachDocumentRankingMetadata( - supabase, - mergeSearchResults(documentLookupData, textFastResults), - args.ownerId, - documentRankingMetadataCache, - ), - ); + const memoryBoost = await hydrateCandidatesWithMetadataAndMemory({ + supabase, + query: args.query, + candidates: mergeSearchResults(documentLookupData, textFastResults), + ownerId: args.ownerId, + accessScope: args.accessScope, + documentIds: documentFilterList, + matchCount: candidateCount, + metadataCache: documentRankingMetadataCache, + cardCache: memoryCardCache, + measurePhase: (phase, operation) => measureSearchPhase(searchTiming, phase, operation), + }); + const documentLookupCandidates = memoryBoost.metadataCandidates; expandedQuery = expandClinicalQueryWithCandidateMetadata(args.query, expandedQuery, documentLookupCandidates); - const memoryBoost = await measureSearchPhase(searchTiming, "memory_hydration", () => - withMemoryBoostedCandidates({ - supabase, - query: args.query, - candidates: documentLookupCandidates, - ownerId: args.ownerId, - accessScope: args.accessScope, - documentIds: documentFilterList, - matchCount: candidateCount, - cardCache: memoryCardCache, - }), - ); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( telemetry.memory_top_score ?? 0, @@ -2357,22 +2351,19 @@ export async function searchChunksWithTelemetry( if (!hybridError) { const rerankStartedAt = Date.now(); const merged = args.forceEmbedding ? vectorCandidates : mergeSearchResults(vectorCandidates, textFastResults); - const mergedWithMetadata = await measureSearchPhase(searchTiming, "metadata_hydration", () => - attachDocumentRankingMetadata(supabase, merged, args.ownerId, documentRankingMetadataCache), - ); - const memoryBoost = await measureSearchPhase(searchTiming, "memory_hydration", () => - withMemoryBoostedCandidates({ - supabase, - query: retrievalQuery, - candidates: mergedWithMetadata, - queryEmbedding: embedding, - ownerId: args.ownerId, - accessScope: args.accessScope, - documentIds: documentFilterList, - matchCount: candidateCount, - cardCache: memoryCardCache, - }), - ); + const memoryBoost = await hydrateCandidatesWithMetadataAndMemory({ + supabase, + query: retrievalQuery, + candidates: merged, + queryEmbedding: embedding, + ownerId: args.ownerId, + accessScope: args.accessScope, + documentIds: documentFilterList, + matchCount: candidateCount, + metadataCache: documentRankingMetadataCache, + cardCache: memoryCardCache, + measurePhase: (phase, operation) => measureSearchPhase(searchTiming, phase, operation), + }); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( telemetry.memory_top_score ?? 0, @@ -2445,27 +2436,21 @@ export async function searchChunksWithTelemetry( mergeSearchResults(resultSets.flat(), embeddingFieldCandidates), indexUnitCandidates, ); - const mergedWithMetadata = await measureSearchPhase(searchTiming, "metadata_hydration", () => - attachDocumentRankingMetadata( - supabase, - args.forceEmbedding ? fallbackVectorCandidates : mergeSearchResults(fallbackVectorCandidates, textFastResults), - args.ownerId, - documentRankingMetadataCache, - ), - ); - const memoryBoost = await measureSearchPhase(searchTiming, "memory_hydration", () => - withMemoryBoostedCandidates({ - supabase, - query: retrievalQuery, - candidates: mergedWithMetadata, - queryEmbedding: embedding, - ownerId: args.ownerId, - accessScope: args.accessScope, - documentIds: documentFilterList, - matchCount: candidateCount, - cardCache: memoryCardCache, - }), - ); + const memoryBoost = await hydrateCandidatesWithMetadataAndMemory({ + supabase, + query: retrievalQuery, + candidates: args.forceEmbedding + ? fallbackVectorCandidates + : mergeSearchResults(fallbackVectorCandidates, textFastResults), + queryEmbedding: embedding, + ownerId: args.ownerId, + accessScope: args.accessScope, + documentIds: documentFilterList, + matchCount: candidateCount, + metadataCache: documentRankingMetadataCache, + cardCache: memoryCardCache, + measurePhase: (phase, operation) => measureSearchPhase(searchTiming, phase, operation), + }); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( telemetry.memory_top_score ?? 0, diff --git a/tests/eval-retrieval.test.ts b/tests/eval-retrieval.test.ts index 671ec7638..05bf5e039 100644 --- a/tests/eval-retrieval.test.ts +++ b/tests/eval-retrieval.test.ts @@ -76,13 +76,16 @@ describe("golden retrieval eval helpers", () => { telemetry: { query_class: "table_threshold", search_total_latency_ms: 321, - retrieval_phase_latencies_ms: { query_classification: 12, metadata_hydration: 34 }, + retrieval_phase_latencies_ms: { query_classification: 12, metadata_and_memory_hydration: 34 }, }, latencyMs: 321, }); expect(evaluated.searchTotalLatencyMs).toBe(321); - expect(evaluated.retrievalPhaseLatenciesMs).toEqual({ query_classification: 12, metadata_hydration: 34 }); + expect(evaluated.retrievalPhaseLatenciesMs).toEqual({ + query_classification: 12, + metadata_and_memory_hydration: 34, + }); }); it("scores ideal graded signal ranking, coverage, and irrelevant sources", () => { diff --git a/tests/rag-retrieval-parallelism.test.ts b/tests/rag-retrieval-parallelism.test.ts new file mode 100644 index 000000000..e86c37f52 --- /dev/null +++ b/tests/rag-retrieval-parallelism.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +describe("retrieval hydration parallelism", () => { + it("overlaps only independent metadata and memory reads before deterministic assembly", () => { + const source = readFileSync("src/lib/rag/rag-hydration.ts", "utf8"); + const helper = source.slice( + source.indexOf("export async function hydrateCandidatesWithMetadataAndMemory"), + source.indexOf("/** Attach document ranking metadata. */"), + ); + + expect(helper).toContain("Promise.all(["); + expect(helper).toContain("attachDocumentRankingMetadata("); + expect(helper).toContain("loadMemoryBoostArtifacts({"); + expect(helper.indexOf("applyMemoryBoostArtifacts(")).toBeGreaterThan(helper.indexOf("Promise.all([")); + }); + + it("uses the parallel helper on all post-gate vector and document-lookup branches", () => { + const source = readFileSync("src/lib/rag/rag.ts", "utf8"); + const callCount = source.match(/await hydrateCandidatesWithMetadataAndMemory\(\{/g)?.length ?? 0; + + expect(callCount).toBe(3); + }); +});