Coverage for astrocyte/pipeline/recall_stage.py: 86%

251 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1from __future__ import annotations 

2 

3import asyncio 

4import logging 

5from datetime import datetime, timezone 

6from typing import TYPE_CHECKING, Any 

7 

8from astrocyte.pipeline.embedding import generate_embeddings 

9from astrocyte.pipeline.entity_extraction import extract_entities 

10from astrocyte.pipeline.fusion import ( 

11 ScoredItem, 

12 memory_hits_as_scored, 

13 rrf_fusion, 

14 weighted_rrf_fusion, 

15) 

16from astrocyte.pipeline.hyde import generate_hyde_vector 

17from astrocyte.pipeline.link_expansion import link_expansion 

18from astrocyte.pipeline.query_intent import ( 

19 QueryIntent, 

20 classify_query_intent, 

21 weights_for_intent, 

22) 

23from astrocyte.pipeline.query_plan import build_query_plan 

24from astrocyte.pipeline.reranking import ( 

25 apply_context_diversity, 

26 basic_rerank, 

27 llm_pairwise_rerank, 

28) 

29from astrocyte.pipeline.retrieval import parallel_retrieve 

30from astrocyte.policy.homeostasis import enforce_token_budget 

31from astrocyte.types import ( 

32 MemoryHit, 

33 RecallRequest, 

34 RecallResult, 

35 RecallTrace, 

36 VectorFilters, 

37 VectorItem, 

38) 

39 

40if TYPE_CHECKING: 

41 pass 

42 

43 

44 

45from astrocyte.pipeline._orchestrator_common import ( 

46 _deterministic_names, 

47 _warn_on_version_drift, 

48) 

49from astrocyte.pipeline.provenance import stored_source 

50 

51_logger = logging.getLogger("astrocyte.mip") 

52 

53 

54class RecallStageMixin: 

55 """Recall pipeline: parallel retrieve → fuse → expand → rerank. 

56 

57 A behavior slice of PipelineOrchestrator, composed into it as a mixin. 

58 ``self`` is the full orchestrator at runtime (attributes come from its 

59 ``__init__`` / ``apply_config``); shared helpers live on sibling mixins. 

60 """ 

61 

62 async def _expand_via_sibling_chunks( 

63 self, 

64 fused: list, 

65 bank_id: str, 

66 ) -> list: 

67 """M10: extend ``fused`` with vectors from sibling chunks of each top-K hit. 

68 

69 Walks the top-K hits, asks the SourceStore for the sibling chunks 

70 of each hit's ``chunk_id`` (capped at ``expansion_max_per_hit``), 

71 then asks the vector store for the vectors backing those chunks. 

72 Each expanded hit's score is multiplied by 

73 ``expansion_score_multiplier`` so the seed hits stay ranked 

74 higher (the multiplier is < 1.0 by default). 

75 

76 Returns the merged list, sorted by score descending. Existing 

77 memory ids are NOT duplicated — siblings whose vector is already 

78 in ``fused`` are skipped. 

79 """ 

80 if not fused: 

81 return fused 

82 store = self.source_store 

83 if store is None: 

84 return fused 

85 

86 # 1. Collect (chunk_id, doc_id?) for the top-K seeds. We process 

87 # only the top ``source_expansion_max_per_hit * 4`` because the 

88 # multiplier already shrinks tail-hit gains and we want to keep 

89 # the per-recall fan-out bounded. 

90 seed_cap = max(1, self.source_expansion_max_per_hit * 4) 

91 seed_chunk_ids = [getattr(h, "chunk_id", None) for h in fused[:seed_cap] if getattr(h, "chunk_id", None)] 

92 if not seed_chunk_ids: 

93 return fused 

94 

95 # 2. Resolve each seed chunk → its document, then list siblings 

96 # of that document. Run lookups in parallel; tolerate failures. 

97 async def _siblings_for(chunk_id: str) -> list[str]: 

98 try: 

99 chunk = await store.get_chunk(chunk_id, bank_id) # type: ignore[union-attr] 

100 if chunk is None: 

