Coverage for astrocyte/pipeline/reflect_stage.py: 73%

170 statements  

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

1from __future__ import annotations 

2 

3import logging 

4from typing import TYPE_CHECKING 

5 

6from astrocyte.pipeline.cross_encoder_rerank import ( 

7 cross_encoder_rerank, 

8) 

9from astrocyte.pipeline.embedding import generate_embeddings 

10from astrocyte.pipeline.fusion import ( 

11 ScoredItem, 

12) 

13from astrocyte.pipeline.query_plan import build_query_plan 

14from astrocyte.pipeline.reflect import synthesize 

15from astrocyte.pipeline.reranking import ( 

16 cross_encoder_like_rerank, 

17) 

18from astrocyte.policy.homeostasis import enforce_token_budget 

19from astrocyte.recall.authority import apply_recall_authority 

20from astrocyte.types import ( 

21 MemoryHit, 

22 RecallRequest, 

23 ReflectRequest, 

24 ReflectResult, 

25 VectorItem, 

26) 

27 

28if TYPE_CHECKING: 

29 pass 

30 

31 

32 

33from astrocyte.pipeline._orchestrator_common import ( 

34 _abstention_floor_for_skepticism, 

35 _resolve_skepticism_for_abstention, 

36 _source_ids_from_metadata, 

37) 

38 

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

40 

41 

42class ReflectStageMixin: 

43 """Reflect pipeline: retrieve context → synthesize an answer. 

44 

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

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

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

48 """ 

49 

50 async def reflect(self, request: ReflectRequest) -> ReflectResult: 

51 """Reflect pipeline: recall → LLM synthesis.""" 

52 query_plan = build_query_plan(request.query) 

53 

54 # 1. Run recall with larger result set. Aggregate/multi-hop queries need 

55 # more candidate memories so synthesis can combine facts instead of 

56 # answering from the first plausible hit. 

57 recall_request = RecallRequest( 

58 query=request.query, 

59 bank_id=request.bank_id, 

60 max_results=query_plan.recall_max_results, 

61 max_tokens=request.max_tokens, 

62 tags=request.tags, 

63 as_of=request.as_of, 

64 query_reference_date=request.query_reference_date, 

65 ) 

66 recall_result = await self.recall(recall_request) 

67 expanded_hits = await self._expand_reflect_sources( 

68 request.bank_id, 

69 self._rank_reflect_context( 

70 request.query, 

71 recall_result.hits, 

72 limit=query_plan.reflect_rank_limit, 

73 ), 

74 limit=query_plan.reflect_expand_limit, 

75 tags=request.tags, 

76 ) 

77 recall_result.hits = expanded_hits 

78 if request.max_tokens: 

79 recall_result.hits, expanded_truncated = enforce_token_budget( 

80 recall_result.hits, 

81 request.max_tokens, 

82 ) 

83 recall_result.truncated = recall_result.truncated or expanded_truncated 

84 

85 path_ctx = self._entity_path_authority_context(recall_result.hits) 

86 auth_ctx: str | None = path_ctx 

87 ra = self.recall_authority 

88 if ra and ra.enabled and ra.apply_to_reflect: 

89 recall_result = apply_recall_authority(recall_result, ra) 

90 auth_ctx = "\n\n".join(part for part in (path_ctx, recall_result.authority_context) if part) 

91 

92 # 2. Resolve per-bank ReflectSpec from MIP (Phase 2, Step 9) 

93 mip_reflect = None 

94 if self.mip_router is not None: 

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

96 if bank_pipeline is not None: 

97 mip_reflect = bank_pipeline.reflect 

98 

99 # 2b. Auto-select a prompt when no MIP prompt override is set. Priority 

100 # (highest → lowest): 

101 # 1. MIP explicit prompt (always wins — never overridden here) 

102 # 2. Evidence-strict gate: when top raw semantic score < threshold, 

103 # retrieval is uncertain. Force citation to prevent the LLM from 

104 # constructing answers from tangential memories — the primary 

105 # adversarial failure mode in open-domain benchmarks. Uses cosine 

106 # similarity from recall(), which is set to 0.0 when no semantic 

107 # results were found. 

108 # 3. query_plan prompt variant (pre-retrieval query-shape routing) 

109 # 4. _auto_prompt_variant fallback (legacy temporal/inference heuristic) 

110 _EVIDENCE_STRICT_THRESHOLD = 0.5 

