Coverage for astrocyte/pipeline/retain_stage.py: 78%

560 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 hashlib 

5import logging 

6import uuid 

7from dataclasses import dataclass 

8from datetime import datetime, timezone 

9from typing import TYPE_CHECKING, Any 

10 

11from astrocyte.config import ExtractionProfileConfig 

12from astrocyte.pipeline.chunking import chunk_text 

13from astrocyte.pipeline.embedding import generate_embeddings 

14from astrocyte.pipeline.entity_extraction import extract_entities 

15from astrocyte.pipeline.extraction import ( 

16 merged_user_and_builtin_profiles, 

17 prepare_retain_input, 

18 resolve_retain_chunking, 

19) 

20from astrocyte.recall.authority import merge_retain_metadata_authority_tier 

21from astrocyte.types import ( 

22 Entity, 

23 EntityLink, 

24 MemoryEntityAssociation, 

25 MemoryLink, 

26 RetainRequest, 

27 RetainResult, 

28 VectorHit, 

29 VectorItem, 

30) 

31 

32if TYPE_CHECKING: 

33 pass 

34 

35 

36from astrocyte.pipeline._orchestrator_common import ( 

37 _build_cooccurrence_pairs, 

38 _entities_from_metadata, 

39) 

40from astrocyte.pipeline.provenance import SOURCE_KEY 

41 

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

43 

44#: Nearest neighbours fetched per chunk for the store-side dedup check. More 

45#: than one so a negation-guarded or observation hit does not hide a genuine 

46#: duplicate ranked just below it. 

47_STORE_DEDUP_NEIGHBOURS = 3 

48#: Store rows that are syntheses, not retained memories (``fact_type`` or 

49#: ``memory_layer``); never treated as the original of a new chunk. 

50_NOT_RETAINED_LAYERS = frozenset({"observation", "model"}) 

51 

52 

53 

54def _chunk_metadata(base: dict | None, index: int, count: int) -> dict | None: 

55 """A chunk's metadata: the retain's, plus its position when the text was 

56 split. ``retained_at`` can't order chunks: they are stored within one 

57 tick of a coarse clock (Windows: ~15 ms).""" 

58 return base if count < 2 or base is None else {**base, "_chunk_index": index} 

59 

60@dataclass(frozen=True) 

61class _FactOverlay: 

62 """What structured extraction learned about one chunk, to apply to its 

63 stored row. ``None`` / empty means "the model said nothing": the row 

64 keeps the request's values.""" 

65 

66 metadata: dict[str, Any] 

67 fact_type: str | None 

68 occurred_at: datetime | None 

69 

70 

71def _apply_fact_overlay( 

72 metadata: dict | None, 

73 overlay: _FactOverlay | None, 

74 *, 

75 fact_type: str, 

76 occurred_at: datetime | None, 

77 profile_fact_type: str | None, 

78) -> tuple[dict | None, str, datetime | None]: 

79 """Merge one chunk's extraction results into its stored fields. 

80 

81 Precedence: a profile that sets ``fact_type`` explicitly is an operator 

82 decision and wins; otherwise the model's classification replaces the 

83 default. An extracted ``occurred_start`` is event time and replaces the 

84 request's timestamp, which is kept as ``_mentioned_at`` so nothing is 

85 lost. The ``_fact_*`` keys are prefixed and never overwrite caller keys. 

86 """ 

87 if overlay is None: 

88 return metadata, fact_type, occurred_at 

89 merged = {**overlay.metadata, **(metadata or {})} if overlay.metadata else metadata 

90 if overlay.fact_type is not None and not profile_fact_type: 

91 fact_type = overlay.fact_type 

92 if overlay.occurred_at is not None: 

93 if occurred_at is not None and overlay.occurred_at != occurred_at: 

94 merged = {**(merged or {}), "_mentioned_at": occurred_at.isoformat()} 

95 occurred_at = overlay.occurred_at 

96 return merged, fact_type, occurred_at 

97 

98 

99class RetainStageMixin: 

100 """Retain pipeline: chunk → extract → embed → persist (+ retain_many). 

101 

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

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

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

105 """ 

106 

107 async def _attach_entity_name_embeddings(self, entities: list[Entity]) -> None: 

108 """Embed each entity's name and attach the vector to ``entity.embedding``. 

109 

110 Powers the Hindsight-inspired entity-resolution cascade — at retain 

111 time we generate one batched embedding call across all entity names, 

112 then attach the resulting vector to each entity. Both 

113 ``store_entities`` (for adapters that persist embeddings) and 

114 ``EntityResolver.resolve()`` (for the cosine-similarity tier of the 

115 cascade) consume the attached vectors. 

116 

117 Skips entities whose ``embedding`` is already set (caller-supplied) 

118 and skips entities with empty/whitespace names. On failure logs a 

119 warning and leaves embeddings unset — the resolver degrades to 

120 trigram-only and the cost is correctness for that one batch, not 

121 a retain failure. 

122 """ 

123 targets = [e for e in entities if e.embedding is None and e.name and e.name.strip()] 

124 if not targets: 

125 return 

126 try: 

127 vectors = await generate_embeddings( 

128 [e.name.strip() for e in targets], 

129 self.llm_provider, 

130 ) 

131 except Exception as exc: 

132 _logger.warning( 

133 "entity name embedding failed (resolver will fall back to trigram-only tier): %s", 

134 exc, 

135 ) 

136 return 

137 for entity, vec in zip(targets, vectors, strict=False): 

138 entity.embedding = vec 

139 

140 async def _structured_fact_extraction_for_text( 

141 self, 

142 prepared, 

143 request: RetainRequest, 

144 *, 

145 chunk_strategy: str = "paragraph", 

146 chunk_max_size: int | None = None, 

147 chunk_overlap: int | None = None, 

148 ) -> tuple[ 

149 list[str] | None, 

150 list[Entity] | None, 

151 list[tuple[int, str]] | None, # (entity_idx_in_full_list, memory_idx) associations 

152 list[tuple[int, int, float]] | None, # (source_memory_idx, target_memory_idx, confidence) caused_by 

153 list[_FactOverlay] | None, # per fact, aligned with fact_texts 

154 ]: 

155 """Run the structured 5-dim fact extraction path. 

156 

157 Returns ``(fact_texts, entities, associations, caused_by, overlays)`` 

158 when the path is enabled and produces facts; all ``None`` otherwise. 

159 ``overlays`` carries each fact's metadata, classification, and event 

160 time for its stored row; before 2026-10-04 these were computed and 

161 then discarded, so extraction paid for metadata nothing stored. Caller falls through to the legacy chunk_text + 

162 extract_entities path when None is returned. 

163 

164 Indices in ``associations`` and ``caused_by`` reference positions 

165 in the returned ``fact_texts``/``entities`` lists. Callers 

166 resolve them to memory IDs after store_vectors. 

167 """ 

168 # SFE supersedes the legacy ``prepared.extract_entities`` gate. 

169 # The legacy setting controlled which entity-extraction PATH 

170 # ran (metadata vs LLM); SFE is a third path that produces 

171 # both entities and structured facts in one call. When SFE is 

172 # enabled, it fires regardless of the profile's entity 

173 # extraction mode — the profile's metadata-entities are 

174 # ignored in favor of the richer SFE output. 

175 if not self.structured_fact_extraction_enabled or self.llm_provider is None: 

176 return None, None, None, None, None 

177 

178 try: 

179 # Pre-chunk using the SAME strategy the legacy retain path 

180 # would use (resolved from the extraction profile), then ask 

181 # the LLM to enrich each chunk with metadata WITHOUT 

182 # paraphrasing. Stored memory text = original chunk 

183 # vocabulary at the granularity the profile specifies 

184 # (e.g. dialogue turns for conversations, paragraphs for 

185 # documents). 

186 # 

187 # The legacy "concise" path (``extract_facts``) was removed 

188 # in M9 — it caused severe recall_hit_rate degradation 

189 # because the LLM-paraphrased ``what`` field lost the 

190 # surface vocabulary that question embeddings share. Verbatim 

191 # is now the only supported mode; ``validate_astrocyte_config`` 

192 # rejects ``extraction_mode: concise`` at config load. 

193 # 

194 # Open-domain regression observed on 2026-05-02 traced to 

195 # hardcoded "paragraph" strategy producing one giant chunk 

196 # per LoCoMo session (no paragraph breaks in dialogue text). 

197 # Profile-driven strategy fixes that. 

198 from astrocyte.pipeline.chunking import chunk_text 

199 from astrocyte.pipeline.fact_extraction import ( 

200 extract_facts_verbatim, 

201 extract_facts_verbatim_parallel, 

202 materialize_facts, 

203 ) 

204 

205 pre_chunk_kwargs: dict[str, int] = {} 

206 if chunk_max_size is not None: 

207 pre_chunk_kwargs["max_chunk_size"] = chunk_max_size 