101 return [] 

102 siblings = await store.list_chunks(chunk.document_id, bank_id) # type: ignore[union-attr] 

103 # Drop the seed chunk itself; cap at expansion_max_per_hit. 

104 ids = [c.id for c in siblings if c.id != chunk_id][: self.source_expansion_max_per_hit] 

105 return ids 

106 except Exception as exc: 

107 _logger.debug("source_store sibling lookup failed for %s: %s", chunk_id, exc) 

108 return [] 

109 

110 sibling_lists = await asyncio.gather(*(_siblings_for(cid) for cid in seed_chunk_ids)) 

111 sibling_chunk_ids: set[str] = set() 

112 for ids in sibling_lists: 

113 sibling_chunk_ids.update(ids) 

114 # Drop any chunk_ids already represented in ``fused`` so we don't 

115 # re-rank the same memory twice. 

116 already_present = {getattr(h, "chunk_id", None) for h in fused if getattr(h, "chunk_id", None)} 

117 sibling_chunk_ids -= already_present 

118 if not sibling_chunk_ids: 

119 return fused 

120 

121 # 3. Pull the sibling vectors. The vector store returns score=1.0; 

122 # we apply the configured multiplier so seeds stay ranked higher. 

123 try: 

124 sibling_hits = await self.vector_store.get_by_chunk_ids( # type: ignore[attr-defined] 

125 list(sibling_chunk_ids), 

126 bank_id, 

127 ) 

128 except Exception as exc: 

129 _logger.warning("vector_store.get_by_chunk_ids failed; skipping chunk expansion: %s", exc) 

130 return fused 

131 

132 if not sibling_hits: 

133 return fused 

134 

135 existing_ids = {getattr(h, "id", None) for h in fused} 

136 boost = self.source_expansion_score_multiplier 

137 for sh in sibling_hits: 

138 if sh.id in existing_ids: 

139 continue 

140 sh.score = max(0.0, min(1.0, sh.score * boost)) 

141 fused.append(sh) 

142 

143 # Re-sort: seeds keep their fused score, expansions slot in by 

144 # the boosted score. The downstream rerank stage gets the final say. 

145 fused.sort(key=lambda h: getattr(h, "score", 0.0), reverse=True) 

146 return fused 

147 

148 async def recall(self, request: RecallRequest) -> RecallResult: 

149 """Recall pipeline: embed query → parallel retrieve → fuse → rerank → budget.""" 

150 # 1. Embed query 

151 query_embeddings = await generate_embeddings([request.query], self.llm_provider) 

152 query_vector = query_embeddings[0] 

153 

154 # 1b. Wiki-tier precedence (M8 W5) — search compiled wiki pages first. 

155 # If the top hit scores above wiki_confidence_threshold, return wiki hits 

156 # + raw-memory citations and skip the full parallel-retrieve pipeline. 

157 # Caller can bypass this tier by setting fact_types (which implies they 

158 # want raw memories, not compiled wiki pages). 

159 if self.wiki_store is not None and not request.fact_types: 

160 wiki_result = await self._try_wiki_tier(request, query_vector) 

161 if wiki_result is not None: 

162 return wiki_result 

163 

164 # 2. Build filters 

165 filters = VectorFilters( 

166 bank_id=request.bank_id, 

167 tags=request.tags, 

168 fact_types=request.fact_types, 

169 time_range=request.time_range, 

170 as_of=request.as_of, # M9: time-travel filter 

171 session_id=request.session_id, # M31 Fix 2 

172 ) 

173 

174 # 2a. Query-level temporal constraint extraction. The analyzer 

175 # parses expressions like "what happened in March 2024?" or 

176 # "last week's events" into a (start, end) range that becomes 

177 # an extra time_range filter on top of any caller-supplied one. 

178 # When the request already supplies a time_range, the 

179 # analyzer's hit is the more restrictive of the two — the 

180 # caller's filter remains the floor. 

181 if ( 

182 self.query_analyzer_enabled and request.time_range is None # caller-supplied range wins 

183 ): 