111 if mip_reflect is None or mip_reflect.prompt is None: 

112 from astrocyte.mip.schema import ReflectSpec 

113 from astrocyte.pipeline.reflect import _auto_prompt_variant 

114 

115 prompt_variant = query_plan.prompt_variant or _auto_prompt_variant(request.query) 

116 

117 # Evidence-strict override: weak retrieval scores mean the top 

118 # semantic match is only tangentially related — upgrade to citation 

119 # mode so the LLM admits uncertainty instead of hallucinating. 

120 # Skip if query_plan already chose evidence_strict (adversarial 

121 # query shape) — no need to double-set. 

122 if recall_result.top_semantic_score < _EVIDENCE_STRICT_THRESHOLD and prompt_variant != "evidence_strict": 

123 prompt_variant = "evidence_strict" 

124 

125 if prompt_variant is not None: 

126 if mip_reflect is None: 

127 mip_reflect = ReflectSpec(prompt=prompt_variant) 

128 else: 

129 # Preserve any other MIP settings (e.g. promote_metadata) 

130 mip_reflect = ReflectSpec( 

131 prompt=prompt_variant, 

132 promote_metadata=mip_reflect.promote_metadata, 

133 ) 

134 

135 # 2b1. Adversarial-defense gate: premise verification. 

136 # Decompose the question into atomic claims, verify each 

137 # against memory. Short-circuit to "insufficient evidence" 

138 # when ANY presupposition fails. Targets false-premise and 

139 # negative-existence adversarial questions. 

140 if self.adversarial_premise_verification_enabled and self.llm_provider is not None: 

141 try: 

142 from astrocyte.pipeline.premise_verification import verify_question 

143 

144 async def _premise_recall(claim: str, max_results: int) -> list[MemoryHit]: 

145 sub_request = RecallRequest( 

146 query=claim, 

147 bank_id=request.bank_id, 

148 max_results=max_results, 

149 max_tokens=request.max_tokens, 

150 tags=request.tags, 

151 as_of=request.as_of, 

152 query_reference_date=request.query_reference_date, 

153 ) 

154 sub_result = await self.recall(sub_request) 

155 return sub_result.hits 

156 

157 verification = await verify_question( 

158 request.query, 

159 recall_fn=_premise_recall, 

160 llm_provider=self.llm_provider, 

161 min_confidence=self.adversarial_premise_min_confidence, 

162 ) 

163 short_circuit = verification.short_circuit_message() 

164 if short_circuit is not None: 

165 _logger.info( 

166 "reflect: premise verification short-circuit — %s", 

167 short_circuit, 

168 ) 

169 return ReflectResult(answer=short_circuit, sources=[]) 

170 except Exception as exc: 

171 _logger.warning( 

172 "premise verification failed (%s); continuing without the guard.", 

173 exc, 

174 ) 

175 

176 # 2c. Adversarial-defense gate: score-floor abstention. 

177 # Distinct from the evidence-strict prompt switch above. 

178 # ``evidence_strict`` keeps invoking the LLM with a tighter 

179 # prompt; the abstention floor short-circuits BEFORE any LLM 

180 # call when retrieval is so weak that the question almost 

181 # certainly has no answer in memory. Targets adversarial 

182 # questions (negative existence, false premise, time-shift) 

183 # where the LLM left to its own devices invents an answer. 

184 # 

185 # The effective floor is now derived from the request's 

186 # ``dispositions.skepticism`` (1=trust→never abstain, 

187 # 5=skeptical→aggressive abstention) rather than the legacy 

188 # global ``abstention_enabled`` bool. This lets one deployment 

189 # serve both adversarial-resistant agents and answer-everything 

190 # assistants from a single config — see 

191 # ``_abstention_floor_for_skepticism`` for the mapping. Legacy 

192 # ``abstention_enabled`` remains as a fallback when no 

193 # per-call dispositions are supplied. 

194 skepticism = _resolve_skepticism_for_abstention( 

195 request.dispositions, 

196 self.adversarial_abstention_enabled, 

197 ) 

198 effective_floor = _abstention_floor_for_skepticism( 

199 skepticism, 

200 self.adversarial_abstention_floor, 

201 ) 

202 if ( 

203 effective_floor > 0.0 

204 and recall_result.top_semantic_score < effective_floor 

205 and (not recall_result.hits or all((h.score or 0.0) < effective_floor for h in recall_result.hits[:5])) 

206 ): 