208 if chunk_overlap is not None: 

209 pre_chunk_kwargs["overlap"] = chunk_overlap 

210 chunks_local = chunk_text( 

211 prepared.text, 

212 strategy=chunk_strategy, 

213 **pre_chunk_kwargs, 

214 ) 

215 # Phase 3: route to the per-chunk parallel path when opt-in. 

216 # Drops cross-chunk causal_relations — pair with 

217 # ``causal_links.enabled=false``. 

218 if self.structured_fact_extraction_parallel_chunks: 

219 facts = await extract_facts_verbatim_parallel( 

220 chunks_local, 

221 self.llm_provider, 

222 event_date=request.occurred_at, 

223 max_concurrency=(self.structured_fact_extraction_parallel_chunks_max_concurrency), 

224 ) 

225 else: 

226 facts = await extract_facts_verbatim( 

227 chunks_local, 

228 self.llm_provider, 

229 event_date=request.occurred_at, 

230 ) 

231 except Exception as exc: 

232 _logger.warning( 

233 "structured fact extraction failed (%s); falling back to legacy chunk + entity-extraction path.", 

234 exc, 

235 ) 

236 return None, None, None, None, None 

237 if not facts: 

238 return None, None, None, None, None 

239 

240 # Materialize without embeddings here — embeddings are batched 

241 # later for cost. We only need the list of fact texts and the 

242 # pre-extracted entities + association index map. 

243 materialized = materialize_facts( 

244 facts, 

245 bank_id=request.bank_id, 

246 occurred_at=request.occurred_at, 

247 ) 

248 fact_texts = [item.text for item in materialized.vector_items] 

249 entities = list(materialized.entities) 

250 

251 # Build (memory_idx, entity_idx) pairs from the materialized 

252 # association tuples (which reference items by ID). We need 

253 # indices because the caller hasn't assigned final memory IDs 

254 # yet — those come after dedup + store_vectors. 

255 item_id_to_idx = {item.id: i for i, item in enumerate(materialized.vector_items)} 

256 ent_id_to_idx = {e.id: i for i, e in enumerate(entities)} 

257 associations: list[tuple[int, str]] = [] 

258 for item_id, ent_id in materialized.memory_entity_associations: 

259 mem_idx = item_id_to_idx.get(item_id) 

260 ent_idx = ent_id_to_idx.get(ent_id) 

261 if mem_idx is None or ent_idx is None: 

262 continue 

263 associations.append((ent_idx, mem_idx)) 

264 

265 # caused_by edges: indices both into fact list (= memory list). 

266 caused_by: list[tuple[int, int, float]] = [] 

267 for link in materialized.memory_links: 

268 src_idx = item_id_to_idx.get(link.source_memory_id) 

269 tgt_idx = item_id_to_idx.get(link.target_memory_id) 

270 if src_idx is None or tgt_idx is None: 

271 continue 

272 caused_by.append((src_idx, tgt_idx, float(link.confidence))) 

273 

274 # One VectorItem per fact, in order (materialize_facts), so the two 

275 # zip exactly. A default fact_type is not a classification. 

276 overlays = [ 

277 _FactOverlay( 

278 metadata={ 

279 k: v 

280 for k, v in (item.metadata or {}).items() 

281 if k.startswith("_fact_") and (k != "_fact_type" or fact.fact_type_classified) 

282 }, 

283 fact_type=fact.fact_type if fact.fact_type_classified else None, 

284 occurred_at=fact.occurred_start, 

285 ) 

286 for fact, item in zip(facts, materialized.vector_items, strict=True) 

287 ] 

288 

289 return fact_texts, entities, associations, caused_by, overlays 

290 

291 async def _persist_semantic_links( 

292 self, 

293 bank_id: str, 

294 memory_ids: list[str], 

295 embeddings: list[list[float]], 

296 ) -> None: 

297 """Compute & persist semantic-kNN edges for a freshly-stored batch. 

298 

299 Hindsight parity (C3a): each new memory gets ``MemoryLink( 

300 link_type="semantic", weight=cosine)`` edges to its top-K 

301 most-similar existing memories with similarity ≥ threshold. 

302 Best-effort — failures log and continue rather than aborting 

303 retain. 

304 """ 

305 if not self.semantic_link_graph_enabled or self.graph_store is None or not memory_ids: 

306 return 

307 try: 

308 from astrocyte.pipeline.semantic_link_graph import compute_semantic_links 

309 

310 links = await compute_semantic_links( 

311 bank_id=bank_id, 

312 new_memory_ids=memory_ids, 

313 new_embeddings=embeddings, 

314 vector_store=self.vector_store, 

315 top_k=self.semantic_link_graph_top_k, 

316 similarity_threshold=self.semantic_link_graph_threshold, 

317 ) 

318 except Exception as exc: 

319 _logger.warning("semantic_link_graph computation failed: %s", exc) 

320 return 

321 if not links: 

322 return 

323 try: 

324 await self.graph_store.store_memory_links(links, bank_id) 

325 except Exception as exc: 

326 _logger.warning("storing semantic memory_links failed: %s", exc) 

327 

328 async def _process_record_entities_with_retry( 

329 self, 

330 record: dict[str, Any], 

331 *, 

332 max_retries: int = 10, 

333 base_delay: float = 0.1, 

334 ) -> None: 

335 """Retry-wrapper around :meth:`_process_record_entities`. 

336 

337 AGE's entity ``MERGE`` path takes per-label advisory locks 

338 (``pg_advisory_xact_lock(label_oid)``) so concurrent retains 

339 racing on the same label name don't produce duplicate label 

340 rows. Three or more retains contending on the same label can 

341 produce a CYCLIC wait: P1 holds advisory, waits on tx; P2 waits 

342 on advisory; P3 holds tx, waits on advisory — Postgres breaks 

343 the cycle by aborting one of them with ``DeadlockDetected``. 

344 

345 This is timing-sensitive: with HNSW vector inserts the retain 

346 latency was high enough that the cyclic pattern was rare in 

347 practice. With DiskANN's faster inserts (2026-05-06 default 

348 switch) the LoCoMo bench surfaced it within 20 retains. 

349 Postgres-style deadlocks are inherently retriable — only the 

350 loser's transaction rolls back, so we just try again with 

351 exponential backoff. 

352 

353 Tuning history: 

354 

355 - 2026-05-06 (initial): ``max_retries=3``, ``base_delay=0.2``. 

356 Absorbed most deadlocks but the LoCoMo bench surfaced 4-way 

357 cyclic waits that the 3 attempts couldn't outlast. 

358 - 2026-05-06 (revised): ``max_retries=10``, ``base_delay=0.1``. 

359 Retain throughput evidence: with ``concurrency=10`` retains 

360 and a per-bank entity overlap rate around 30% (LoCoMo 

361 characters appearing in many sessions), deadlocks fire on 

362 ~5% of retains. 10 retries with 0.1s × 2^n backoff 

363 (capped at 3.6s by the worst case) gives 51.1s max stall — 

364 ample headroom for the cyclic-wait probability tail. The 

365 bench earlier observed 6 deadlocks absorbed before one 

366 exhausted — bumping to 10 attempts more than doubles the 

367 probability mass we cover. 

368 """ 

369 last_exc: BaseException | None = None 

370 for attempt in range(max_retries): 

371 try: 

372 await self._process_record_entities(record) 

373 return 

374 except Exception as exc: # noqa: BLE001 — match psycopg without import dep 

375 # Match by class name + message so we don't need a hard 

376 # dependency on psycopg in this module (the orchestrator 

377 # is provider-agnostic; deadlocks are a Postgres-side 

378 # concept that bubbles up via whatever DB driver the 

379 # graph_store happens to use). 

380 name = type(exc).__name__.lower() 

381 msg = str(exc).lower() 

382 is_deadlock = "deadlock" in name or "deadlock detected" in msg 

383 if not is_deadlock or attempt == max_retries - 1: 

384 raise 

385 last_exc = exc 

386 delay = base_delay * (2**attempt) 

387 _logger.warning( 

388 "_process_record_entities deadlock on attempt %d/%d (%s); retrying in %.2fs", 

389 attempt + 1, 

390 max_retries, 

391 exc, 

392 delay, 

393 ) 

394 await asyncio.sleep(delay) 

395 # Defensive — loop body either returns or re-raises; this is 

396 # only reachable if max_retries=0 (which we guard against above 

397 # via the attempt < max_retries-1 check). 

398 if last_exc is not None: 

399 raise last_exc 

400 

401 async def _process_record_entities(self, record: dict[str, Any]) -> None: 

402 """Run all entity-related I/O for a single retain_many record. 

403 

404 Encapsulates the embed-names / store-entities / link-memories / 

405 co-occurrence-links / entity-resolution cascade so :meth:`retain_many` 

406 can dispatch one task per record via :func:`asyncio.gather`. Each 

407 record's writes target distinct ``(bank_id, id)`` and 

408 ``(bank_id, memory_id, entity_id)`` keys, so concurrent records 

409 don't contend on row-level locks. 

410 

411 Failures in entity resolution are caught and logged — they must 

412 not abort retain because the raw memories are already stored. 

413 """ 