184 try: 

185 from astrocyte.pipeline.query_analyzer import analyze_query 

186 

187 # Resolution order for the relative-phrase anchor: 

188 # 1. ``query_reference_date`` if explicitly set 

189 # (preferred — see types.py for rationale) 

190 # 2. ``as_of`` if set (back-compat: M9 audit-replay 

191 # callers got temporal anchoring "for free" before 

192 # the split — preserve that) 

193 # 3. ``None`` → analyze_query falls back to ``now()`` 

194 anchor = request.query_reference_date or request.as_of 

195 analysis = await analyze_query( 

196 request.query, 

197 reference_date=anchor, 

198 llm_provider=self.llm_provider, 

199 allow_llm_fallback=self.query_analyzer_allow_llm_fallback, 

200 allow_temporal_expansion=self.query_analyzer_enable_temporal_expansion, 

201 ) 

202 if analysis.temporal_constraint and analysis.temporal_constraint.is_bounded(): 

203 c = analysis.temporal_constraint 

204 # VectorFilters.time_range is (start, end) tuple, 

205 # both required for the SQL/in-memory adapters. 

206 # Use bench-bank min/max as defaults for open 

207 # ranges so the adapter contract is satisfied. 

208 far_past = datetime(1900, 1, 1, tzinfo=timezone.utc) 

209 far_future = datetime(2200, 1, 1, tzinfo=timezone.utc) 

210 filters = VectorFilters( 

211 bank_id=filters.bank_id, 

212 tags=filters.tags, 

213 fact_types=filters.fact_types, 

214 time_range=( 

215 c.start_date or far_past, 

216 c.end_date or far_future, 

217 ), 

218 as_of=filters.as_of, 

219 session_id=filters.session_id, # M31 Fix 2 — preserve 

220 ) 

221 _logger.info( 

222 "query_analyzer extracted temporal range %s", 

223 analysis.temporal_constraint, 

224 ) 

225 except Exception as exc: # pragma: no cover — defensive 

226 _logger.warning( 

227 "query_analyzer failed (%s); continuing without temporal filter.", 

228 exc, 

229 ) 

230 

231 # 2b. Extract entities from query for graph search 

232 entity_ids: list[str] | None = None 

233 if self.graph_store: 

234 query_entities = await extract_entities(request.query, self.llm_provider) 

235 if query_entities: 

236 # Look up entity IDs in the graph store 

237 found: list[str] = [] 

238 for ent in query_entities: 

239 matches = await self.graph_store.query_entities(ent.name, request.bank_id, limit=3) 

240 found.extend(m.id for m in matches) 

241 entity_ids = found or None 

242 

243 # Resolve per-bank MIP PipelineSpec once (reused for temporal 

244 # half-life override at retrieval time AND for rerank + version 

245 # check further down). Previously resolved after retrieval — moved 

246 # earlier so temporal_half_life_days can flow into parallel_retrieve. 

247 bank_pipeline = None 

248 if self.mip_router is not None: 

249 bank_pipeline = self.mip_router.resolve_pipeline_for_bank(request.bank_id) 

250 

251 # Per-bank half-life override beats the orchestrator default. 

252 # Long-term knowledge banks (e.g. LongMemEval-style corpora with 

253 # months-old answers) set this to 90+; chat banks leave it at 7. 

254 effective_half_life = ( 

255 bank_pipeline.temporal_half_life_days 

256 if bank_pipeline is not None and bank_pipeline.temporal_half_life_days is not None 

257 else self.temporal_half_life_days 

258 ) 

259 

260 # 2c. HyDE (R1) — generate hypothetical document embedding. 

261 # Runs concurrently with the retrieval step below via a separate task. 

262 # Failures return None; parallel_retrieve treats None as "HyDE disabled". 

263 hyde_vec: list[float] | None = None 

264 if self.enable_hyde: 

265 hyde_vec = await generate_hyde_vector(request.query, self.llm_provider) 

266 

267 # 3. Parallel retrieval 

268 overfetch_limit = request.max_results * self.semantic_overfetch 