207 _logger.info( 

208 "reflect: abstention floor triggered (top_semantic=%.3f < " 

209 "%.3f, skepticism=%d); returning 'insufficient evidence' " 

210 "without LLM call.", 

211 recall_result.top_semantic_score, 

212 effective_floor, 

213 skepticism, 

214 ) 

215 return ReflectResult( 

216 answer="insufficient evidence: no memory supports this question.", 

217 sources=[], 

218 ) 

219 

220 # 3. Synthesize. Two paths: 

221 # 

222 # (a) Agentic loop (Hindsight parity) — when configured, the LLM 

223 # selects between ``recall`` and ``done`` over up to N 

224 # iterations. Targets multi-hop / open-domain where a 

225 # single-shot recall misses bridge memories. Each loop 

226 # ``recall`` reuses the full upgraded recall pipeline (RRF 

227 # + spread + cross-encoder rerank + scope tags). 

228 # 

229 # (b) Single-shot synthesis — original path. Faster, simpler. 

230 synthesize_kwargs = dict( 

231 dispositions=request.dispositions, 

232 max_tokens=request.max_tokens or 2048, 

233 authority_context=auth_ctx, 

234 mip_reflect=mip_reflect, 

235 # Forward the relative-phrase anchor so the synthesis 

236 # prompt's ``<reference_date>`` block reflects the 

237 # question's contemporaneous date, not the run wall-clock. 

238 query_reference_date=request.query_reference_date, 

239 ) 

240 if self.agentic_reflect_params is not None: 

241 from astrocyte.pipeline.agentic_reflect import agentic_reflect 

242 

243 async def _loop_recall(query: str, max_results: int) -> list[MemoryHit]: 

244 # Reuses everything we just shipped: spread, cross- 

245 # encoder rerank, tag scoping, RRF — same recall the 

246 # outer step ran, parameterized by the agent's refined 

247 # query and its requested max_results. Forwards the 

248 # outer ``as_of`` (audit replay) AND 

249 # ``query_reference_date`` (relative-phrase anchor) so 

250 # every sub-recall the agent makes sees the same 

251 # temporal context as the seed reflect. 

252 sub_request = RecallRequest( 

253 query=query, 

254 bank_id=request.bank_id, 

255 max_results=max_results, 

256 max_tokens=request.max_tokens, 

257 tags=request.tags, 

258 as_of=request.as_of, 

259 query_reference_date=request.query_reference_date, 

260 ) 

261 sub_result = await self.recall(sub_request) 

262 return sub_result.hits 

263 

264 # ``search_observations`` tool — searches the consolidated 

265 # observation layer when the consolidator is wired up. The 

266 # ::obs bank scope is reused to mirror how observations are 

267 # stored at retain time. 

268 observations_fn = None 

269 if self._observation_consolidator is not None: 

270 

271 async def _loop_observations(query: str, max_results: int) -> list[MemoryHit]: 

272 qvec_batch = await generate_embeddings([query], self.llm_provider) 

273 qvec = qvec_batch[0] if qvec_batch else [] 

274 # ``request.as_of`` (when set) propagates through to 

275 # observation search so time-travel reflect sees the 

276 # same observation snapshot as raw recall. When 

277 # unset, search uses the bank's current state. 

278 obs_results = await self._retrieve_observations( 

279 qvec, 

280 request.bank_id, 

281 max_results, 

282 request.as_of, 

283 ) 

284 # Convert ScoredItem → MemoryHit so the loop can 

285 # cite IDs uniformly across tools. 

286 return [ 

287 MemoryHit( 

288 text=item.text, 

289 score=item.score, 

290 fact_type=item.fact_type, 

291 metadata=item.metadata, 

292 tags=item.tags, 

293 memory_id=item.id, 

294 bank_id=request.bank_id, 

295 memory_layer="observation", 

296 ) 

297 for item in (obs_results or []) 

298 ] 

299 

300 observations_fn = _loop_observations 

301 

302 # ``expand`` tool — fetch source memories cited by a 

303 # compiled fact / wiki / observation. Reuses the existing 

304 # source-expansion path with a single-id seed. 

305 async def _loop_expand(memory_id: str, max_sources: int) -> list[MemoryHit]: 

306 # Find the seed hit in the running pool to expand from. 

307 seed: MemoryHit | None = next( 

308 (h for h in recall_result.hits if h.memory_id == memory_id), 

309 None, 

310 ) 