414 if self.graph_store is None: 

415 return 

416 request: RetainRequest = record["request"] 

417 prepared = record["prepared"] 

418 memory_ids: list[str] = record["memory_ids"] 

419 entities: list[Entity] = record["entities"] 

420 if not entities: 

421 return 

422 

423 if self.entity_resolver is not None: 

424 async with self._profiler.time("entity_emb"): 

425 await self._attach_entity_name_embeddings(entities) 

426 # Path B (Hindsight-style): rewrite tentative IDs to canonical 

427 # IDs BEFORE storage so different surface forms that match an 

428 # existing canonical never produce duplicate entity rows. The 

429 # post-store ``resolve()`` alias pass becomes redundant in this 

430 # mode and is skipped below. 

431 if self.entity_resolver.canonical_resolution: 

432 try: 

433 async with self._profiler.time("entity_resolve"): 

434 await self.entity_resolver.resolve_canonical_ids_in_place( 

435 new_entities=entities, 

436 bank_id=request.bank_id, 

437 graph_store=self.graph_store, 

438 event_date=request.occurred_at, 

439 ) 

440 except Exception as exc: 

441 _logger.warning( 

442 "canonical resolution failed during retain (falling back to tentative IDs): %s", 

443 exc, 

444 ) 

445 

446 async with self._profiler.time("entity_store"): 

447 entity_ids = await self.graph_store.store_entities(entities, request.bank_id) 

448 

449 # Per-fact entity associations when SFE supplied them 

450 # (each memory linked only to entities IT mentions); legacy path 

451 # uses the Cartesian product. 

452 sfe_associations = record.get("sfe_associations") 

453 if sfe_associations is not None: 

454 associations = [ 

455 MemoryEntityAssociation( 

456 memory_id=memory_ids[mem_idx], 

457 entity_id=entity_ids[ent_idx], 

458 ) 

459 for ent_idx, mem_idx in sfe_associations 

460 if 0 <= ent_idx < len(entity_ids) and 0 <= mem_idx < len(memory_ids) 

461 ] 

462 else: 

463 associations = [ 

464 MemoryEntityAssociation(memory_id=mid, entity_id=eid) for mid in memory_ids for eid in entity_ids 

465 ] 

466 async with self._profiler.time("entity_link_mem"): 

467 await self.graph_store.link_memories_to_entities(associations, request.bank_id) 

468 # Same cap as the single-record retain path — see comment there. 

469 # Without it the all-pairs Cartesian product dominates retain 

470 # wall on entity-dense workloads (LME profile 2026-05-06). 

471 if self.entity_cooccurrence_enabled: 

472 pairs = _build_cooccurrence_pairs( 

473 entity_ids, 

474 self.entity_cooccurrence_max_entities, 

475 ) 

476 if pairs: 

477 links = [EntityLink(entity_a=a, entity_b=b, link_type="co_occurs") for a, b in pairs] 

478 async with self._profiler.time("entity_co_occur"): 

479 await self.graph_store.store_links(links, request.bank_id) 

480 

481 # MemoryLinks (caused_by). Two paths: 

482 # - SFE supplied them inline → resolve indices, store. 

483 # - Legacy fact_causal_extraction LLM call per record. 

484 sfe_caused_by = record.get("sfe_caused_by") 

485 chunks: list[str] = record.get("chunks") or [] 

486 if sfe_caused_by: 

487 memory_links = [ 

488 MemoryLink( 

489 source_memory_id=memory_ids[src_idx], 

490 target_memory_id=memory_ids[tgt_idx], 

491 link_type="caused_by", 

492 confidence=conf, 

493 weight=1.0, 

494 created_at=datetime.now(timezone.utc), 

495 metadata={"bank_id": request.bank_id, "source": "fact_extraction"}, 

496 ) 

497 for src_idx, tgt_idx, conf in sfe_caused_by 

498 if 0 <= src_idx < len(memory_ids) and 0 <= tgt_idx < len(memory_ids) and src_idx != tgt_idx 

499 ] 

500 if memory_links: 

501 try: 

502 await self.graph_store.store_memory_links( 

503 memory_links, 

504 request.bank_id, 

505 ) 

506 except Exception as exc: 

507 _logger.warning("storing memory_links failed: %s", exc) 

508 elif ( 

509 self.causal_links_enabled 

510 and len(memory_ids) > 1 

511 and len(chunks) == len(memory_ids) 

512 and self.llm_provider is not None 

513 ): 

514 try: 

515 from astrocyte.pipeline.fact_causal_extraction import ( 

516 build_memory_links_from_relations, 

517 extract_fact_causal_relations, 

518 ) 

519 

520 relations = await extract_fact_causal_relations( 

521 chunks, 

522 self.llm_provider, 

523 max_pairs_per_fact=self.causal_max_pairs_per_memory, 

524 min_confidence=self.causal_min_confidence, 

525 ) 

526 memory_links = build_memory_links_from_relations( 

527 relations, 

528 memory_ids, 

529 bank_id=request.bank_id, 

530 ) 

531 except Exception as exc: 

532 _logger.warning("fact-level causal extraction failed: %s", exc) 

533 memory_links = [] 

534 if memory_links: 

535 try: 

536 await self.graph_store.store_memory_links( 

537 memory_links, 

538 request.bank_id, 

539 ) 

540 except Exception as exc: 

541 _logger.warning("storing memory_links failed: %s", exc) 

542 

543 # Legacy two-stage flow: post-store alias resolution. Skipped when 

544 # ``canonical_resolution=True`` because IDs are already canonical. 

545 if self.entity_resolver is not None and not self.entity_resolver.canonical_resolution: 

546 try: 

547 await self.entity_resolver.resolve( 

548 new_entities=entities, 

549 source_text=prepared.text, 

550 bank_id=request.bank_id, 

551 graph_store=self.graph_store, 

552 llm_provider=self.llm_provider, 

553 event_date=request.occurred_at, 

554 ) 

555 except Exception as exc: 

556 _logger.warning("entity resolution failed during retain: %s", exc) 

557 

558 async def _provision_source_provenance( 

559 self, 

560 request: RetainRequest, 

561 prepared_text: str, 

562 chunks: list[str], 

563 ) -> list[str | None]: 

564 """Stamp the (document, chunks) pair into the SourceStore. 

565 

566 Returns the per-chunk ``chunk_id`` list, parallel to ``chunks``. 

567 On failure (or when the SourceStore is not configured / the flag 

568 is off), returns ``[None] * len(chunks)`` so retain continues 

569 with anonymous vectors — provenance must never break ingest. 

570 

571 The document id is deterministic (SHA-256 of the prepared text, 

572 prefixed with ``doc:``) so the same input ingested twice resolves 

573 to the same SourceDocument and the dedup probe is hit. Chunk ids 

574 are ``{document_id}:{i}`` for the same reason. 

575 """ 

576 n = len(chunks) 

577 empty: list[str | None] = [None] * n 

578 if self.source_store is None or not self.source_retain_provenance: 

579 return empty 

580 

581 # Late import: keeps the module import-cost low and avoids a hard 

582 # dependency on the M10 types from the orchestrator's surface area. 

583 from astrocyte.types import SourceChunk, SourceDocument 

584 

585 try: 

586 doc_hash = hashlib.sha256(prepared_text.encode("utf-8")).hexdigest() 

587 doc_id_default = f"doc:{doc_hash[:16]}" 

588 document = SourceDocument( 

589 id=doc_id_default, 

590 bank_id=request.bank_id, 

591 title=None, 

592 source_uri=request.source, 

593 content_hash=doc_hash, 

594 content_type=request.content_type, 

595 metadata=None, 

596 ) 

597 # store_document is idempotent on (bank_id, content_hash); the 

598 # returned id may differ from doc_id_default if a prior live 

599 # row with the same hash already exists. 

600 stored_doc_id = await self.source_store.store_document(document) # type: ignore[union-attr] 

601 

602 source_chunks: list[SourceChunk] = [] 

603 for i, chunk_text_str in enumerate(chunks): 

604 chunk_hash = hashlib.sha256(chunk_text_str.encode("utf-8")).hexdigest() 

605 source_chunks.append( 

606 SourceChunk( 

607 id=f"{stored_doc_id}:{i}", 

608 bank_id=request.bank_id, 

609 document_id=stored_doc_id, 

610 chunk_index=i, 

611 text=chunk_text_str, 

612 content_hash=chunk_hash, 

613 metadata=None, 

614 ) 

615 ) 

616 chunk_ids = await self.source_store.store_chunks(source_chunks) # type: ignore[union-attr] 

617 if len(chunk_ids) != n: 