269 strategy_timings_ms: dict[str, float] = {} 

270 strategy_candidate_counts: dict[str, int] = {} 

271 strategy_results = await parallel_retrieve( 

272 query_vector=query_vector, 

273 query_text=request.query, 

274 bank_id=request.bank_id, 

275 vector_store=self.vector_store, 

276 graph_store=self.graph_store, 

277 document_store=self.document_store, 

278 entity_ids=entity_ids, 

279 limit=overfetch_limit, 

280 filters=filters, 

281 enable_temporal=self.enable_temporal_retrieval, 

282 temporal_scan_cap=self.temporal_scan_cap, 

283 temporal_half_life_days=effective_half_life, 

284 hyde_vector=hyde_vec, 

285 strategy_timings_ms=strategy_timings_ms, 

286 strategy_candidate_counts=strategy_candidate_counts, 

287 use_bm25_idf=self.bm25_idf_enabled, 

288 ) 

289 query_plan = build_query_plan(request.query) 

290 if not request.fact_types and query_plan.needs_multi_hop_synthesis: 

291 entity_path_hits = await self._retrieve_entity_path_fallback( 

292 request.query, 

293 request.bank_id, 

294 limit=overfetch_limit, 

295 ) 

296 if entity_path_hits: 

297 strategy_results["entity_path"] = entity_path_hits 

298 

299 # 3b. Intent classification — hoisted here so both observation injection 

300 # (step 3b) and RRF weighting (step 4) share the same result without 

301 # computing it twice. UNKNOWN → neutral 1.0 weights everywhere. 

302 query_intent = QueryIntent.UNKNOWN 

303 if self.enable_intent_aware_recall: 

304 query_intent = classify_query_intent(request.query).intent 

305 intent_weights = weights_for_intent(query_intent) 

306 

307 # 3c. Observation strategy — intent-gated injection. 

308 # The ::obs bank holds distilled, multi-evidence facts synthesised from 

309 # raw memories. Injecting it for *every* recall degrades factual 

310 # precision (abstract summaries displace verbatim answers). Instead, 

311 # inject only for EXPLORATORY and RELATIONAL queries — the two intents 

312 # where synthesised behavioural patterns add value over raw memories: 

313 # EXPLORATORY: "What are Alice's hobbies?" / "Describe Bob's personality" 

314 # RELATIONAL: "How does Alice's role relate to her projects?" 

315 # For all other intents, effective_obs_weight falls back to the 

316 # configured observation_weight (default 0.0 = disabled). 

317 from astrocyte.pipeline.reflect import _auto_prompt_variant 

318 

319 _OBS_INJECTION_INTENTS = {QueryIntent.EXPLORATORY, QueryIntent.RELATIONAL} 

320 prompt_variant = _auto_prompt_variant(request.query) 

321 effective_obs_weight = ( 

322 self.observation_injection_weight 

323 if query_intent in _OBS_INJECTION_INTENTS or prompt_variant == "evidence_inference" 

324 else self.observation_weight 

325 ) 

326 if self._observation_consolidator is not None and not request.fact_types and effective_obs_weight > 0.0: 

327 obs_results = await self._retrieve_observations( 

328 query_vector, request.bank_id, overfetch_limit, request.as_of 

329 ) 

330 if obs_results: 

331 strategy_results["observation"] = obs_results 

332 

333 # 4. RRF fusion (local strategies + optional federated / manual external_context) 

334 weighted_inputs: list[tuple[list[Any], float]] = [] 

335 for strategy, results in strategy_results.items(): 

336 if not results: 

337 continue 

338 # Observation strategy uses the effective intent-gated weight; 

339 # other strategies use intent-derived weights. 

340 if strategy == "observation": 

341 weight = effective_obs_weight 

342 else: 

343 # Map strategy name → weight. External/proxy results use 

344 # semantic weight (they're typically semantic fusions upstream). 

345 weight = getattr(intent_weights, strategy, 1.0) 

346 weighted_inputs.append((results, weight)) 

347 if request.external_context: 