311 if seed is None: 

312 return [] 

313 expanded = await self._expand_reflect_sources( 

314 request.bank_id, 

315 [seed], 

316 limit=max(1, max_sources) + 1, 

317 tags=request.tags, 

318 ) 

319 # Drop the seed itself so the model sees only NEW evidence. 

320 return [h for h in expanded if h.memory_id != memory_id] 

321 

322 # ``search_mental_models`` tool — top of the hierarchy. 

323 # Forwards to ``MentalModelStore.list(bank_id, scope=...)``, 

324 # converts each MentalModel to a MemoryHit so the agent can 

325 # cite ``model_id`` like any other ``memory_id``. Returns 

326 # all models in scope (no semantic ranking — mental models 

327 # are usually a handful per bank, the LLM picks). 

328 mental_models_fn = None 

329 if self.mental_model_service is not None: 

330 

331 async def _loop_mental_models( 

332 query: str, 

333 scope: str | None, 

334 ) -> list[MemoryHit]: 

335 try: 

336 models = await self.mental_model_service.list( # type: ignore[union-attr] 

337 request.bank_id, 

338 scope=scope, 

339 ) 

340 except Exception as exc: 

341 _logger.warning( 

342 "agentic_reflect.mental_models list failed (%s)", 

343 exc, 

344 ) 

345 return [] 

346 return [ 

347 MemoryHit( 

348 text=f"# {m.title}\n\n{m.content}", 

349 score=1.0, 

350 fact_type="mental_model", 

351 metadata={ 

352 "scope": m.scope, 

353 "revision": m.revision, 

354 "refreshed_at": m.refreshed_at.isoformat(), 

355 }, 

356 memory_id=m.model_id, 

357 bank_id=m.bank_id, 

358 memory_layer="mental_model", 

359 ) 

360 for m in models 

361 ] 

362 

363 mental_models_fn = _loop_mental_models 

364 

365 return await agentic_reflect( 

366 request.query, 

367 initial_hits=recall_result.hits, 

368 recall_fn=_loop_recall, 

369 observations_fn=observations_fn, 

370 expand_fn=_loop_expand, 

371 mental_models_fn=mental_models_fn, 

372 llm_provider=self.llm_provider, 

373 params=self.agentic_reflect_params, 

374 final_synthesize_fn=synthesize, 

375 final_synthesize_kwargs=synthesize_kwargs, 

376 ) 

377 return await synthesize( 

378 query=request.query, 

379 hits=recall_result.hits, 

380 llm_provider=self.llm_provider, 

381 **synthesize_kwargs, 

382 ) 

383 

384 def _rank_reflect_context( 

385 self, 

386 query: str, 

387 hits: list[MemoryHit], 

388 *, 

389 limit: int, 

390 ) -> list[MemoryHit]: 

391 """Apply final precision rerank and hierarchy before synthesis. 

392 

393 Uses the configured cross-encoder reranker (Hindsight parity) when 

394 ``self.cross_encoder`` is set; otherwise falls back to the 

395 deterministic heuristic ``cross_encoder_like_rerank``. The 

396 heuristic remains the default so reflect stays dependency-free 

397 unless the operator opts into the cross-encoder backend. 

398 """ 

399 if not hits: 

400 return hits 

401 

402 items = [ 

403 ScoredItem( 

404 id=h.memory_id or f"hit-{idx}", 

405 text=h.text, 

406 score=h.score, 

407 fact_type=h.fact_type, 

408 metadata=h.metadata, 

409 tags=h.tags, 

410 memory_layer=h.memory_layer, 

411 occurred_at=h.occurred_at, 

412 retained_at=h.retained_at, 

413 ) 

414 for idx, h in enumerate(hits) 

415 ] 

416 

417 if self.cross_encoder is not None: 

418 try: 

419 scored = cross_encoder_rerank( 

420 items, 

421 query, 

422 model=self.cross_encoder, 

423 top_k=self.cross_encoder_top_k, 

424 ) 

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

426 _logger.warning( 

427 "cross-encoder rerank failed (%s); falling back to heuristic.", 

428 exc, 

429 ) 

430 scored = cross_encoder_like_rerank(items, query) 

431 else: 

432 scored = cross_encoder_like_rerank(items, query) 

433 

434 hit_by_id = {h.memory_id or f"hit-{idx}": h for idx, h in enumerate(hits)} 