618 # Should not happen for well-behaved adapters; defend 

619 # against shape mismatch by falling back rather than 

620 # silently misaligning chunk_ids onto VectorItems. 

621 _logger.warning( 

622 "source_store.store_chunks returned %d ids, expected %d — falling back", 

623 len(chunk_ids), 

624 n, 

625 ) 

626 return empty 

627 return list(chunk_ids) 

628 except Exception as exc: 

629 # Defensive: provenance is best-effort. Log and degrade to 

630 # anonymous vectors so a SourceStore outage doesn't break ingest. 

631 _logger.warning("source-aware retain failed; continuing without provenance: %s", exc) 

632 return empty 

633 

634 async def _store_entity_graph( 

635 self, 

636 *, 

637 entities: list[Entity], 

638 bank_id: str, 

639 memory_ids: list[str], 

640 chunks: list[str], 

641 sfe_associations: list[tuple[int, int]] | None, 

642 sfe_caused_by: list[tuple[int, int, float]] | None, 

643 source_text: str, 

644 occurred_at: datetime | None, 

645 ) -> None: 

646 """Retain phase 5 — persist entities, associations, and links. 

647 

648 No-op without a graph store. Covers: name embeddings + canonical 

649 resolution, entity storage, memory↔entity associations (SFE 

650 fact-grained or legacy Cartesian), co-occurrence links, causal 

651 MemoryLinks (SFE-inline or legacy LLM pass), and the legacy 

652 two-stage entity-resolution cascade. 

653 """ 

654 if not (self.graph_store and entities): 

655 return 

656 # 5a. Attach name embeddings before persistence so the 

657 # Hindsight-inspired entity-resolution cascade has the cheap 

658 # cosine-similarity tier available. Only fires when an 

659 # entity_resolver is wired up — without one, the embedding 

660 # would be persisted but never used, wasting API budget. 

661 if self.entity_resolver is not None: 

662 async with self._profiler.time("entity_emb"): 

663 await self._attach_entity_name_embeddings(entities) 

664 # Path B (Hindsight): rewrite tentative IDs to canonicals 

665 # before storage. Skipped when canonical_resolution=False 

666 # to preserve the legacy two-stage (store → resolve aliases) 

667 # flow. 

668 if self.entity_resolver.canonical_resolution: 

669 try: 

670 async with self._profiler.time("entity_resolve"): 

671 await self.entity_resolver.resolve_canonical_ids_in_place( 

672 new_entities=entities, 

673 bank_id=bank_id, 

674 graph_store=self.graph_store, 

675 event_date=occurred_at, 

676 ) 

677 except Exception as exc: 

678 _logger.warning( 

679 "canonical resolution failed during retain (falling back to tentative IDs): %s", 

680 exc, 

681 ) 

682 

683 async with self._profiler.time("entity_store"): 

684 entity_ids = await self.graph_store.store_entities(entities, bank_id) 

685 

686 # Link memories to entities. Two paths: 

687 # - Structured fact extraction provides per-fact-per-entity 

688 # associations (each memory linked only to entities IT mentions), 

689 # matching Hindsight's fact-grained granularity. 

690 # - Legacy path uses the Cartesian product (every memory linked 

691 # to every entity in the batch), which works when extraction 

692 # was over the whole text rather than per fact. 

693 if sfe_associations is not None: 

694 associations = [ 

695 MemoryEntityAssociation( 

696 memory_id=memory_ids[mem_idx], 

697 entity_id=entity_ids[ent_idx], 

698 ) 

699 for ent_idx, mem_idx in sfe_associations 

700 if 0 <= ent_idx < len(entity_ids) and 0 <= mem_idx < len(memory_ids) 

701 ] 

702 else: 

703 associations = [ 

704 MemoryEntityAssociation(memory_id=mid, entity_id=eid) for mid in memory_ids for eid in entity_ids 

705 ] 

706 async with self._profiler.time("entity_link_mem"): 

707 await self.graph_store.link_memories_to_entities(associations, bank_id) 

708 

709 # Create co-occurrence links between entities. The 2026-05-06 

710 # LME retain-profile measured the unbounded all-pairs 

711 # Cartesian product at 34% of retain wall (52s tail at the 

712 # session-100 mark). The cap in 

713 # ``_build_cooccurrence_pairs`` bounds the work to O(K²) per 

714 # retain regardless of corpus size. 

715 if self.entity_cooccurrence_enabled: 

716 pairs = _build_cooccurrence_pairs( 

717 entity_ids, 

718 self.entity_cooccurrence_max_entities, 

719 ) 

720 if pairs: 

721 links = [EntityLink(entity_a=a, entity_b=b, link_type="co_occurs") for a, b in pairs] 

722 async with self._profiler.time("entity_co_occur"): 

723 await self.graph_store.store_links(links, bank_id) 

724 

725 # Causal MemoryLinks. Two paths: 

726 # - Structured fact extraction supplies them inline (no 

727 # extra LLM call); resolve indices to memory IDs and store. 

728 # - Legacy path runs a separate fact_causal_extraction LLM call. 

729 if sfe_caused_by is not None and sfe_caused_by: 

730 memory_links = [ 

731 MemoryLink( 

732 source_memory_id=memory_ids[src_idx], 

733 target_memory_id=memory_ids[tgt_idx], 

734 link_type="caused_by", 

735 confidence=conf, 

736 weight=1.0, 

737 created_at=datetime.now(timezone.utc), 

738 metadata={"bank_id": bank_id, "source": "fact_extraction"}, 

739 ) 

740 for src_idx, tgt_idx, conf in sfe_caused_by 

741 if 0 <= src_idx < len(memory_ids) and 0 <= tgt_idx < len(memory_ids) and src_idx != tgt_idx 

742 ] 

743 if memory_links: 

744 try: 

745 await self.graph_store.store_memory_links( 

746 memory_links, 

747 bank_id, 

748 ) 

749 except Exception as exc: 

750 _logger.warning("storing memory_links failed: %s", exc) 

751 elif ( 

752 self.causal_links_enabled 

753 and len(memory_ids) > 1 

754 and len(chunks) == len(memory_ids) 

755 and self.llm_provider is not None 

756 ): 

757 # Legacy fact-causal-extraction LLM pass (separate call). 

758 try: 

759 from astrocyte.pipeline.fact_causal_extraction import ( 

760 build_memory_links_from_relations, 

761 extract_fact_causal_relations, 

762 ) 

763 

764 relations = await extract_fact_causal_relations( 

765 chunks, 

766 self.llm_provider, 

767 max_pairs_per_fact=self.causal_max_pairs_per_memory, 

768 min_confidence=self.causal_min_confidence, 

769 ) 

770 memory_links = build_memory_links_from_relations( 

771 relations, 

772 memory_ids, 

773 bank_id=bank_id, 

774 ) 

775 except Exception as exc: 

776 _logger.warning("fact-level causal extraction failed: %s", exc) 

777 memory_links = [] 

778 if memory_links: 

779 try: 

780 await self.graph_store.store_memory_links( 

781 memory_links, 

782 bank_id, 

783 ) 

784 except Exception as exc: 

785 _logger.warning("storing memory_links failed: %s", exc) 

786 

787 # 5b. Entity resolution (M11) — Hindsight-inspired tiered cascade. 

788 # Skipped when canonical_resolution=True (Path B) because IDs 

789 # are already canonical from the pre-store pass above. Otherwise 

790 # the legacy two-stage path runs the cascade and writes 

791 # alias_of links for matched candidates. 

792 if self.entity_resolver is not None and not self.entity_resolver.canonical_resolution: 

793 try: 

794 await self.entity_resolver.resolve( 

795 new_entities=entities, 

796 source_text=source_text, 

797 bank_id=bank_id, 

798 graph_store=self.graph_store, 

799 llm_provider=self.llm_provider, 

800 event_date=occurred_at, 

801 ) 

802 except Exception as exc: 

803 # Resolution failures must never abort a retain — degrade gracefully. 

804 _logger.warning("entity resolution failed during retain: %s", exc) 

805 

806 def _spawn_observation_consolidation( 

807 self, 

808 *, 

809 chunks: list[str], 

810 embeddings: list[list[float]], 

811 memory_ids: list[str], 

812 bank_id: str, 

813 tags: list[str] | None, 

814 ) -> None: 

815 """Fire-and-forget observation consolidation (retain phase 7). 

816 

817 Runs after the retain response is returned so it never adds latency to 

818 the caller; failures must never surface — the raw memories are already 

819 stored and consolidation is best-effort. The representative vector is 

820 the first chunk's embedding, sufficient for the observation similarity 

821 search. 

822 """ 

823 if self._observation_consolidator is None or not memory_ids or not chunks: 

824 return 

825 

826 representative_vec = embeddings[0] 

827 consolidator = self._observation_consolidator 

828 first_chunk = chunks[0] 