348 weighted_inputs.append( 

349 (memory_hits_as_scored(request.external_context), intent_weights.semantic), 

350 ) 

351 

352 # Short-circuit to plain RRF when all weights are 1.0 — keeps the 

353 # baseline path bit-identical for deployments that disable 

354 # intent-aware recall. 

355 if all(w == 1.0 for _, w in weighted_inputs): 

356 fused = rrf_fusion([r for r, _ in weighted_inputs], k=self.rrf_k) 

357 else: 

358 fused = weighted_rrf_fusion(weighted_inputs, k=self.rrf_k) 

359 

360 # 4a. Link expansion (Hindsight parity, C3). After initial RRF 

361 # surfaces direct hits, query the three first-class memory-link 

362 # signals — entity overlap, semantic kNN, causal — and merge 

363 # the resulting candidates back into the fused set. Replaces 

364 # the previous BFS-hop spreading-activation path. 

365 if self.link_expansion_params is not None and self.graph_store is not None and fused: 

366 try: 

367 expansion_hits = await link_expansion( 

368 fused[: self.link_expansion_params.expansion_limit], 

369 bank_id=request.bank_id, 

370 vector_store=self.vector_store, 

371 graph_store=self.graph_store, 

372 params=self.link_expansion_params, 

373 tags=request.tags, 

374 ) 

375 except Exception as exc: # pragma: no cover — defensive 

376 _logger.warning( 

377 "link expansion failed (%s); continuing with direct fused hits only.", 

378 exc, 

379 ) 

380 expansion_hits = [] 

381 if expansion_hits: 

382 # Re-fuse via RRF so direct evidence keeps its precedence 

383 # (top-1 direct beats top-1 expansion by construction). 

384 fused = rrf_fusion([fused, expansion_hits], k=self.rrf_k) 

385 

386 # 4b. Multi-query expansion: decompose the question into sub-questions, 

387 # recall for each independently (parallel), and merge all fused lists 

388 # via a final RRF pass. Only runs when enabled and the LLM judges the 

389 # question as multi-hop (len > 1). The original query's fused result is 

390 # always included so the expansion never discards the baseline recall. 

391 # 

392 # Two guards: 

393 # (a) Empty-bank guard — if initial retrieval returned nothing, 

394 # decomposition cannot help and we avoid a wasted LLM call. 

395 # (b) Confidence gate — peek at the top raw semantic score *before* 

396 # fusion. Cosine similarity is the cleanest signal for "did we 

397 # already find the answer?": if the top hit already exceeds 

398 # multi_query_confidence_threshold we skip decomposition entirely, 

399 # preventing broad sub-queries from displacing the precise answer. 

400 # RRF scores are rank-based and not used here. 

401 _top_semantic_score: float = 0.0 

402 if "semantic" in strategy_results and strategy_results["semantic"]: 

403 _top_semantic_score = strategy_results["semantic"][0].score 

404 

405 if self.enable_multi_query_expansion and fused and _top_semantic_score < self.multi_query_confidence_threshold: 

406 from astrocyte.pipeline.multi_query import decompose_query 

407 

408 sub_queries = await decompose_query(request.query, self.llm_provider) 

409 if len(sub_queries) > 1: 

410 

411 async def _fuse_sub_query(sq: str) -> list[Any]: 

412 sq_vec = (await generate_embeddings([sq], self.llm_provider))[0] 

413 sq_strategy_results = await parallel_retrieve( 

414 query_vector=sq_vec, 

415 query_text=sq, 

416 bank_id=request.bank_id, 

417 vector_store=self.vector_store, 

418 graph_store=self.graph_store, 

419 document_store=self.document_store, 

420 entity_ids=entity_ids, 

421 limit=overfetch_limit, 

422 filters=filters, 

423 enable_temporal=self.enable_temporal_retrieval, 

424 temporal_scan_cap=self.temporal_scan_cap, 

425 temporal_half_life_days=effective_half_life, 

426 use_bm25_idf=self.bm25_idf_enabled, 

427 ) 