435 return [hit_by_id[item.id] for item in scored[:limit] if item.id in hit_by_id] 

436 

437 def _entity_path_authority_context(self, hits: list[MemoryHit]) -> str | None: 

438 path_lines: list[str] = [] 

439 direct_lines: list[str] = [] 

440 for idx, hit in enumerate(hits, 1): 

441 path = (hit.metadata or {}).get("_entity_path") if hit.metadata else None 

442 if path: 

443 path_lines.append(f"- Memory {idx}: entity_path={path}") 

444 elif hit.score >= 0.5: 

445 direct_lines.append(f"- Memory {idx}: direct_facts") 

446 if not path_lines: 

447 return None 

448 sections = ["entity_path_evidence:", *path_lines] 

449 if direct_lines: 

450 sections.extend(["direct_facts:", *direct_lines[:8]]) 

451 sections.append("supporting_context: use entity-path evidence before unrelated semantic matches.") 

452 return "\n".join(sections) 

453 

454 async def _expand_reflect_sources( 

455 self, 

456 bank_id: str, 

457 hits: list[MemoryHit], 

458 *, 

459 limit: int, 

460 tags: list[str] | None = None, 

461 ) -> list[MemoryHit]: 

462 """Append raw sources cited by top wiki/observation hits. 

463 

464 This mirrors Hindsight's reflect loop in a bounded, non-agentic form: 

465 start from compiled/observation evidence, then expand to raw facts for 

466 grounding before synthesis. 

467 

468 ``tags`` (optional): when set, only fetched memories carrying every 

469 listed tag are appended. Closes the leak where a tag-scoped reflect 

470 could otherwise pull cross-scope raw memories via a wiki page's 

471 ``_wiki_source_ids`` metadata. 

472 """ 

473 if not hits: 

474 return hits 

475 

476 source_ids: list[str] = [] 

477 seen_sources: set[str] = set() 

478 for hit in hits: 

479 for sid in _source_ids_from_metadata(hit.metadata): 

480 if sid not in seen_sources: 

481 seen_sources.add(sid) 

482 source_ids.append(sid) 

483 if not source_ids: 

484 return hits[:limit] 

485 

486 raw_hits = await self._fetch_memory_hits_by_id(bank_id, source_ids, tags=tags) 

487 existing_ids = {h.memory_id for h in hits if h.memory_id} 

488 expanded = list(hits) 

489 for raw in raw_hits: 

490 if raw.memory_id and raw.memory_id not in existing_ids: 

491 expanded.append(raw) 

492 existing_ids.add(raw.memory_id) 

493 if len(expanded) >= limit: 

494 break 

495 return expanded[:limit] 

496 

497 async def _fetch_memory_hits_by_id( 

498 self, 

499 bank_id: str, 

500 ids: list[str], 

501 *, 

502 tags: list[str] | None = None, 

503 ) -> list[MemoryHit]: 

504 target = set(ids) 

505 found: dict[str, VectorItem] = {} 

506 offset = 0 

507 batch = 100 

508 # Tag scoping: a fetched memory is kept only if it carries every 

509 # tag in ``tags``. ``None``/empty disables the filter (legacy 

510 # behavior). Comparison is case-insensitive to match recall's 

511 # tag-filter convention. 

512 required_tags = {str(t).lower() for t in tags} if tags else None 

513 while target: 

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

515 if not chunk: 

516 break 

517 for item in chunk: 

518 if item.id not in target: 

519 continue 

520 if required_tags is not None: 

521 item_tags = {str(t).lower() for t in (item.tags or [])} 

522 if not required_tags.issubset(item_tags): 

523 # ID matches but scope tags missing — drop and 

524 # mark as resolved so we don't keep scanning. 

525 target.discard(item.id) 

526 continue 

527 found[item.id] = item 

528 target.discard(item.id) 

529 if len(chunk) < batch: 

530 break 

531 offset += batch 

532 

533 return [ 

534 MemoryHit( 

535 text=item.text, 

536 score=0.0, 

537 fact_type=item.fact_type, 

538 metadata=item.metadata, 

539 tags=item.tags, 

540 memory_id=item.id, 

541 bank_id=bank_id, 

542 memory_layer=item.memory_layer or "raw", 

543 occurred_at=item.occurred_at, 

544 retained_at=item.retained_at, 

545 ) 

546 for sid in ids 

547 if (item := found.get(sid)) is not None 

548 ]