829 all_ids = list(memory_ids) 

830 vs = self.vector_store 

831 llm = self.llm_provider 

832 

833 async def _run_consolidation() -> None: 

834 try: 

835 await consolidator.consolidate( 

836 new_memory_text=first_chunk, 

837 new_memory_ids=all_ids, 

838 bank_id=bank_id, 

839 vector_store=vs, 

840 llm_provider=llm, 

841 query_vector=representative_vec, 

842 scope="|".join(sorted(tags)) if tags else None, 

843 ) 

844 except Exception as exc: 

845 _logger.warning("Observation consolidation task failed for bank %s: %s", bank_id, exc) 

846 

847 self._submit_consolidation(_run_consolidation, bank_id) 

848 

849 def _submit_consolidation(self, factory, bank_id: str) -> None: 

850 """Run now, defer, or — only past both bounds — shed. 

851 

852 Three tiers, each bounded, because the original defect was an 

853 *unbounded* backlog starving foreground retain() through the provider's 

854 own concurrency limit: 

855 

856 1. **Run** while fewer than ``max_pending_consolidations`` are in flight. 

857 2. **Defer** into a FIFO of at most ``max_deferred_consolidations`` 

858 zero-arg factories. Deferred entries hold no provider slot, so they 

859 cannot starve foreground work; they start as in-flight tasks finish 

860 (``_on_consolidation_done``). This replaces silently dropping the 

861 overflow — the n=250 self-eval measured ~1,000+ sheds per 50 items, 

862 roughly 30% of the observation layer never built. 

863 3. **Shed** only when the deferred queue is also full. 

864 

865 What deferral does NOT fix: a *sustained* arrival rate above the 

866 consolidation service rate. A finite queue absorbs a burst, but under 

867 continuous saturated ingest it fills and tier 3 resumes at the old 

868 rate. That case needs faster decisions or more provider concurrency; 

869 rising ``consolidations_shed`` alongside a full queue is the signal. 

870 """ 

871 cap = getattr(self, "max_pending_consolidations", 0) 

872 if not cap or len(self._background_tasks) < cap: 

873 self._start_consolidation(factory) 

874 return 

875 

876 deferred = getattr(self, "_deferred_consolidations", None) 

877 dcap = getattr(self, "max_deferred_consolidations", 0) 

878 if deferred is not None and dcap and len(deferred) < dcap and not getattr(self, "_consolidation_closed", False): 

879 deferred.append(factory) 

880 self.consolidations_deferred = getattr(self, "consolidations_deferred", 0) + 1 

881 return 

882 

883 shed = getattr(self, "consolidations_shed", 0) + 1 

884 self.consolidations_shed = shed 

885 # WARNING on a geometric backoff (1st, 10th, 100th, …). DEBUG was the 

886 # wrong level: shedding means part of the observation layer is never 

887 # built, so recall is quietly thinner than the operator thinks. It was 

888 # invisible through an entire benchmark run while DEBUG was off. 

889 digits = str(shed) 

890 if digits[0] == "1" and set(digits[1:]) <= {"0"}: 

891 _logger.warning( 

892 "Observation consolidation shed (%d so far): %d in flight (cap %d), " 

893 "%d deferred (cap %d). Derived memory is incomplete for bank %s — " 

894 "ingest is outpacing consolidation; a full deferred queue means this " 

895 "is sustained, not a burst.", 

896 shed, 

897 len(self._background_tasks), 

898 cap, 

899 len(deferred) if deferred is not None else 0, 

900 dcap, 

901 bank_id, 

902 ) 

903 

904 def _start_consolidation(self, factory) -> None: 

905 task = asyncio.create_task(factory()) 

906 self._background_tasks.add(task) 

907 task.add_done_callback(self._on_consolidation_done) 

908 

909 def _on_consolidation_done(self, task) -> None: 

910 self._background_tasks.discard(task) 

911 self._drain_deferred() 

912 

913 def _drain_deferred(self) -> None: 

914 """Promote deferred work into freed in-flight slots, oldest first.""" 

915 # Defense in depth only. The load-bearing protection is the ORDER in 

916 # ``PipelineOrchestrator.shutdown()`` — it closes and clears this queue 

917 # before cancelling tasks, so by the time a cancelled task's callback 

918 # lands here the queue is already empty (a negative control confirms 

919 # removing that ordering spawns tasks mid-shutdown; removing this check 

920 # alone does not). Kept so a future reordering fails safe. 

921 if getattr(self, "_consolidation_closed", False): 

922 return 

923 deferred = getattr(self, "_deferred_consolidations", None) 

924 if not deferred: 

925 return 

926 cap = getattr(self, "max_pending_consolidations", 0) 

927 while deferred and (not cap or len(self._background_tasks) < cap): 

928 self._start_consolidation(deferred.popleft()) 

929 

930 def _config_dedup_action(self, bank_id: str) -> str: 

931 """``signal_quality.dedup.action`` for ``bank_id`` — the bank's own 

932 setting if it has one, else the top-level one. Used when no matched MIP 

933 rule sets ``dedup.action``.""" 

934 bank = getattr(self, "dedup_by_bank", {}).get(bank_id) 

935 return bank.pipeline_action if bank is not None else getattr(self, "dedup_action", "skip_chunk") 

936 

937 async def _find_duplicate_chunks( 

938 self, 

939 bank_id: str, 

940 chunks: list[str], 

941 embeddings: list[list[float]], 

942 threshold_override: float | None, 

943 ) -> list[bool]: 

944 """Which chunks are near-duplicates of memories the bank already holds 

945 (see :meth:`_find_duplicate_ids`).""" 

946 found = await self._find_duplicate_ids(bank_id, chunks, embeddings, threshold_override) 

947 return [memory_id is not None for memory_id in found] 

948 

949 async def _find_duplicate_ids( 

950 self, 

951 bank_id: str, 

952 chunks: list[str], 

953 embeddings: list[list[float]], 

954 threshold_override: float | None, 

955 ) -> list[str | None]: 

956 """For each chunk, the id of a memory the bank already holds that it 

957 near-duplicates, or ``None``. 

958 

959 The in-process ``DedupDetector`` cache only knows what this process 

960 retained, so a restarted daemon, a CLI run or a gateway restart would 

961 store a duplicate of a memory written earlier. Chunks the cache does 

962 not match are checked against their nearest neighbours in the vector 

963 store, under the same threshold and negation guard. Observations and 

964 mental models are skipped: a raw memory restating a synthesis is new 

965 evidence, and the cache never held them either. A failed lookup counts 

966 as "not a duplicate" — a redundant row is cheaper than a lost fact. 

967 

968 Costs one ``search_similar`` per chunk the cache missed; 

969 ``dedup_consult_store = False`` turns it off. ``dedup_enabled = False`` 

970 skips the whole check: nothing is a duplicate. A bank listed in 

971 ``dedup_by_bank`` uses its own enabled / threshold / consult_store; a 

972 MIP ``threshold_override`` still beats the bank's threshold. 

973 """ 

974 bank = getattr(self, "dedup_by_bank", {}).get(bank_id) 

975 if bank is not None: 

976 enabled, consult_store = bank.enabled, bank.consult_store 

977 if threshold_override is None: 

978 threshold_override = bank.similarity_threshold 

979 else: 

980 enabled = getattr(self, "dedup_enabled", True) 

981 consult_store = getattr(self, "dedup_consult_store", True) 

982 if not enabled: 

983 return [None] * len(chunks) 

984 dups = [ 

985 self._dedup.find_duplicate(bank_id, emb, threshold_override=threshold_override, text=chunk) 

986 for chunk, emb in zip(chunks, embeddings) 

987 ] 

988 misses = [i for i, dup in enumerate(dups) if dup is None] 

989 if not misses or not consult_store or self.vector_store is None: 

990 return dups 

991 

992 async def nearest(i: int) -> list[VectorHit]: 

993 try: 

994 return await self.vector_store.search_similar(embeddings[i], bank_id, limit=_STORE_DEDUP_NEIGHBOURS) 

995 except Exception as exc: 

996 _logger.warning("retain dedup: store lookup failed in bank %r (%s); keeping the chunk", bank_id, exc) 

997 return [] 

998 

999 # Sequential on purpose: SqliteStore scans the bank per query, and 

1000 # concurrent scans thrash — 10 chunks against 50k rows took 12 s 

1001 # gathered vs 1.5 s one after another. 

1002 async with self._profiler.time("dedup_store"): 

1003 neighbours = [await nearest(i) for i in misses] 

1004 for i, hits in zip(misses, neighbours): 

1005 for hit in hits: 

1006 if hit.fact_type in _NOT_RETAINED_LAYERS or hit.memory_layer in _NOT_RETAINED_LAYERS: 

1007 continue 

1008 if self._dedup.matches([(hit.score, hit.text)], threshold_override=threshold_override, text=chunks[i])[0]: 