428 sq_intent = ( 

429 classify_query_intent(sq).intent if self.enable_intent_aware_recall else QueryIntent.UNKNOWN 

430 ) 

431 sq_weights = weights_for_intent(sq_intent) 

432 sq_weighted = [ 

433 (res, getattr(sq_weights, strat, 1.0)) for strat, res in sq_strategy_results.items() if res 

434 ] 

435 if not sq_weighted: 

436 return [] 

437 if all(w == 1.0 for _, w in sq_weighted): 

438 return rrf_fusion([r for r, _ in sq_weighted], k=self.rrf_k) 

439 return weighted_rrf_fusion(sq_weighted, k=self.rrf_k) 

440 

441 # sub_queries[0] is the original (already in fused); expand the rest 

442 sub_fused_lists = await asyncio.gather(*[_fuse_sub_query(sq) for sq in sub_queries[1:]]) 

443 non_empty = [sf for sf in sub_fused_lists if sf] 

444 if non_empty: 

445 fused = rrf_fusion([fused, *non_empty], k=self.rrf_k) 

446 

447 # 4b. M10 chunk expansion — for each top-K hit with a chunk_id, 

448 # fetch sibling chunks (other vectors from the same SourceDocument) 

449 # and merge them into the candidate pool. Helps multi-hop / split- 

450 # evidence questions where the answer key is in chunk N±1 of a 

451 # chunk that hit. No-op when source_store is unwired or the flag 

452 # is off, or when the vector store doesn't expose 

453 # ``get_by_chunk_ids`` (older adapters degrade gracefully). 

454 if ( 

455 self.source_chunk_expansion 

456 and self.source_store is not None 

457 and hasattr(self.vector_store, "get_by_chunk_ids") 

458 ): 

459 fused = await self._expand_via_sibling_chunks(fused, request.bank_id) 

460 

461 # 5. Reranking — apply per-bank MIP RerankSpec when a rule targets this bank (P3) 

462 # (bank_pipeline already resolved above for temporal half-life override) 

463 mip_rerank = bank_pipeline.rerank if bank_pipeline is not None else None 

464 reranked = basic_rerank(fused, request.query, mip_rerank=mip_rerank) 

465 if self.final_rerank_mode == "llm_pairwise": 

466 reranked = await llm_pairwise_rerank( 

467 reranked, 

468 request.query, 

469 self.llm_provider, 

470 top_n=self.final_rerank_top_n, 

471 keep_n=self.final_rerank_keep_n or len(reranked), 

472 ) 

473 else: 

474 reranked = apply_context_diversity(reranked, request.query) 

475 

476 # 6. Trim to max_results 

477 trimmed = reranked[: request.max_results] 

478 

479 # 7. Convert to MemoryHit 

480 hits = [ 

481 MemoryHit( 

482 text=item.text, 

483 score=item.score, 

484 fact_type=item.fact_type, 

485 metadata=item.metadata, 

486 tags=item.tags, 

487 memory_id=item.id, 

488 bank_id=request.bank_id, 

489 occurred_at=getattr(item, "occurred_at", None), 

490 retained_at=getattr(item, "retained_at", None), # M9 

491 chunk_id=getattr(item, "chunk_id", None), # M10 

492 source=stored_source(item.metadata), 

493 ) 

494 for item in trimmed 

495 ] 

496 

497 # 7b. Version-drift warning (Phase 2, Step 10) 

498 # Compare each hit's persisted ``_mip.pipeline_version`` against the 

499 # version currently configured for this bank's rule. Stale hits indicate 

500 # rule changes since retain — operator should re-index or accept drift. 

501 _warn_on_version_drift(bank_pipeline, hits, request.bank_id) 

502 

503 # 8. Token budget 

504 truncated = False 

505 if request.max_tokens: 

506 hits, truncated = enforce_token_budget(hits, request.max_tokens) 

507 

508 ext_n = len(request.external_context) if request.external_context else 0 

509 strategies_used = list(strategy_results.keys()) 

510 if ext_n: 

511 strategies_used.append("proxy") 

512 total_candidates = sum(len(r) for r in strategy_results.values()) + ext_n 

