检索插件可替换,但读取边界必须由 ScopedView 固定
Halofy 检索源码拆解:ScopedView 只读视图、ContextAllocator lane packing、FaultHandler L2/L3 阈值、BM25 brownout 与 driver score calibration。
观察结论
它不是又一个 prompt memory 插件,而是把身份、ACL、写入验证、检索驱动、审计链和合规删除放进同一个内核边界。
Driver 只拿 refs,不拿数据库
源码判断
src/driver/view.ts:1 ScopedView 文件头声明它是 driver 读取的唯一世界。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/driver/view.ts:140 whole-word matcher 降低实体名误召回。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/driver/view.ts:181 candidates 带 validOnly/access/sourceProjection。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/driver/view.ts:220 vectorSearch 继续沿用 access 和 sourceIds。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/driver/view.ts:242 sourceProjection 下 concepts fail closed。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/driver/view.ts:260 entityFacts 从 scoped fact pool 开始构造实体跳数。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
181export function buildScopedView<TDb>(
182 deps: ScopedViewDeps<TDb>,
183 namespace: Namespace,
184 access?: AccessSubject,
185 sourceProjection?: RetrievalSourceProjection,
186): ScopedView {
187 return {
188 namespace,
189
190 async candidates(limit = DEFAULT_CANDIDATE_LIMIT) {
191 const rows = await deps.index.listCandidates(namespace, {
192 validOnly: true,
193 limit,
194 access,
195 sourceIds: sourceProjection?.sourceIds,
196 });
197 return rows.map((r) => ({
198 ref: r.id,
199 content: retrievalContent(r, sourceProjection),
200 }));
201 },
202
203 ...(deps.index.listLexicalCandidates
204 ? {
205 async lexicalCandidates(query: string, limit: number) {
206 const rows = await deps.index.listLexicalCandidates!(namespace, query, {
207 validOnly: true,
208 limit,
209 access,
210 sourceIds: sourceProjection?.sourceIds,
211 });
212 return rows.map((r) => ({
213 ref: r.id,
214 content: retrievalContent(r, sourceProjection),
215 }));
216 },
217 }
218 : {}),
219
220 async vectorSearch(embedding, k) {
221 const hits = await deps.index.vectorSearch(
222 namespace,
223 embedding,
224 k,
225 true,
226 false,
227 false,
228 access,
229 sourceProjection?.sourceIds,
230 );
231 return hits.map((h) => ({
232 ref: h.obj.id,
233 content: retrievalContent(h.obj, sourceProjection),
234 score: h.similarity,
235 }));
236 },
237
238 async embed(texts) {
239 return deps.embedder.embed(texts);
240 },
241
242 async concepts(query, k) {
243 // L3 concepts do not retain per-source provenance. A source-scoped view
244 // therefore fails closed instead of allowing a future driver to widen
245 // the result set through the cold tier.
246 if (sourceProjection?.sourceIds !== undefined) return [];
247 return deps.cold.searchConcepts(deps.db, deps.embedder, namespace, query, k);
248 },
249
250 async entityFacts(mention, hops) {
251 const needle = mention.trim().toLowerCase();
252 if (needle.length === 0) return [];
253
254 const entities = await deps.entities.listEntities(namespace);
255 const seeds = entities.filter((e) =>
256 lowerNames(e).some((n) => n === needle),
257 );
258 if (seeds.length === 0) return []; 260 // ACL-scoped fact pool (delegated to the index — see module header).
261 const pool = await deps.index.listCandidates(namespace, {
262 validOnly: true,
263 limit: DEFAULT_CANDIDATE_LIMIT,
264 access,
265 sourceIds: sourceProjection?.sourceIds,
266 });
267 const docs = pool.map((row) => {
268 const content = retrievalContent(row, sourceProjection);
269 return { ref: row.id, content, lower: content.toLowerCase() };
270 });
271
272 const out: { ref: string; content: string; entity: string }[] = [];
273 const seen = new Set<string>();
274
275 // Hop 1: facts mentioning a seed entity (whole-word, ci).
276 const hop1Lower: string[] = [];
277 for (const seed of seeds) {
278 const matchers = nameMatchers(lowerNames(seed));
279 for (const d of docs) {
280 if (!mentions(d.lower, matchers)) continue;
281 if (!seen.has(d.ref)) {
282 seen.add(d.ref);
283 out.push({ ref: d.ref, content: d.content, entity: seed.canonicalName });
284 hop1Lower.push(d.lower);
285 }
286 }
287 }
288
289 if (hops === 2 && hop1Lower.length > 0) {
290 // Hop 2: entities co-mentioned in hop-1 facts → their facts.
291 const seedNames = new Set(seeds.map((s) => s.canonicalName));
292 for (const other of entities) {
293 if (seedNames.has(other.canonicalName)) continue;
294 const matchers = nameMatchers(lowerNames(other));
295 if (!hop1Lower.some((c) => mentions(c, matchers))) continue;
296 for (const d of docs) {
297 if (seen.has(d.ref) || !mentions(d.lower, matchers)) continue;
298 seen.add(d.ref);
299 out.push({ ref: d.ref, content: d.content, entity: other.canonicalName });
300 }
301 }
302 }
303
304 return out;
305 },工作集按治理优先级贪心打包
源码判断
src/allocator/index.ts:7 policy-directive 作为 lane 0 获得预留 token slice。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/allocator/index.ts:125 tryAdd 用预算和 ref 去重控制工作集。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/allocator/index.ts:157 governed stable pins 在 legacy path pins 前处理。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/allocator/index.ts:207 scope-memory lane 只使用有效且曾访问的候选。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/allocator/index.ts:232 predicted-relevant 只在 taskHint 非空时启动。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/allocator/index.ts:285 assemble audit detail 记录 blocks、tokens 与 policy digest。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
110 async assemble(args: AssembleArgs): Promise<WorkingSet> {
111 const t0 = performance.now();
112 const { namespace, policy, taskHint } = args;
113 // Defensive: some callers (syscall layer) route audit attribution
114 // themselves and may omit actor/agentId at runtime.
115 const actor = args.actor ?? "unknown";
116 const agentId = args.agentId ?? "unknown";
117 const budget = policy.tokenBudget;
118
119 let totalTokens = 0;
120 let rejectedPins = 0;
121 let brownoutDetail = "";
122 const selected: ContextBlock[] = [];
123 const usedRefs = new Set<string>();
124
125 const tryAdd = (block: ContextBlock): void => {
126 if (usedRefs.has(block.ref)) return;
127 if (totalTokens + block.tokenEstimate > budget) return; // never split
128 selected.push(block);
129 usedRefs.add(block.ref);
130 totalTokens += block.tokenEstimate;
131 };
132
133 // -- lane 0: governed policy directives ---------------------------------
134 // Guardrails are packed first out of a reserved slice of the budget, so
135 // no amount of recall can push a policy out of the working set. The
136 // reserve is a CEILING, not an allotment: unused policy tokens stay
137 // available to the ordinary lanes below.
138 let policySet: PolicyDirectiveSet | null = null;
139 if (this.deps.policyDirectives) {
140 try {
141 const loaded = await this.deps.policyDirectives.load(
142 namespace,
143 policyTokenReserve(budget),
144 );
145 if (loaded.documents.length > 0) {
146 policySet = loaded;
147 for (const block of loaded.blocks) tryAdd(block);
148 }
149 } catch {
150 // Brownout (kernel rule 5): an unreadable guardrail lane degrades the
151 // working set, it never fails the agent's call. The audit detail below
152 // records that policy was unavailable for this assembly.
153 brownoutDetail += " brownout=policy_unavailable";
154 }
155 }
156
157 // -- lane 1a: governed stable pins (durable creation order) --------------
158 if (this.deps.pins) {
159 try {
160 const governed = await this.deps.pins.resolve(namespace, args.access);
161 rejectedPins += governed.rejected;
162 for (let i = 0; i < governed.pins.length; i++) {
163 const pin = governed.pins[i];
164 tryAdd({
165 kind: pin.kind === "memory" ? "pinned-memory" : "pinned-concept",
166 ref: pin.ref,
167 content: pin.content,
168 tokenEstimate: estimateTokens(pin.content),
169 orderKey: `0-pin-0-governed-${String(i).padStart(6, "0")}-${pin.pinId}`,
170 });
171 }
172 } catch {
173 // Brownout: pin metadata or its backing store may be unavailable. The
174 // compatibility and ordinary lanes still provide a working set.
175 rejectedPins++;
176 }
177 } 207 // -- lane 2: most-recently-accessed valid scope memories -----------------
208 const rows = await this.deps.index.listCandidates(namespace, {
209 validOnly: true,
210 limit: CANDIDATE_LIMIT,
211 access: args.access,
212 });
213 const now = Date.now();
214 const accessed = rows
215 .filter((r) => r.tValidTo === null || Date.parse(r.tValidTo) > now)
216 .filter(
217 (r) =>
218 r.telemetry.lastAccessed !== null || r.telemetry.accessCount > 0,
219 )
220 .sort(byRecencyDesc)
221 .slice(0, SCOPE_LANE_LIMIT);
222 for (const row of accessed) {
223 tryAdd({
224 kind: "scope-memory",
225 ref: row.id,
226 content: row.content,
227 tokenEstimate: estimateTokens(row.content),
228 orderKey: `1-scope-${row.id}`,
229 });
230 }
231
232 // -- lane 3: predicted-relevant (only with a task hint) ------------------
233 if (taskHint && taskHint.trim().length > 0) {
234 let hits: HybridHit[];
235 try {
236 hits = await hybridSearchL2({
237 index: this.deps.index,
238 embedder: this.deps.embedder,
239 namespace,
240 query: taskHint,
241 k: PREDICTED_K,
242 candidateLimit: CANDIDATE_LIMIT,
243 access: args.access,
244 });
245 } catch {
246 const scores = bm25Scores(
247 taskHint,
248 rows.map((row) => ({ ref: row.id, content: row.content })),
249 );
250 hits = rows
251 .map((row) => ({ row, score: saturate(scores.get(row.id) ?? 0) }))
252 .filter((hit) => hit.score > 0)
253 .sort((a, b) => b.score - a.score || a.row.id.localeCompare(b.row.id))
254 .slice(0, PREDICTED_K);
255 brownoutDetail += " brownout=embedding_unavailable";
256 }
257 for (const hit of hits) {
258 if (hit.score <= 0) continue;
259 tryAdd({
260 kind: "predicted-relevant",
261 ref: hit.row.id,
262 content: hit.row.content,
263 tokenEstimate: estimateTokens(hit.row.content),
264 orderKey: `2-rel-${hit.row.id}`,
265 });
266 }
267 } 285 const assembledAt = new Date().toISOString();
286 const event = {
287 ts: assembledAt,
288 actor,
289 agentId,
290 op: "assemble",
291 memoryId: null,
292 namespace,
293 purpose: taskHint ?? null,
294 result: "ok",
295 detail: `blocks=${selected.length} tokens=${totalTokens} budget=${budget} rejectedPins=${rejectedPins}${policyDetail}${brownoutDetail}`,
296 } as const;
297 // D36: with the kernel's transact/usage wiring the audit row and its
298 // companion usage row commit in one transaction; module-level tests keep
299 // the standalone append.
300 const writeAudit = async (audit: AuditPort, tx?: unknown): Promise<void> => {
301 const auditSeq = await audit.append(event);
302 if (this.deps.usage && tx !== undefined && typeof auditSeq === "number" && args.usage) {
303 await this.deps.usage(tx, {
304 auditSeq,
305 traceId: args.usage.traceId,
306 ts: assembledAt,
307 namespace,
308 actor,
309 principal: args.usage.principal,
310 callerClass: args.usage.callerClass,
311 op: "assemble",
312 tool: args.usage.tool,
313 result: "ok",
314 latencyMs: performance.now() - t0,
315 tokens: totalTokens,
316 returned: selected.length,
317 driver: null,
318 sourceTier: null,
319 sourceIds: null,
320 topScore: null,
321 });
322 }
323 };
324 if (this.deps.transact) {
325 await this.deps.transact(writeAudit);
326 } else {
327 await writeAudit(this.deps.audit);
328 }
329
330 if (deliveredPolicy && this.deps.policyDirectives?.onDelivered) {
331 try {
332 await this.deps.policyDirectives.onDelivered(deliveredPolicy, args);
333 } catch {
334 // The ledger is evidence, not a gate: a failed compliance write never
335 // costs the agent its working set.
336 }
337 }
338
339 return {
340 namespace,
341 blocks: selected,
342 totalTokens,
343 budget,
344 assembledAt,
345 ...(deliveredPolicy
346 ? {
347 policy: {
348 digest: deliveredPolicy.digest,
349 documents: deliveredPolicy.documents,
350 tokens: deliveredPolicy.totalTokens,
351 required: deliveredPolicy.required,
352 truncated: deliveredPolicy.truncated,
353 },
354 }
355 : {}),
356 };Fault 命中必须过绝对分数门槛
源码判断
src/fault/index.ts:49 L2/L3 score floor 把 fault 变成有门槛的补召回。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/fault/index.ts:182 driver-planned L2 仍要经过 absoluteScores 校准。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/fault/index.ts:217 embedding brownout 会退到 BM25。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/fault/index.ts:265 L2 不足才走 L3 cold concept。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/fault/index.ts:317 access/fault telemetry 与 audit 一起提交。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
src/fault/index.ts:361 absoluteScores 的注释解释为什么 driver 分数不能直接和固定 floor 比。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
test/allocator-fault.test.ts:227 assemble 测试覆盖优先级与预算。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
test/allocator-fault.test.ts:459 embedding 失败测试确认 brownout 行为。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
test/allocator-fault.test.ts:586 driver-lane 测试防止只用返回 refs 计算 BM25 IDF。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
test/cold-encryption.test.ts:326 冷层 pin 组装测试确认 secret canary 不会进入错误 namespace 的 working set。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
160 async fault(args: FaultArgs): Promise<FaultResult> {
161 const t0 = performance.now();
162 const { namespace, query } = args;
163 // Defensive: some callers (syscall layer) route audit attribution
164 // themselves and may omit actor/agentId at runtime.
165 const actor = args.actor ?? "unknown";
166 const agentId = args.agentId ?? "unknown";
167 const k = args.k ?? DEFAULT_K;
168 const tokenBudget = args.tokenBudget ?? DEFAULT_TOKEN_BUDGET;
169
170 // -- tier L2 --------------------------------------------------------------
171 // M2: when the kernel wired a driver-routed planner, the L2 candidates
172 // come from the policy-selected retriever cartridge; otherwise the
173 // legacy hybrid search runs. Either way the ACCEPTANCE decision uses the
174 // absolute hybrid score against L2_SCORE_FLOOR (see FaultHandlerDeps).
175 let l2Refs: ScoredRef[];
176 let acceptL2: boolean;
177 let bumpTargetId: string | null;
178 let driverDetail = "";
179 let driverName: string | null = null;
180 let degradedReason: FaultResult["degradedReason"];
181
182 try {
183 if (this.deps.l2Planner) {
184 const outcome = await this.deps.l2Planner(query, k, tokenBudget, args.access);
185 driverDetail =
186 ` driver=${outcome.driver}` +
187 (outcome.fallback ? ` driver_fallback=${outcome.fallback}` : "");
188 driverName = outcome.driver;
189 l2Refs = outcome.refs;
190 const abs = await this.absoluteScores(namespace, query, outcome.refs, args.access);
191 acceptL2 = abs.some((s) => s >= L2_SCORE_FLOOR);
192 bumpTargetId = outcome.refs[0]?.ref ?? null;
193 } else {
194 const l2Hits: HybridHit[] = await hybridSearchL2({
195 index: this.deps.index,
196 embedder: this.deps.embedder,
197 namespace,
198 query,
199 k: Math.max(k * 3, 12),
200 access: args.access,
201 });
202 const bestL2 = l2Hits.length > 0 ? l2Hits[0] : null;
203 acceptL2 = bestL2 !== null && bestL2.score >= L2_SCORE_FLOOR;
204 bumpTargetId = bestL2?.row.id ?? null;
205 l2Refs = l2Hits
206 .filter((h) => h.score > 0)
207 .slice(0, k)
208 .map((h) => ({
209 id: h.row.id,
210 tier: "L2" as const,
211 ref: h.row.id,
212 score: h.score,
213 content: h.row.content,
214 tokenEstimate: estimateTokens(h.row.content),
215 }));
216 }
217 } catch {
218 // Immediate brownout, including failures before the circuit breaker has
219 // opened: retain the governed lexical lane and expose the degradation.
220 const rows = await this.deps.index.listCandidates(namespace, {
221 validOnly: true,
222 limit: 200,
223 access: args.access,
224 });
225 const scores = bm25Scores(
226 query,
227 rows.map((row) => ({ ref: row.id, content: row.content })),
228 );
229 l2Refs = rows
230 .map((row) => ({
231 id: row.id,
232 tier: "L2" as const,
233 ref: row.id,
234 // In a lexical-only brownout the BM25 lane owns the full confidence
235 // scale; retaining the hybrid 0.4 weight would make its maximum too
236 // small to clear the calibrated acceptance floor reliably.
237 score: saturate(scores.get(row.id) ?? 0),
238 content: row.content,
239 tokenEstimate: estimateTokens(row.content),
240 }))
241 .filter((ref) => ref.score > 0)
242 .sort((a, b) => b.score - a.score || a.ref.localeCompare(b.ref))
243 .slice(0, k);
244 acceptL2 = (l2Refs[0]?.score ?? 0) >= L2_SCORE_FLOOR;
245 bumpTargetId = l2Refs[0]?.ref ?? null;
246 driverDetail = " driver=bm25-degraded brownout=embedding_unavailable";
247 driverName = "bm25-degraded";
248 degradedReason = "embedding_unavailable";
249 } 260 result = "ok";
261 if (injected.length > 0) {
262 accessIds = injected.map((r) => r.ref);
263 }
264 } else {
265 // -- tier L3: cold concept search --------------------------------------
266 let l3Refs: ScoredRef[];
267 try {
268 l3Refs = (
269 await this.deps.cold.searchConcepts(
270 this.deps.db,
271 this.deps.embedder,
272 namespace,
273 query,
274 k,
275 )
276 ).filter((s) => s.score > L3_SCORE_FLOOR);
277 } catch {
278 // Brownout: an unavailable/corrupt cold tier must not crash the agent
279 // after the governed L2 lane has already been exhausted. The additive
280 // result field and audit detail distinguish this from a genuine miss.
281 l3Refs = [];
282 degradedReason = "cold_unavailable";
283 }
284
285 if (l3Refs.length > 0) {
286 source = "L3";
287 injected = packToBudget(l3Refs.slice(0, k), tokenBudget);
288 result = "ok";
289 } else {
290 source = null;
291 injected = [];
292 result = "miss";
293 }
294 // Fault pressure: the closest L2 memory was not good enough.
295 if (bumpTargetId !== null) {
296 faultId = bumpTargetId;
297 }
298 }
299
300 const latencyMs = Math.max(0, Math.round(performance.now() - t0));
301 const injectedTokens = injected.reduce((s, r) => s + r.tokenEstimate, 0);
302
303 const event = {
304 ts: new Date().toISOString(),
305 actor,
306 agentId,
307 op: "fault",
308 memoryId: injected.length > 0 ? injected[0].id : null,
309 namespace,
310 purpose: args.auditPurpose ?? query,
311 result,
312 detail:
313 `source=${source ?? "none"} injected=${injected.length} tokens=${injectedTokens} latencyMs=${latencyMs}${driverDetail}` +
314 (degradedReason ? ` brownout=${degradedReason}` : ""),
315 } as const;
316
317 const mutateAndAudit = async (index: L2IndexPort, audit: AuditPort, tx?: TDb): Promise<void> => {
318 if (accessIds.length > 0) await index.bumpAccess(accessIds);
319 if (faultId !== null) await index.bumpFault(faultId);
320 const auditSeq = await audit.append(event);
321 // D36: the companion usage row rides the audit transaction (the hook
322 // wraps its writes in a SAVEPOINT brownout). The audit detail string
323 // above stays byte-identical.
324 if (this.deps.usage && tx !== undefined && typeof auditSeq === "number" && args.usage) {
325 await this.deps.usage(tx, {
326 auditSeq,
327 traceId: args.usage.traceId,
328 ts: event.ts,
329 namespace,
330 actor,
331 principal: args.usage.principal,
332 callerClass: args.usage.callerClass,
333 op: "fault",
334 tool: args.usage.tool,
335 result,
336 latencyMs,
337 tokens: injectedTokens,
338 returned: injected.length,
339 driver: driverName,
340 sourceTier: source === "L2" ? "warm" : source === "L3" ? "cold" : null,
341 sourceIds: null,
342 topScore: injected[0]?.score ?? null,
343 });
344 }
345 };
346 if (this.deps.transact) {
347 await this.deps.transact(mutateAndAudit);
348 } else {
349 await mutateAndAudit(this.deps.index, this.deps.audit);
350 }
351
352 return {
353 hit: result === "ok",
354 source,
355 injected,
356 latencyMs,
357 ...(degradedReason ? { degradedReason } : {}),
358 }; 361 /**
362 * Absolute 0.6·cosine + 0.4·saturated-BM25 score per driver-planned ref —
363 * the same scale L2_SCORE_FLOOR was calibrated against (driver plan()
364 * scores are per-query normalized and NOT comparable to a fixed floor).
365 *
366 * BM25 IDF is computed over the SAME ~200-row ACL-scoped candidate pool
367 * the legacy hybridSearchL2 lane used, NOT over just the <=k returned refs.
368 * With an n<=k pool the IDF of a rare query term collapses (≈log(1.2)
369 * instead of ≈log(pool/df)), systematically deflating lexical-dominant
370 * matches below a floor they cleared in M1 — a silent fault hit-rate
371 * regression. Refs beyond the pool cap are unioned in so every ref still
372 * gets a score.
373 */
374 private async absoluteScores(
375 namespace: Namespace,
376 query: string,
377 refs: ScoredRef[],
378 access?: AccessSubject,
379 ): Promise<number[]> {
380 if (refs.length === 0) return [];
381 const [queryVec] = await this.deps.embedder.embed([query]);
382 const contentVecs = await this.deps.embedder.embed(refs.map((r) => r.content));
383
384 // IDF context: full ACL-scoped candidate pool (M1 parity: hybridSearchL2
385 // ran bm25Scores over listCandidates(limit 200) ∪ vectorSearch).
386 const pool = await this.deps.index.listCandidates(namespace, {
387 validOnly: true,
388 limit: 200,
389 access,
390 });
391 const docsByRef = new Map<string, { ref: string; content: string }>();
392 for (const row of pool) docsByRef.set(row.id, { ref: row.id, content: row.content });
393 for (const r of refs) {
394 if (!docsByRef.has(r.ref)) docsByRef.set(r.ref, { ref: r.ref, content: r.content });
395 }
396 const bm = bm25Scores(query, [...docsByRef.values()]);
397
398 return refs.map((r, i) => {
399 const vecScore = Math.max(0, cosineSim(queryVec, contentVecs[i]));
400 const bmScore = saturate(bm.get(r.ref) ?? 0);
401 return 0.6 * vecScore + 0.4 * bmScore;
402 });
403 }