1009 dups[i] = hit.id 

1010 break 

1011 return dups 

1012 

1013 async def retain(self, request: RetainRequest) -> RetainResult: 

1014 """Retain pipeline: normalize → chunk → extract entities → embed → store.""" 

1015 # 0–1. Raw → normalizer → profile metadata/tags (M3 extraction chain) 

1016 profile: ExtractionProfileConfig | None = None 

1017 name = getattr(request, "extraction_profile", None) 

1018 profile_table = merged_user_and_builtin_profiles(self.extraction_profiles) 

1019 if name: 

1020 profile = profile_table.get(name) 

1021 

1022 # MIP pipeline overrides (from RoutingDecision) — optional, no behavior 

1023 # change when absent. ``chunker`` and ``dedup`` are consumed here; 

1024 # ``rerank`` and ``reflect`` apply to recall/reflect (Phase 2). 

1025 mip_pipeline = request.mip_pipeline 

1026 mip_chunker = mip_pipeline.chunker if mip_pipeline else None 

1027 mip_dedup = mip_pipeline.dedup if mip_pipeline else None 

1028 dedup_threshold_override = mip_dedup.threshold if mip_dedup else None 

1029 dedup_action = (mip_dedup.action if mip_dedup else None) or self._config_dedup_action(request.bank_id) 

1030 

1031 prepared = prepare_retain_input( 

1032 request, 

1033 profile, 

1034 graph_store_configured=self.graph_store is not None, 

1035 ) 

1036 chunking = resolve_retain_chunking( 

1037 prepared.effective_content_type, 

1038 profile=profile, 

1039 default_strategy=self.chunk_strategy, 

1040 default_max_chunk_size=self.max_chunk_size, 

1041 mip_chunker=mip_chunker, 

1042 ) 

1043 chunk_kwargs: dict[str, int] = {"max_chunk_size": chunking.max_size} 

1044 if chunking.overlap is not None: 

1045 chunk_kwargs["overlap"] = chunking.overlap 

1046 

1047 # Structured 5-dim fact extraction (opt-in). Replaces chunking + 

1048 # entity extraction with a single LLM call producing facts. 

1049 # Falls back to legacy chunk_text + extract_entities when 

1050 # disabled or when extraction returns no facts. 

1051 # SFE uses its OWN chunking strategy (not the profile's), because 

1052 # SFE has different granularity needs: large chunks give the LLM 

1053 # full context for metadata extraction. Default "paragraph" wins 

1054 # +2.5pts on LoCoMo over the profile-driven "dialogue" choice. 

1055 async with self._profiler.time("sfe"): 

1056 ( 

1057 sfe_fact_texts, 

1058 sfe_entities, 

1059 sfe_associations, 

1060 sfe_caused_by, 

1061 sfe_overlays, 

1062 ) = await self._structured_fact_extraction_for_text( 

1063 prepared, 

1064 request, 

1065 chunk_strategy=self.structured_fact_extraction_chunk_strategy, 

1066 chunk_max_size=( 

1067 self.structured_fact_extraction_chunk_max_size 

1068 if self.structured_fact_extraction_chunk_max_size is not None 

1069 else chunking.max_size 

1070 ), 

1071 chunk_overlap=chunking.overlap, 

1072 ) 

1073 if sfe_fact_texts is not None: 

1074 chunks = list(sfe_fact_texts) 

1075 else: 

1076 chunks = chunk_text(prepared.text, strategy=chunking.strategy, **chunk_kwargs) 

1077 if not chunks: 

1078 return RetainResult(stored=False, error="No content after chunking") 

1079 

1080 # 2. Generate embeddings for all chunks 

1081 async with self._profiler.time("embed"): 

1082 embeddings = await generate_embeddings(chunks, self.llm_provider) 

1083 

1084 # 2b. Per-chunk dedup — behavior depends on dedup_action (MIP rule, 

1085 # else signal_quality.dedup.action for the bank): 

1086 # "skip_chunk" (default): drop duplicate chunks, keep the rest 

1087 # "skip": if any chunk is a duplicate, reject the entire retain 

1088 # "warn": keep all chunks regardless of duplicates 

1089 # "update": not yet implemented; falls back to "skip_chunk" 

1090 keep_indices: list[int] = [] 

1091 any_duplicate = False 

1092 duplicates = await self._find_duplicate_chunks(request.bank_id, chunks, embeddings, dedup_threshold_override) 

1093 for i, is_dup in enumerate(duplicates): 

1094 if is_dup: 

1095 any_duplicate = True 

1096 if dedup_action == "warn" or not is_dup: 

1097 keep_indices.append(i) 

1098 

1099 if dedup_action == "skip" and any_duplicate: 

1100 return RetainResult(stored=False, deduplicated=True, error="Duplicate chunk(s) found; rule action=skip") 

1101 

1102 if not keep_indices: 

1103 return RetainResult(stored=False, deduplicated=True, error="All chunks are near-duplicates") 

1104 

1105 chunks = [chunks[i] for i in keep_indices] 

1106 embeddings = [embeddings[i] for i in keep_indices] 

1107 if sfe_overlays is not None: 

1108 sfe_overlays = [sfe_overlays[i] for i in keep_indices] 

1109 

1110 # Remap structured-fact-extraction indices through dedup. The 

1111 # association and caused_by lists referenced positions in the 

1112 # ORIGINAL chunks; after dedup, indices need remapping to the 

1113 # surviving positions (or the records dropped entirely). 

1114 if sfe_associations is not None or sfe_caused_by is not None: 

1115 old_to_new = {old: new for new, old in enumerate(keep_indices)} 

1116 if sfe_associations is not None: 

1117 sfe_associations = [ 

1118 (ent_idx, old_to_new[mem_idx]) for ent_idx, mem_idx in sfe_associations if mem_idx in old_to_new 

1119 ] 

1120 if sfe_caused_by is not None: 

1121 sfe_caused_by = [ 

1122 (old_to_new[src], old_to_new[tgt], conf) 

1123 for src, tgt, conf in sfe_caused_by 

1124 if src in old_to_new and tgt in old_to_new 

1125 ] 

1126 

1127 # 3. Extract entities (profile can disable; metadata profiles avoid LLM calls). 

1128 # Structured fact extraction provides entities pre-extracted — 

1129 # skip the second LLM call when it ran successfully. 

1130 if sfe_entities is not None: 

1131 entities = list(sfe_entities) 

1132 elif profile is not None and profile.entity_extraction == "metadata": 

1133 entities = _entities_from_metadata(prepared.metadata) 

1134 elif prepared.extract_entities: 

1135 entities = await extract_entities(prepared.text, self.llm_provider) 

1136 else: 

1137 entities = [] 

1138 

1139 profile_tier = profile.authority_tier if profile else None 

1140 chunk_metadata = merge_retain_metadata_authority_tier( 

1141 prepared.metadata, 

1142 bank_id=request.bank_id, 

1143 profile_authority_tier=profile_tier, 

1144 recall_authority=self.recall_authority, 

1145 ) 

1146 

1147 # Persist MIP provenance so recall can warn on rule-version drift (P2). 

1148 if request.mip_rule_name or (mip_pipeline and mip_pipeline.version is not None): 

1149 chunk_metadata = dict(chunk_metadata or {}) 

1150 if request.mip_rule_name: 

1151 chunk_metadata["_mip.rule"] = request.mip_rule_name 

1152 if mip_pipeline and mip_pipeline.version is not None: 

1153 chunk_metadata["_mip.pipeline_version"] = int(mip_pipeline.version) 

1154 

1155 # Stamp ``_created_at`` so (1) temporal retrieval can rank by 

1156 # recency, (2) MIP forget.min_age_days can enforce age guards, and 

1157 # (3) lifecycle TTL has a consistent retain timestamp. Callers that 

1158 # already set ``_created_at`` (imports / replay) win via setdefault. 

1159 # Mirrors InMemoryEngineProvider.retain so pipeline- and engine- 

1160 # backed deployments share the same observability contract. 

1161 chunk_metadata = dict(chunk_metadata or {}) 

1162 chunk_metadata.setdefault( 

1163 "_created_at", 

1164 datetime.now(timezone.utc).isoformat(), 

1165 ) 

1166 # Ties this retain's chunks together. ``_created_at`` cannot: on a 

1167 # coarse clock (Windows ticks every ~15 ms) consecutive retains share it. 

1168 chunk_metadata.setdefault("_retain_id", uuid.uuid4().hex) 

1169 # Where the content came from (a URL, a file, an agent): recall hits 

1170 # carry it as MemoryHit.source. It reached only the optional source 

1171 # store before, so on the default path every hit's source was None. 

1172 if request.source: 

1173 chunk_metadata.setdefault(SOURCE_KEY, request.source) 

1174 

1175 # 3b. M10: persist source-document + chunk provenance, get back per-chunk 

1176 # ``chunk_id``s that we'll stamp onto each VectorItem so recall can 