513 

514 return RecallResult( 

515 hits=hits, 

516 total_available=len(fused), 

517 truncated=truncated, 

518 trace=RecallTrace( 

519 strategies_used=strategies_used, 

520 total_candidates=total_candidates, 

521 fusion_method="rrf", 

522 strategy_timings_ms=strategy_timings_ms or None, 

523 strategy_candidate_counts=strategy_candidate_counts or None, 

524 ), 

525 top_semantic_score=_top_semantic_score, 

526 ) 

527 

528 async def _retrieve_observations( 

529 self, 

530 query_vector: list[float], 

531 bank_id: str, 

532 limit: int, 

533 as_of: Any | None, 

534 ) -> list[Any]: 

535 """Retrieve observation-layer hits for RRF fusion. 

536 

537 Observations are stored in a dedicated bank (``{bank_id}::obs``) that 

538 is completely separate from the raw memory bank. This prevents 

539 double-counting: the main semantic/keyword/temporal strategies only 

540 search the raw bank, while this method exclusively searches the obs 

541 bank. Converts ``VectorHit`` objects to ``ScoredItem`` to match the 

542 format expected by the weighted RRF fusion step. 

543 """ 

544 from astrocyte.pipeline.fusion import ScoredItem 

545 from astrocyte.pipeline.observation import obs_bank_id 

546 

547 obs_bank = obs_bank_id(bank_id) 

548 obs_filters = VectorFilters( 

549 bank_id=obs_bank, 

550 as_of=as_of, 

551 ) 

552 try: 

553 hits = await self.vector_store.search_similar(query_vector, obs_bank, limit=limit, filters=obs_filters) 

554 except Exception as exc: 

555 _logger.warning("Observation retrieval failed for bank %s: %s", bank_id, exc) 

556 return [] 

557 

558 return [ 

559 ScoredItem( 

560 id=h.id, 

561 text=h.text, 

562 score=h.score, 

563 fact_type=h.fact_type, 

564 metadata=h.metadata, 

565 tags=h.tags, 

566 memory_layer="observation", 

567 occurred_at=getattr(h, "occurred_at", None), 

568 retained_at=getattr(h, "retained_at", None), 

569 ) 

570 for h in hits 

571 ] 

572 

573 async def _retrieve_entity_path_fallback( 

574 self, 

575 query: str, 

576 bank_id: str, 

577 *, 

578 limit: int, 

579 ) -> list[ScoredItem]: 

580 """In-memory entity-path recall when no graph backend is configured.""" 

581 

582 query_names = _deterministic_names(query) 

583 if not query_names: 

584 return [] 

585 try: 

586 items = await self.vector_store.list_vectors(bank_id, offset=0, limit=max(limit * 10, 200)) 

587 except Exception: 

588 return [] 

589 

590 hits: list[ScoredItem] = [] 

591 for item in items: 

592 metadata = dict(item.metadata or {}) 

593 text_names = _deterministic_names(item.text) 

594 metadata_names = { 

595 part.strip().lower() 

596 for key in ("locomo_persons", "locomo_speakers", "person") 

597 for part in str(metadata.get(key) or "").replace("|", ",").split(",") 

598 if part.strip() 

599 } 

600 matched = query_names & (text_names | metadata_names) 

601 if not matched: 

602 continue 

603 metadata["_entity_path"] = " -> ".join(sorted(matched)) 

604 metadata["_entity_path_kind"] = "metadata_fallback" 

605 hits.append( 

606 ScoredItem( 

607 id=item.id, 

608 text=item.text, 

609 score=0.65 + min(len(matched), 3) * 0.05, 

610 fact_type=item.fact_type, 

611 metadata=metadata, 

612 tags=item.tags, 

613 memory_layer=item.memory_layer, 

614 occurred_at=item.occurred_at, 

615 retained_at=item.retained_at, 

616 ) 

617 ) 

618 return sorted(hits, key=lambda hit: hit.score, reverse=True)[:limit] 

619 