1177 # later resolve "which document/chunk did this memory come from". 

1178 # Returns ``[None] * len(chunks)`` when source_store is not configured 

1179 # or the flag is off — fully backward-compatible. 

1180 chunk_ids = await self._provision_source_provenance( 

1181 request, 

1182 prepared.text, 

1183 chunks, 

1184 ) 

1185 

1186 # 4. Store vectors 

1187 memory_ids: list[str] = [] 

1188 items: list[VectorItem] = [] 

1189 for index, (chunk, embedding, chunk_id) in enumerate(zip(chunks, embeddings, chunk_ids)): 

1190 mem_id = uuid.uuid4().hex[:16] 

1191 memory_ids.append(mem_id) 

1192 item_meta, item_fact_type, item_occurred = _apply_fact_overlay( 

1193 _chunk_metadata(chunk_metadata, index, len(chunks)), 

1194 sfe_overlays[index] if sfe_overlays is not None else None, 

1195 fact_type=prepared.fact_type, 

1196 occurred_at=request.occurred_at, 

1197 profile_fact_type=profile.fact_type if profile is not None else None, 

1198 ) 

1199 items.append( 

1200 VectorItem( 

1201 id=mem_id, 

1202 bank_id=request.bank_id, 

1203 vector=embedding, 

1204 text=chunk, 

1205 metadata=item_meta, 

1206 tags=prepared.tags, 

1207 fact_type=item_fact_type, 

1208 occurred_at=item_occurred, 

1209 retained_at=datetime.now(timezone.utc), # M9: wall-clock store time 

1210 chunk_id=chunk_id, # M10: source-chunk backreference 

1211 ) 

1212 ) 

1213 

1214 async with self._profiler.time("store_vec"): 

1215 await self.vector_store.store_vectors(items) 

1216 

1217 # Hindsight-parity semantic-kNN graph (C3a). Best-effort: links 

1218 # the new memories to their nearest existing neighbors so the 

1219 # link-expansion retrieval CTE has a precomputed semantic 

1220 # signal at recall time. Skipped when disabled. 

1221 await self._persist_semantic_links( 

1222 request.bank_id, 

1223 [item.id for item in items], 

1224 [item.vector for item in items], 

1225 ) 

1226 

1227 # 4b. Mirror chunks into the document store so keyword (BM25) search 

1228 # has content to retrieve from. Previously recall.parallel_retrieve 

1229 # ran a keyword strategy when ``document_store`` was configured — 

1230 # but nothing was ever stored there. Fixed here: every chunk that 

1231 # lands in the vector store also lands in the document store, with 

1232 # the same memory_id so RRF fusion can dedupe across strategies. 

1233 # Failures don't abort the retain — degrading to semantic-only 

1234 # retrieval is preferable to losing the whole memory. 

1235 if self.document_store is not None: 

1236 from astrocyte.types import Document 

1237 

1238 for item in items: 

1239 try: 

1240 await self.document_store.store_document( 

1241 Document( 

1242 id=item.id, 

1243 text=item.text, 

1244 metadata=item.metadata, 

1245 tags=item.tags, 

1246 ), 

1247 request.bank_id, 

1248 ) 

1249 except Exception as exc: 

1250 _logger.warning( 

1251 "document_store.store_document failed for chunk %s: %s " 

1252 "(keyword retrieval will miss this chunk)", 

1253 item.id, 

1254 exc, 

1255 ) 

1256 

1257 # 5. Store entities and links (if graph store configured) — see 

1258 # _store_entity_graph for the phase body. 

1259 await self._store_entity_graph( 

1260 entities=entities, 

1261 bank_id=request.bank_id, 

1262 memory_ids=memory_ids, 

1263 chunks=chunks, 

1264 sfe_associations=sfe_associations, 

1265 sfe_caused_by=sfe_caused_by, 

1266 source_text=prepared.text, 

1267 occurred_at=request.occurred_at, 

1268 ) 

1269 

1270 # 6. Update dedup cache with stored embeddings 

1271 for mem_id, emb, chunk in zip(memory_ids, embeddings, chunks): 

1272 self._dedup.add(request.bank_id, mem_id, emb, text=chunk) 

1273 

1274 # 7. Observation consolidation — see _spawn_observation_consolidation. 

1275 self._spawn_observation_consolidation( 

1276 chunks=chunks, 

1277 embeddings=embeddings, 

1278 memory_ids=memory_ids, 

1279 bank_id=request.bank_id, 

1280 tags=request.tags, 

1281 ) 

1282 

1283 return RetainResult( 

1284 stored=True, 

1285 memory_id=memory_ids[0] if memory_ids else None, 

1286 ) 

1287 

1288 async def retain_many(self, requests: list[RetainRequest]) -> list[RetainResult]: 

1289 """Retain multiple requests while batching embedding generation and vector writes.""" 

1290 

1291 if not requests: 

1292 return [] 

1293 

1294 results: list[RetainResult | None] = [None] * len(requests) 

1295 records: list[dict[str, Any]] = [] 

1296 all_chunks: list[str] = [] 

1297 profile_table = merged_user_and_builtin_profiles(self.extraction_profiles) 

1298 

1299 for index, request in enumerate(requests): 

1300 profile = profile_table.get(request.extraction_profile) if request.extraction_profile else None 

1301 mip_pipeline = request.mip_pipeline 

1302 mip_chunker = mip_pipeline.chunker if mip_pipeline else None 

1303 prepared = prepare_retain_input( 

1304 request, 

1305 profile, 

1306 graph_store_configured=self.graph_store is not None, 

1307 ) 

1308 chunking = resolve_retain_chunking( 

1309 prepared.effective_content_type, 

1310 profile=profile, 

1311 default_strategy=self.chunk_strategy, 

1312 default_max_chunk_size=self.max_chunk_size, 

1313 mip_chunker=mip_chunker, 

1314 ) 

1315 chunk_kwargs: dict[str, int] = {"max_chunk_size": chunking.max_size} 

1316 if chunking.overlap is not None: 

1317 chunk_kwargs["overlap"] = chunking.overlap 

1318 

1319 # Structured 5-dim fact extraction (opt-in) — same branch as 

1320 # retain(); produces fact texts + pre-extracted entities + 

1321 # caused_by index pairs for this record. 

1322 # SFE uses its own chunking strategy (see retain() above). 

1323 async with self._profiler.time("sfe"): 

1324 ( 

1325 sfe_fact_texts, 

1326 sfe_entities, 

1327 sfe_associations, 

1328 sfe_caused_by, 

1329 sfe_overlays, 

1330 ) = await self._structured_fact_extraction_for_text( 

1331 prepared, 

1332 request, 

1333 chunk_strategy=self.structured_fact_extraction_chunk_strategy, 

1334 chunk_max_size=( 

1335 self.structured_fact_extraction_chunk_max_size 

1336 if self.structured_fact_extraction_chunk_max_size is not None 

1337 else chunking.max_size 

1338 ), 

1339 chunk_overlap=chunking.overlap, 

1340 ) 

1341 if sfe_fact_texts is not None: 

1342 chunks = list(sfe_fact_texts) 

1343 else: 

1344 chunks = chunk_text(prepared.text, strategy=chunking.strategy, **chunk_kwargs) 

1345 if not chunks: 

1346 results[index] = RetainResult(stored=False, error="No content after chunking") 

1347 continue 

1348 

1349 start = len(all_chunks) 

1350 all_chunks.extend(chunks) 

1351 records.append( 

1352 { 

1353 "index": index, 

1354 "request": request, 

1355 "profile": profile, 

1356 "prepared": prepared, 

1357 "chunks": chunks, 

1358 "start": start, 

1359 "end": len(all_chunks), 

1360 # Carry the SFE artefacts through to the second loop and 

1361 # _process_record_entities. None entries mean the record 

1362 # took the legacy chunk_text + extract_entities path. 

1363 "sfe_entities": sfe_entities, 

1364 "sfe_associations": sfe_associations, 

1365 "sfe_caused_by": sfe_caused_by, 

1366 "sfe_overlays": sfe_overlays, 

1367 } 

1368 ) 

1369 

1370 if not records: 

1371 return [result or RetainResult(stored=False, error="No content after chunking") for result in results] 

1372 

1373 async with self._profiler.time("embed"): 

1374 all_embeddings = await generate_embeddings(all_chunks, self.llm_provider) 

1375 stored_records: list[dict[str, Any]] = [] 

1376 all_items: list[VectorItem] = [] 

1377 

1378 for record in records: 

1379 request: RetainRequest = record["request"] 

1380 profile: ExtractionProfileConfig | None = record["profile"] 

1381 prepared = record["prepared"] 

1382 chunks = record["chunks"] 

1383 embeddings = all_embeddings[record["start"] : record["end"]] 

1384 mip_pipeline = request.mip_pipeline 