620 async def _try_wiki_tier( 

621 self, 

622 request: RecallRequest, 

623 query_vector: list[float], 

624 ) -> RecallResult | None: 

625 """Search compiled wiki pages and return hits if the top score meets the threshold. 

626 

627 Returns ``None`` when: 

628 - No wiki pages exist in the bank. 

629 - The top wiki score is below ``wiki_confidence_threshold``. 

630 - The vector store raises (wiki tier is non-fatal; full recall continues). 

631 

632 When a wiki hit is returned the result also includes raw-memory citations 

633 derived from ``_wiki_source_ids`` stored in VectorItem metadata during 

634 compile. The citations are returned as additional MemoryHits with 

635 ``memory_layer="raw"`` so callers can distinguish synthesised wiki 

636 content from the underlying evidence. 

637 """ 

638 try: 

639 wiki_filters = VectorFilters( 

640 bank_id=request.bank_id, 

641 tags=request.tags, 

642 fact_types=["wiki"], 

643 ) 

644 wiki_hits = await self.vector_store.search_similar( 

645 query_vector, 

646 request.bank_id, 

647 limit=request.max_results, 

648 filters=wiki_filters, 

649 ) 

650 except Exception: 

651 _logger.debug("Wiki-tier search failed; falling back to standard recall", exc_info=True) 

652 return None 

653 

654 if not wiki_hits or wiki_hits[0].score < self.wiki_confidence_threshold: 

655 return None 

656 

657 # Convert wiki VectorHits → MemoryHits 

658 hits: list[MemoryHit] = [ 

659 MemoryHit( 

660 text=h.text, 

661 score=h.score, 

662 fact_type=h.fact_type, 

663 metadata=h.metadata, 

664 tags=h.tags, 

665 memory_id=h.id, 

666 bank_id=request.bank_id, 

667 memory_layer="compiled", 

668 source=stored_source(h.metadata), 

669 ) 

670 for h in wiki_hits 

671 ] 

672 

673 # Append raw-memory citations from source_ids stored in metadata 

674 citation_ids: list[str] = [] 

675 for h in wiki_hits: 

676 raw_ids_str = (h.metadata or {}).get("_wiki_source_ids", "") 

677 if raw_ids_str: 

678 citation_ids.extend(raw_ids_str.split(",")) 

679 

680 if citation_ids: 

681 # Fetch raw memories by scanning the vector store. 

682 # We use list_vectors (paginated) to find matches by ID — the 

683 # VectorStore SPI has no get_by_ids, so we scan once and filter. 

684 raw_map: dict[str, VectorItem] = {} 

685 offset = 0 

686 batch = 100 

687 target = set(citation_ids) 

688 while target: 

689 chunk = await self.vector_store.list_vectors(request.bank_id, offset=offset, limit=batch) 

690 if not chunk: 

691 break 

692 for item in chunk: 

693 if item.id in target: 

694 raw_map[item.id] = item 

695 target.discard(item.id) 

696 if len(chunk) < batch: 

697 break 

698 offset += batch 

699 

700 for cid in citation_ids: 

701 item = raw_map.get(cid) 

702 if item is None: 

703 continue 

704 hits.append( 

705 MemoryHit( 

706 text=item.text, 

707 score=0.0, # citations are provenance, not ranked hits 

708 fact_type=item.fact_type, 

709 metadata=item.metadata, 

710 tags=item.tags, 

711 memory_id=item.id, 

712 bank_id=request.bank_id, 

713 memory_layer="raw", 

714 ) 

715 ) 

716 

717 # Apply token budget if requested 

718 truncated = False 

719 if request.max_tokens: 

720 hits, truncated = enforce_token_budget(hits, request.max_tokens) 

721 

722 return RecallResult( 

723 hits=hits[: request.max_results], 

724 total_available=len(wiki_hits), 

725 truncated=truncated, 

726 trace=RecallTrace( 

727 strategies_used=["wiki"], 

728 total_candidates=len(wiki_hits), 

729 fusion_method="wiki_tier", 

730 wiki_tier_used=True, 

731 ), 

732 )