1385 mip_dedup = mip_pipeline.dedup if mip_pipeline else None 

1386 dedup_threshold_override = mip_dedup.threshold if mip_dedup else None 

1387 dedup_action = (mip_dedup.action if mip_dedup else None) or self._config_dedup_action(request.bank_id) 

1388 

1389 keep_indices: list[int] = [] 

1390 any_duplicate = False 

1391 duplicates = await self._find_duplicate_chunks( 

1392 request.bank_id, chunks, embeddings, dedup_threshold_override 

1393 ) 

1394 for chunk_index, is_dup in enumerate(duplicates): 

1395 if is_dup: 

1396 any_duplicate = True 

1397 if dedup_action == "warn" or not is_dup: 

1398 keep_indices.append(chunk_index) 

1399 

1400 result_index = record["index"] 

1401 if dedup_action == "skip" and any_duplicate: 

1402 results[result_index] = RetainResult( 

1403 stored=False, 

1404 deduplicated=True, 

1405 error="Duplicate chunk(s) found; rule action=skip", 

1406 ) 

1407 continue 

1408 if not keep_indices: 

1409 results[result_index] = RetainResult( 

1410 stored=False, 

1411 deduplicated=True, 

1412 error="All chunks are near-duplicates", 

1413 ) 

1414 continue 

1415 

1416 chunks = [chunks[i] for i in keep_indices] 

1417 embeddings = [embeddings[i] for i in keep_indices] 

1418 sfe_overlays = record["sfe_overlays"] 

1419 if sfe_overlays is not None: 

1420 sfe_overlays = [sfe_overlays[i] for i in keep_indices] 

1421 

1422 # Remap SFE indices through dedup (mirrors retain() path). 

1423 sfe_associations = record["sfe_associations"] 

1424 sfe_caused_by = record["sfe_caused_by"] 

1425 sfe_entities = record["sfe_entities"] 

1426 if sfe_associations is not None or sfe_caused_by is not None: 

1427 old_to_new = {old: new for new, old in enumerate(keep_indices)} 

1428 if sfe_associations is not None: 

1429 sfe_associations = [ 

1430 (ent_idx, old_to_new[mem_idx]) for ent_idx, mem_idx in sfe_associations if mem_idx in old_to_new 

1431 ] 

1432 if sfe_caused_by is not None: 

1433 sfe_caused_by = [ 

1434 (old_to_new[src], old_to_new[tgt], conf) 

1435 for src, tgt, conf in sfe_caused_by 

1436 if src in old_to_new and tgt in old_to_new 

1437 ] 

1438 # Persist remapped versions for _process_record_entities. 

1439 record["sfe_associations"] = sfe_associations 

1440 record["sfe_caused_by"] = sfe_caused_by 

1441 

1442 if sfe_entities is not None: 

1443 entities = list(sfe_entities) 

1444 elif profile is not None and profile.entity_extraction == "metadata": 

1445 entities = _entities_from_metadata(prepared.metadata) 

1446 elif prepared.extract_entities: 

1447 entities = await extract_entities(prepared.text, self.llm_provider) 

1448 else: 

1449 entities = [] 

1450 

1451 profile_tier = profile.authority_tier if profile else None 

1452 chunk_metadata = merge_retain_metadata_authority_tier( 

1453 prepared.metadata, 

1454 bank_id=request.bank_id, 

1455 profile_authority_tier=profile_tier, 

1456 recall_authority=self.recall_authority, 

1457 ) 

1458 chunk_metadata = dict(chunk_metadata or {}) 

1459 if request.mip_rule_name: 

1460 chunk_metadata["_mip.rule"] = request.mip_rule_name 

1461 if mip_pipeline and mip_pipeline.version is not None: 

1462 chunk_metadata["_mip.pipeline_version"] = int(mip_pipeline.version) 

1463 chunk_metadata.setdefault("_created_at", datetime.now(timezone.utc).isoformat()) 

1464 chunk_metadata.setdefault("_retain_id", uuid.uuid4().hex) # see the single-retain path 

1465 if request.source: 

1466 chunk_metadata.setdefault(SOURCE_KEY, request.source) 

1467 

1468 # M10 source-aware retain — same helper as the single-retain path. 

1469 chunk_ids = await self._provision_source_provenance( 

1470 request, 

1471 prepared.text, 

1472 chunks, 

1473 ) 

1474 

1475 memory_ids: list[str] = [] 

1476 items: list[VectorItem] = [] 

1477 for index, (chunk, embedding, chunk_id) in enumerate(zip(chunks, embeddings, chunk_ids, strict=False)): 

1478 mem_id = uuid.uuid4().hex[:16] 

1479 memory_ids.append(mem_id) 

1480 item_meta, item_fact_type, item_occurred = _apply_fact_overlay( 

1481 _chunk_metadata(chunk_metadata, index, len(chunks)), 

1482 sfe_overlays[index] if sfe_overlays is not None else None, 

1483 fact_type=prepared.fact_type, 

1484 occurred_at=request.occurred_at, 

1485 profile_fact_type=record["profile"].fact_type if record["profile"] is not None else None, 

1486 ) 

1487 items.append( 

1488 VectorItem( 

1489 id=mem_id, 

1490 bank_id=request.bank_id, 

1491 vector=embedding, 

1492 text=chunk, 

1493 metadata=item_meta, 

1494 tags=prepared.tags, 

1495 fact_type=item_fact_type, 

1496 occurred_at=item_occurred, 

1497 retained_at=datetime.now(timezone.utc), 

1498 chunk_id=chunk_id, # M10: source-chunk backreference 

1499 ) 

1500 ) 

1501 

1502 record.update( 

1503 { 

1504 "chunks": chunks, 

1505 "embeddings": embeddings, 

1506 "entities": entities, 

1507 "memory_ids": memory_ids, 

1508 "items": items, 

1509 } 

1510 ) 

1511 stored_records.append(record) 

1512 all_items.extend(items) 

1513 

1514 if all_items: 

1515 async with self._profiler.time("store_vec"): 

1516 await self.vector_store.store_vectors(all_items) 

1517 

1518 # Hindsight-parity semantic-kNN graph (C3a) — same call as the 

1519 # single-retain path, applied per record so each batch's 

1520 # neighbors-search is scoped to its own bank. 

1521 for record in stored_records: 

1522 await self._persist_semantic_links( 

1523 record["request"].bank_id, 

1524 record["memory_ids"], 

1525 record["embeddings"], 

1526 ) 

1527 

1528 if self.document_store is not None: 

1529 from astrocyte.types import Document 

1530 

1531 for record in stored_records: 

1532 request = record["request"] 

1533 for item in record["items"]: 

1534 try: 

1535 await self.document_store.store_document( 

1536 Document( 

1537 id=item.id, 

1538 text=item.text, 

1539 metadata=item.metadata, 

1540 tags=item.tags, 

1541 ), 

1542 request.bank_id, 

1543 ) 

1544 except Exception as exc: 

1545 _logger.warning( 

1546 "document_store.store_document failed for chunk %s: %s " 

1547 "(keyword retrieval will miss this chunk)", 

1548 item.id, 

1549 exc, 

1550 ) 

1551 

1552 # ── Phase 1: parallel entity processing across all records ── 

1553 # Each record's entity work (embed names, store entities, write 

1554 # co-occurrence links, run resolution cascade) is independent of 

1555 # other records and dominates retain wall-clock when the bank is 

1556 # large. ``asyncio.gather`` lets all records' LLM/DB I/O run 

1557 # concurrently — for batches of 10 records this gives ~10× speedup 

1558 # on the entity-resolution-bound retain phase. The non-I/O steps 

1559 # (dedup cache, result assembly) stay sequential below. 

1560 if self.graph_store is not None: 

1561 entity_records = [r for r in stored_records if r["entities"]] 

1562 if entity_records: 

1563 await asyncio.gather(*[self._process_record_entities_with_retry(r) for r in entity_records]) 

1564 

1565 for record in stored_records: 

1566 request: RetainRequest = record["request"] 

1567 memory_ids: list[str] = record["memory_ids"] 

1568 embeddings: list[list[float]] = record["embeddings"] 

1569 chunks: list[str] = record["chunks"] 

1570 

1571 for mem_id, embedding, chunk in zip(memory_ids, embeddings, chunks, strict=False): 

1572 self._dedup.add(request.bank_id, mem_id, embedding, text=chunk) 

1573 

1574 self._spawn_observation_consolidation( 

1575 chunks=chunks, 

1576 embeddings=embeddings, 

1577 memory_ids=memory_ids, 

1578 bank_id=request.bank_id, 

1579 tags=request.tags, 

1580 ) 

1581 

1582 results[record["index"]] = RetainResult( 

1583 stored=True, 

1584 memory_id=memory_ids[0] if memory_ids else None, 

1585 ) 

1586 

1587 return [result or RetainResult(stored=False, error="Not retained") for result in results]