Coverage for astrocyte/provider.py: 70%

190 statements  

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

1"""Astrocyte provider protocols — the SPIs that backends implement. 

2 

3These are abstract contracts (Python Protocols), not concrete implementations. 

4Providers must implement every method in the relevant Protocol to be recognized 

5by Astrocyte's runtime type check (``isinstance(obj, VectorStore)`` etc.). 

6 

7Built-in implementations: 

8- In-memory test doubles: ``astrocyte.testing.in_memory`` 

9- PostgreSQL + pgvector: ``astrocyte_postgres.store.PostgresStore`` 

10- Optional wheels (repo ``adapters-storage-py/``): ``astrocyte-postgres``, ``astrocyte-qdrant``, ``astrocyte-neo4j``, ``astrocyte-elasticsearch`` 

11 

12Each protocol has a SPI_VERSION ClassVar for compatibility checking. 

13All methods are async except capabilities() and transport methods. 

14 

15SPI versioning: Astrocyte checks SPI_VERSION at registration time. 

16- Version 1: base protocol (all methods required) 

17- Version 2+: may add optional methods; Astrocyte adapts calls accordingly. 

18Providers with unrecognized versions are rejected. 

19""" 

20 

21from __future__ import annotations 

22 

23from datetime import datetime 

24from typing import TYPE_CHECKING, ClassVar, Protocol, runtime_checkable 

25 

26if TYPE_CHECKING: 

27 from astrocyte.types import ( 

28 Completion, 

29 Document, 

30 DocumentFilters, 

31 DocumentHit, 

32 EngineCapabilities, 

33 Entity, 

34 EntityCandidateMatch, 

35 EntityLink, 

36 ForgetRequest, 

37 ForgetResult, 

38 GraphHit, 

39 HealthStatus, 

40 HttpClientContext, 

41 LLMCapabilities, 

42 MemoryEntityAssociation, 

43 MemoryLink, 

44 MentalModel, 

45 Message, 

46 PageIndexDocument, 

47 PageIndexFact, 

48 PageIndexFactHit, 

49 PageIndexSection, 

50 PageIndexSectionEntity, 

51 PageIndexSectionLink, 

52 RecallRequest, 

53 RecallResult, 

54 ReflectRequest, 

55 ReflectResult, 

56 RetainRequest, 

57 RetainResult, 

58 SourceChunk, 

59 SourceDocument, 

60 ToolDefinition, 

61 TransportCapabilities, 

62 VectorFilters, 

63 VectorHit, 

64 VectorItem, 

65 WikiPage, 

66 WikiPageHit, 

67 ) 

68 

69 

70# --------------------------------------------------------------------------- 

71# Tier 1: Storage Providers 

72# --------------------------------------------------------------------------- 

73 

74 

75@runtime_checkable 

76class VectorStore(Protocol): 

77 """SPI for vector database adapters. Required for Tier 1. 

78 

79 Implement this protocol to connect Astrocyte to a vector database 

80 (e.g., pgvector, Qdrant, Pinecone). Astrocyte uses ``@runtime_checkable`` 

81 to verify your class satisfies this contract at registration time — 

82 no inheritance required, just implement every method below. 

83 

84 See ``astrocyte_postgres.store.PostgresStore`` for a production example, 

85 or ``astrocyte.testing.in_memory.InMemoryVectorStore`` for a minimal reference. 

86 

87 **Optional methods.** Not part of the protocol (so ``isinstance`` checks 

88 don't require them); Astrocyte probes for them with ``getattr`` and falls 

89 back, or reports the capability as unsupported, when they are absent: 

90 

91 - ``list_recent_vectors(bank_id, limit, filters)`` — newest live vectors 

92 first; recency retrieval uses it instead of scanning ``list_vectors``. 

93 - ``get_by_chunk_ids(chunk_ids, bank_id)`` — sibling chunks of a source 

94 chunk, for chunk expansion at recall. 

95 - ``list_changes(bank_id, *, after, limit) -> list[MemoryChange]`` — the 

96 bank's change feed for team-memory sync: **every change to a synced 

97 row, in (changed_at, id) order**. Live rows carry their current values 

98 (so a later metadata or status change shows up as an upsert of the same 

99 id); forgotten rows are tombstones (``deleted=True``, no text). 

100 ``changed_at`` is the row's last change to any synced field: the store 

101 must set it on **every** write that changes a row (insert: its 

102 ``retained_at``; overwrite, restore or any field update: the time of 

103 the change; forget: the time of the forget), and rows that predate it 

104 backfill as ``max(retained_at, forgotten_at)``. ``after`` is a ``(changed_at, id)`` 

105 position; only entries strictly after it are returned, so paging by the 

106 last entry's position never skips or repeats a row, even when many rows 

107 share one ``changed_at``. Ids compare byte-wise (``COLLATE "C"`` on 

108 Postgres). Requires soft deletes (or remembered tombstones): a store 

109 that erases rows outright cannot report that they were forgotten. 

110 ``Astrocyte.list_changes`` (``GET /v1/banks/{bank_id}/changes``) raises 

111 :class:`~astrocyte.errors.CapabilityNotSupported` without it. 

112 - ``lookup_ids(ids) -> list[MemoryChange]`` — the current state of each id 

113 that exists **in any bank**: a live row (full record) or a tombstone. 

114 Ids never stored (or purged) are absent. Deliberately cross-bank: both 

115 SQL stores key rows on ``id`` alone, so a writer that must not take over 

116 another bank's row has to be able to see it. Callers must never return 

117 another bank's content. 

118 - ``insert_vectors(items) -> list[str]`` — like ``store_vectors`` but 

119 insert-only: an item whose id already exists in any bank, live or 

120 forgotten, is skipped, never overwritten (``ON CONFLICT DO NOTHING``). 

121 Returns the ids actually inserted, so a concurrent writer that got there 

122 first is detected rather than clobbered. 

123 ``Astrocyte.push_records`` (``POST /v1/banks/{bank_id}/sync/push``) 

124 needs both, and raises ``CapabilityNotSupported`` without them. 

125 - ``erase(bank_id, ids) -> int`` — erase already-forgotten memories of 

126 ``ids`` from storage for good (text, embedding, metadata and anything 

127 derived from them), keeping each one's tombstone: ``list_changes`` and 

128 ``lookup_ids`` still report it deleted, and ``insert_vectors`` never 

129 stores the id again. Live rows are untouched, so erase never bypasses 

130 forget. Returns how many were erased. ``Astrocyte.erase`` (a gateway 

131 forget with ``erase: true``, a DSAR erasure) raises 

132 ``CapabilityNotSupported`` without it. 

133 - ``save_legal_hold(hold)``, ``delete_legal_hold(bank_id, hold_id) -> bool``, 

134 ``list_legal_holds(bank_id) -> list[LegalHold]`` — persisted legal 

135 holds, so a hold survives restarts and binds every process and replica 

136 sharing the store. ``save`` replaces a hold with the same 

137 ``(bank_id, hold_id)``. ``Astrocyte.place_legal_hold`` uses them, and 

138 ``forget`` / ``run_lifecycle`` re-read them before checking; without 

139 them, holds are kept in the process only. 

140 """ 

141 

142 SPI_VERSION: ClassVar[int] = 1 

143 

144 async def store_vectors(self, items: list[VectorItem]) -> list[str]: 

145 """Persist vectors with metadata. Upsert semantics — existing IDs are overwritten. 

146 

147 Implementations must validate that each item's vector length matches the 

148 configured embedding dimensions. 

149 

150 Returns: 

151 List of stored IDs (same order as ``items``). If ``items`` is empty, 

152 implementations must treat this as a no-op and return an empty list. 

153 

154 Raises: 

155 ValueError: If any vector's length does not match embedding dimensions. 

156 """ 

157 pass 

158 

159 async def search_similar( 

160 self, 

161 query_vector: list[float], 

162 bank_id: str, 

163 limit: int = 10, 

164 filters: VectorFilters | None = None, 

165 ) -> list[VectorHit]: 

166 """Find vectors most similar to ``query_vector`` within a bank. 

167 

168 Returns: 

169 Hits sorted by similarity descending. Each hit's ``score`` must be 

170 in the range [0.0, 1.0] where 1.0 is an exact match. Returns an 

171 empty list if ``bank_id`` does not exist. 

172 

173 Raises: 

174 ValueError: If ``query_vector`` length does not match embedding dimensions. 

175 """ 

176 pass 

177 

178 async def delete(self, ids: list[str], bank_id: str) -> int: 

179 """Delete vectors by ID within a bank. Bank isolation is required — 

180 an ID in ``bank-2`` must not be deleted when ``bank_id`` is ``bank-1``. 

181 

182 Returns: 

183 Count of vectors actually deleted. Returns 0 for empty ``ids`` list. 

184 """ 

185 pass 

186 

187 async def list_vectors( 

188 self, 

189 bank_id: str, 

190 offset: int = 0, 

191 limit: int = 100, 

192 ) -> list[VectorItem]: 

193 """List vectors in a bank with pagination. Used by consolidation 

194 to scan and deduplicate vectors. 

195 

196 Implementations must return vectors in a **stable order** (e.g., by ID) 

197 so that pagination produces consistent, complete results. 

198 

199 Returns: 

200 Up to ``limit`` vectors starting at ``offset``. Empty list if 

201 the bank does not exist or ``offset`` is past the end. 

202 """ 

203 pass 

204 

205 async def health(self) -> HealthStatus: 

206 """Check database connectivity. 

207 

208 Returns: 

209 ``HealthStatus(healthy=True, ...)`` if the database is reachable, 

210 ``HealthStatus(healthy=False, ...)`` with a diagnostic message otherwise. 

211 """ 

212 pass 

213 

214 

215@runtime_checkable 

216class GraphStore(Protocol): 

217 """SPI for graph database adapters. Optional for Tier 1. 

218 

219 Implement this protocol to add entity/relationship-based retrieval 

220 alongside vector search. Astrocyte's multi-strategy retrieval pipeline 

221 will query both VectorStore and GraphStore in parallel when available. 

222 """ 

223 

224 SPI_VERSION: ClassVar[int] = 1 

225 

226 async def store_entities(self, entities: list[Entity], bank_id: str) -> list[str]: 

227 """Persist or update entities (people, concepts, objects) in the graph. 

228 

229 Returns: 

230 List of stored entity IDs. 

231 """ 

232 pass 

233 

234 async def store_links(self, links: list[EntityLink], bank_id: str) -> list[str]: 

235 """Persist relationships between entities. 

236 

237 Returns: 

238 List of stored link IDs. 

239 """ 

240 pass 

241 

242 async def link_memories_to_entities( 

243 self, 

244 associations: list[MemoryEntityAssociation], 

245 bank_id: str, 

246 ) -> None: 

247 """Associate memory IDs (from VectorStore) with entity IDs in the graph. 

248 

249 This enables graph-based recall: "find memories connected to entity X." 

250 """ 

251 pass 

252 

253 async def store_memory_links( 

254 self, 

255 links: list["MemoryLink"], 

256 bank_id: str, 

257 ) -> list[str]: 

258 """Persist directional memory-to-memory links (Hindsight parity). 

259 

260 Used for causal chains (``caused_by``) and the precomputed 

261 semantic kNN graph (``semantic``). Adapters that don't 

262 implement this should silently no-op (return ``[]``) — the 

263 spread / link-expansion path probes via ``getattr`` and 

264 degrades to entity-only when the method is absent. 

265 

266 Returns the list of stored link IDs. 

267 """ 

268 return [] 

269 

270 async def find_memory_links( 

271 self, 

272 seed_memory_ids: list[str], 

273 bank_id: str, 

274 *, 

275 link_types: list[str] | None = None, 

276 limit: int = 200, 

277 ) -> list["MemoryLink"]: 

278 """Return memory links touching any seed (either direction). 

279 

280 Adapter contract: results SHOULD include links where the seed 

281 is the source OR the target — the link-expansion retrieval 

282 treats both directions for ``semantic`` and only the target 

283 direction for ``caused_by`` (effect → cause). Callers filter 

284 as needed. 

285 

286 Adapters that haven't implemented this return ``[]``; the 

287 link-expansion retrieval probes via ``getattr`` and skips the 

288 memory-link signal when the method is absent. 

289 """ 

290 return [] 

291 

292 async def query_neighbors( 

293 self, 

294 entity_ids: list[str], 

295 bank_id: str, 

296 max_depth: int = 2, 

297 limit: int = 20, 

298 ) -> list[GraphHit]: 

299 """Traverse the graph from ``entity_ids`` up to ``max_depth`` hops 

300 and return connected memories. 

301 

302 Returns: 

303 Graph hits sorted by relevance (e.g., shortest path, edge weight). 

304 """ 

305 pass 

306 

307 async def query_entities( 

308 self, 

309 query: str, 

310 bank_id: str, 

311 limit: int = 10, 

312 ) -> list[Entity]: 

313 """Search entities by name or alias (fuzzy or exact match). 

314 

315 Returns: 

316 Matching entities, useful for resolving entity references before 

317 calling ``query_neighbors``. 

318 """ 

319 pass 

320 

321 async def find_entity_candidates( 

322 self, 

323 name: str, 

324 bank_id: str, 

325 threshold: float = 0.8, 

326 limit: int = 5, 

327 ) -> list[Entity]: 

328 """Return entities whose name is similar to *name* above *threshold*. 

329 

330 Used by the entity resolution pipeline (M11) to find candidate 

331 entities that may be aliases of a newly-extracted entity before 

332 calling the LLM confirmation step. 

333 

334 A simple implementation may use case-insensitive substring matching; 

335 production adapters should use vector similarity or full-text search. 

336 

337 Returns: 

338 Candidate entities ordered by similarity descending. 

339 """ 

340 pass 

341 

342 async def find_entity_candidates_scored( 

343 self, 

344 name: str, 

345 bank_id: str, 

346 *, 

347 name_embedding: list[float] | None = None, 

348 trigram_threshold: float = 0.3, 

349 limit: int = 10, 

350 ) -> list[EntityCandidateMatch]: 

351 """Return scored entity candidates for the Hindsight entity-resolution cascade. 

352 

353 Scores each candidate against two cheap signals: 

354 

355 - ``name_similarity`` — trigram similarity between the candidate's 

356 name and ``name`` in ``[0.0, 1.0]``. Production adapters use 

357 ``pg_trgm.similarity()``; the in-memory adapter approximates with 

358 :class:`difflib.SequenceMatcher`. 

359 - ``embedding_similarity`` — cosine similarity between the 

360 candidate's stored embedding and ``name_embedding``, or ``None`` 

361 when either side has no embedding. 

362 

363 The resolver uses these scores to autolink, skip, or escalate to LLM 

364 without further round-trips. 

365 

366 Args: 

367 name: Surface form to resolve. 

368 bank_id: Bank scope. 

369 name_embedding: Optional embedding of ``name`` for the embedding 

370 tier. When ``None``, ``embedding_similarity`` is ``None`` for 

371 every result and the cascade falls back to trigram + LLM. 

372 trigram_threshold: Adapter-side prefilter — candidates with a 

373 trigram similarity strictly below this value are dropped. 

374 Default ``0.3`` matches PostgreSQL's ``pg_trgm.similarity_threshold``. 

375 limit: Maximum candidates returned, ordered by descending score. 

376 

377 Returns: 

378 ``list[EntityCandidateMatch]`` ordered by best score first. 

379 Defaults to an empty list when no candidates clear the threshold. 

380 """ 

381 pass 

382 

383 async def store_entity_link(self, link: EntityLink, bank_id: str) -> str: 

384 """Persist a single typed relationship between two entities. 

385 

386 Unlike ``store_links`` (bulk, co-occurrence), this method is called 

387 by the entity resolution pipeline for confirmed alias links that carry 

388 ``evidence`` and ``confidence``. 

389 

390 Returns: 

391 The stored link ID. 

392 """ 

393 pass 

394 

395 async def health(self) -> HealthStatus: 

396 """Check database connectivity.""" 

397 pass 

398 

399 

400@runtime_checkable 

401class DocumentStore(Protocol): 

402 """SPI for document storage with full-text search. Optional for Tier 1. 

403 

404 Implement this protocol to add BM25/lexical search alongside vector 

405 similarity. Useful for keyword-heavy queries where semantic search 

406 alone may miss exact matches. 

407 """ 

408 

409 SPI_VERSION: ClassVar[int] = 1 

410 

411 async def store_document(self, document: Document, bank_id: str) -> str: 

412 """Persist a source document for full-text indexing. 

413 

414 Returns: 

415 The stored document ID. 

416 """ 

417 pass 

418 

419 async def search_fulltext( 

420 self, 

421 query: str, 

422 bank_id: str, 

423 limit: int = 10, 

424 filters: DocumentFilters | None = None, 

425 ) -> list[DocumentHit]: 

426 """BM25 full-text search over stored content. 

427 

428 Returns: 

429 Hits sorted by BM25 relevance score descending. 

430 """ 

431 pass 

432 

433 async def get_document(self, document_id: str, bank_id: str) -> Document | None: 

434 """Retrieve a single document by ID. 

435 

436 Returns: 

437 The document, or ``None`` if not found in this bank. 

438 """ 

439 pass 

440 

441 async def health(self) -> HealthStatus: 

442 """Check database connectivity.""" 

443 pass 

444 

445 

446# --------------------------------------------------------------------------- 

447# Tier 1: Wiki Store (M8) 

448# --------------------------------------------------------------------------- 

449 

450 

451@runtime_checkable 

452class WikiStore(Protocol): 

453 """SPI for wiki page storage (M8 LLM wiki compile). Optional. 

454 

455 WikiStore persists structured WikiPage metadata. Vector embeddings of 

456 compiled pages are stored separately in the VectorStore with 

457 ``memory_layer="compiled"`` and ``fact_type="wiki"``, so recall tiering 

458 can search them via the standard ``search_similar`` path. 

459 

460 Implement this protocol to enable ``brain.compile()`` persistence. 

461 See ``astrocyte.testing.in_memory.InMemoryWikiStore`` for a reference. 

462 """ 

463 

464 SPI_VERSION: ClassVar[int] = 1 

465 

466 async def upsert_page(self, page: WikiPage, bank_id: str) -> str: 

467 """Create or update a wiki page. Upsert semantics — if a page with 

468 the same ``page_id`` exists in this bank, its revision is incremented 

469 and content replaced. The previous revision is archived (not deleted). 

470 

471 Returns: 

472 The stored ``page_id``. 

473 """ 

474 pass 

475 

476 async def get_page(self, page_id: str, bank_id: str) -> WikiPage | None: 

477 """Retrieve the current revision of a wiki page by ID. 

478 

479 Returns: 

480 The page, or ``None`` if not found in this bank. 

481 """ 

482 pass 

483 

484 async def list_pages( 

485 self, 

486 bank_id: str, 

487 scope: str | None = None, 

488 kind: str | None = None, 

489 ) -> list[WikiPage]: 

490 """List current-revision wiki pages for a bank. 

491 

492 Args: 

493 bank_id: The bank to list pages for. 

494 scope: If set, return only pages whose ``scope`` matches exactly. 

495 kind: If set, return only pages of this kind ("entity", "topic", "concept"). 

496 

497 Returns: 

498 All matching pages, unsorted. 

499 """ 

500 pass 

501 

502 async def delete_page(self, page_id: str, bank_id: str) -> bool: 

503 """Delete a wiki page (current revision and audit log). 

504 

505 Returns: 

506 ``True`` if the page was found and deleted, ``False`` if not found. 

507 """ 

508 pass 

509 

510 async def health(self) -> HealthStatus: 

511 """Check storage connectivity.""" 

512 pass 

513 

514 

515# --------------------------------------------------------------------------- 

516# Section recall (M9): PageIndex tree + section graph store 

517# --------------------------------------------------------------------------- 

518 

519 

520@runtime_checkable 

521class PageIndexDocumentStore(Protocol): 

522 """Document + section-skeleton persistence (save/load). 

523 

524 A narrow role slice of the section-recall SPI. Consumers should depend on 

525 the narrowest role they use; :class:`PageIndexStore` is the union of all 

526 roles, retained for implementers and existing annotations. 

527 """ 

528 

529 async def save_document(self, doc: PageIndexDocument) -> str: 

530 """Upsert a document. Returns the document id (UUID string). 

531 

532 Upsert keyed on ``(bank_id, source_id)`` — re-running tree-build 

533 for the same conversation replaces the prior row. 

534 """ 

535 

536 async def save_sections( 

537 self, 

538 document_id: str, 

539 sections: list[PageIndexSection], 

540 ) -> int: 

541 """Bulk-replace all sections for a document. Returns the count 

542 written. Existing rows for ``document_id`` are deleted first 

543 (the tree rebuild is treated as atomic — no partial trees).""" 

544 

545 async def load_document( 

546 self, 

547 bank_id: str, 

548 source_id: str, 

549 ) -> PageIndexDocument | None: 

550 """Fetch one document by (bank_id, source_id). Returns ``None`` 

551 when not found (caller decides whether to build it).""" 

552 

553 async def load_skeleton(self, document_id: str) -> list[PageIndexSection]: 

554 """Fetch all sections for a document, ordered by ``line_num``. 

555 

556 Returned sections do NOT carry ``summary_embedding`` (that field 

557 is only used by the semantic strategy in PR2; the picker doesn't 

558 need it). Implementations may project it out for cheaper reads. 

559 """ 

560 

561 async def load_sections_with_embeddings( 

562 self, 

563 bank_id: str, 

564 document_id: str, 

565 ) -> list["PageIndexSection"]: 

566 """M10.1: load sections including ``summary_embedding`` (which 

567 :meth:`load_skeleton` deliberately drops as a cost optimisation). 

568 

569 Used by :func:`section_compile.compile_sections_for_document` to 

570 cluster sections via DBSCAN. Returns sections in arbitrary order; 

571 caller sorts as needed.""" 

572 

573 

574class PageIndexSectionWriter(Protocol): 

575 """Section enrichment writes (embeddings, entities, links, event dates). 

576 

577 A narrow role slice of the section-recall SPI. Consumers should depend on 

578 the narrowest role they use; :class:`PageIndexStore` is the union of all 

579 roles, retained for implementers and existing annotations. 

580 """ 

581 

582 async def save_section_embeddings( 

583 self, 

584 document_id: str, 

585 embeddings: list[tuple[int, list[float]]], 

586 ) -> int: 

587 """PR2 commit A: bulk-update ``summary_embedding`` on existing 

588 section rows. Skips rows whose ``line_num`` doesn't already 

589 exist (the tree-build step is the source of truth for which 

590 sections exist). Returns rows updated.""" 

591 

592 async def save_section_entities( 

593 self, 

594 entities: list[PageIndexSectionEntity], 

595 ) -> int: 

596 """Bulk-insert entity-mention rows. Idempotent on the composite 

597 primary key. Returns rows written. PR2 commit A populates.""" 

598 

599 async def save_section_links( 

600 self, 

601 links: list[PageIndexSectionLink], 

602 ) -> int: 

603 """Bulk-insert section-link rows. Idempotent on the composite 

604 primary key. Returns rows written. PR2 commit B/D populates.""" 

605 

606 async def populate_semantic_knn_links( 

607 self, 

608 document_id: str, 

609 *, 

610 top_k: int = 5, 

611 min_similarity: float = 0.5, 

612 ) -> int: 

613 """PR2 D.7.1: Populate ``section_links`` with kNN edges over 

614 ``summary_embedding``. For each section, link to its top_k 

615 most-similar OTHER sections in the same document with 

616 ``link_type='semantic_knn'`` and weight = cosine similarity. 

617 

618 Pure SQL — no LLM call. Idempotent. Returns rows inserted. 

619 

620 Why this exists: D.7's LLM-based link extraction over-emits on 

621 LoCoMo and severely under-emits on LME (5 vs 100 links/doc). 

622 kNN restores dense topical bridging for graph_expand.""" 

623 

624 async def save_section_event_dates( 

625 self, 

626 document_id: str, 

627 event_dates: list[tuple[int, datetime, datetime | None]], 

628 ) -> int: 

629 """M11.1: update ``occurred_start`` / ``occurred_end`` on 

630 existing section rows. 

631 

632 ``event_dates`` is a list of ``(line_num, start, end_or_None)`` 

633 tuples. Rows not in the list are left untouched. Returns the 

634 number of rows updated. Used by the bench retain pipeline 

635 after section_event_extraction runs.""" 

636 

637 

638class PageIndexSectionSearch(Protocol): 

639 """Section-grain retrieval: parallel search + graph-expand strategies. 

640 

641 A narrow role slice of the section-recall SPI. Consumers should depend on 

642 the narrowest role they use; :class:`PageIndexStore` is the union of all 

643 roles, retained for implementers and existing annotations. 

644 """ 

645 

646 # ── PR2 commit B: query methods for the 5 parallel strategies ── 

647 # 

648 # These are pure read methods that the section recall orchestrator 

649 # (``astrocyte.pipeline.section_recall``) calls in parallel. Each 

650 # returns a ranked list of ``(document_id, line_num, score)`` 

651 # tuples; the orchestrator fuses them via RRF. 

652 # 

653 # Single-bank scoping for now (PR2). Cross-bank scoping comes when 

654 # multi-bank section recall ships in M10+. 

655 

656 async def search_sections_semantic( 

657 self, 

658 bank_id: str, 

659 query_embedding: list[float], 

660 *, 

661 top_k: int = 20, 

662 session_filter: str | None = None, 

663 ) -> list[tuple[str, int, float]]: 

664 """PR2 commit B / semantic strategy: cosine-similarity over 

665 ``summary_embedding``. Returns ``(document_id, line_num, score)`` 

666 tuples, ordered by similarity desc. Score is in [0, 1] (1 - distance). 

667 

668 M31 Fix 2: ``session_filter`` (opaque str) scopes results to 

669 sections whose ``session_id`` matches. ``None`` (default) 

670 retrieves cross-session. 

671 """ 

672 

673 async def search_sections_keyword( 

674 self, 

675 bank_id: str, 

676 query: str, 

677 *, 

678 top_k: int = 20, 

679 speaker: str | None = None, 

680 document_id: str | None = None, 

681 session_filter: str | None = None, 

682 ) -> list[tuple[str, int, float]]: 

683 """PR2 commit B / keyword strategy: full-text search (``tsvector``) 

684 over section titles + summaries. Returns ``(document_id, 

685 line_num, score)`` tuples. The optional ``speaker`` filter is 

686 for LME assistant-recall (``WHERE speaker = 'assistant'``). 

687 The optional ``document_id`` filter (PR2.6) scopes the search 

688 to a single document — used by ``temporal_arithmetic.find_event_date`` 

689 to avoid bank-wide top-K starvation when 50+ documents share a 

690 bank and the matching one isn't in the unfiltered top-5. 

691 

692 M31 Fix 2: ``session_filter`` (opaque str) scopes to sections 

693 whose ``session_id`` matches.""" 

694 

695 async def search_sections_by_entities( 

696 self, 

697 bank_id: str, 

698 entity_names: list[str], 

699 *, 

700 top_k: int = 20, 

701 session_filter: str | None = None, 

702 ) -> list[tuple[str, int, float]]: 

703 """PR2 commit B / entity strategy: Hindsight's CTE pattern. 

704 Returns ``(document_id, line_num, score)`` where score is the 

705 count of distinct matching entity_names per section. 

706 

707 M31 Fix 2: ``session_filter`` joins to sections to scope by 

708 ``session_id``.""" 

709 

710 async def search_sections_temporal( 

711 self, 

712 bank_id: str, 

713 date_range: tuple[datetime, datetime], 

714 *, 

715 top_k: int = 20, 

716 session_filter: str | None = None, 

717 ) -> list[tuple[str, int, float]]: 

718 """PR2 commit B / temporal strategy: date-range filter on 

719 ``session_date`` (uses the partial btree index from migration 

720 015). Score is uniform (1.0) — temporal is a filter, not a 

721 ranker; ranking happens in fusion. 

722 

723 M31 Fix 2: ``session_filter`` scopes to sections whose 

724 ``session_id`` matches.""" 

725 

726 async def expand_section_links( 

727 self, 

728 seeds: list[tuple[str, int]], 

729 *, 

730 link_types: list[str] | None = None, 

731 top_k: int = 20, 

732 ) -> list[tuple[str, int, float]]: 

733 """PR2 commit B / graph-expand strategy: 1-hop expansion through 

734 ``section_links`` from the given ``(document_id, line_num)`` 

735 seeds. ``link_types`` filters to e.g. ['semantic_knn', 'causal']; 

736 None means all types. Score is link weight.""" 

737 

738 async def expand_sections_by_shared_entities( 

739 self, 

740 bank_id: str, 

741 seeds: list[tuple[str, int]], 

742 *, 

743 top_k: int = 20, 

744 exclude_seeds: bool = True, 

745 ) -> list[tuple[str, int, float]]: 

746 """Fix 3 (conv-run-4) / entity spreading activation: 1-hop 

747 expansion through entity co-occurrence in 

748 ``astrocyte_pi_section_entities``. 

749 

750 For each seed ``(document_id, line_num)``, find every section 

751 in the same bank that shares at least one entity with the seed. 

752 Returns ``(document_id, line_num, score)`` where score is the 

753 count of distinct shared entities. ``exclude_seeds=True`` 

754 filters the seeds themselves out of the result. 

755 

756 Why this is distinct from ``expand_section_links``: links rely 

757 on ``astrocyte_pi_section_links`` being populated (semantic_knn 

758 and the LLM-extracted causal/elaborates edges). In conversation 

759 ingest the link density is sparse, so the existing graph_expand 

760 strategy can't bridge Denver → Red Rocks even when both 

761 sections mention the same entity. Entity spread uses the 

762 already-populated ``astrocyte_pi_section_entities`` table 

763 directly, which gives a denser bridge with the same SQL cost. 

764 """ 

765 

766 

767class PageIndexFactStore(Protocol): 

768 """M12.1 atomic fact-grain layer (persist + search facts). 

769 

770 A narrow role slice of the section-recall SPI. Consumers should depend on 

771 the narrowest role they use; :class:`PageIndexStore` is the union of all 

772 roles, retained for implementers and existing annotations. 

773 """ 

774 

775 # ── M12.1: fact-grain layer (atomic facts alongside sections) ─────── 

776 

777 async def save_facts(self, facts: list["PageIndexFact"]) -> int: 

778 """M12.1: persist atomic facts extracted from sections. 

779 

780 Each fact's ``embedding`` may be ``None`` (caller embeds in a 

781 separate batched pass) or populated. Returns the number of rows 

782 inserted. Implementations should validate that 

783 ``(document_id, line_num)`` references an existing section and 

784 let the FK cascade handle deletion.""" 

785 

786 async def update_fact_embeddings( 

787 self, 

788 embeddings: list[tuple[str, list[float]]], 

789 ) -> int: 

790 """M12.1: write the embedding column for a batch of facts. 

791 

792 ``embeddings`` is ``[(fact_id, embedding_vector), ...]``. 

793 Returns the number of rows updated. Separated from 

794 :meth:`save_facts` so the bench can extract facts and embed 

795 them in two parallel passes — Hindsight's retain shape.""" 

796 

797 async def search_facts_semantic( 

798 self, 

799 bank_id: str, 

800 query_embedding: list[float], 

801 *, 

802 top_k: int = 20, 

803 document_id: str | None = None, 

804 fact_type: str | None = None, 

805 session_filter: str | None = None, 

806 ) -> list["PageIndexFactHit"]: 

807 """M12.1: cosine-similarity search over fact embeddings. 

808 

809 M31 Fix 2: ``session_filter`` scopes results to facts whose 

810 anchoring section has matching ``session_id`` (EXISTS subquery 

811 in the Postgres adapter, _matches_session helper in the 

812 in-memory adapter). Top-level facts (no anchor) are excluded 

813 when ``session_filter`` is set.""" 

814 

815 async def search_facts_by_entity( 

816 self, 

817 bank_id: str, 

818 entity_name: str, 

819 *, 

820 top_k: int = 50, 

821 document_id: str | None = None, 

822 fact_type: str | None = None, 

823 session_filter: str | None = None, 

824 ) -> list["PageIndexFactHit"]: 

825 """M12.1: list facts whose ``entities`` array contains 

826 ``entity_name`` (or a case-insensitive variant). Used by the 

827 agent's counting tool — e.g. for "how many doctors" the agent 

828 queries ``entity_name="role:doctor"`` and counts the rows. 

829 

830 M31 Fix 2: ``session_filter`` mirrors the semantic search 

831 contract. For cross-session link-expansion usage (M27), 

832 ``session_filter`` should be ``None`` (the default) so the 

833 whole point of cross-session traversal is preserved. 

834 

835 M34-3: ``fact_type`` filters to a single fact_type (e.g. 

836 ``'preference'``) so per-fact-type segmented retrieval can 

837 produce independent candidate pools per type.""" 

838 

839 async def search_facts_temporal( 

840 self, 

841 bank_id: str, 

842 date_range: tuple[datetime, datetime], 

843 *, 

844 top_k: int = 50, 

845 document_id: str | None = None, 

846 fact_type: str | None = None, 

847 session_filter: str | None = None, 

848 ) -> list["PageIndexFactHit"]: 

849 """M12.1: list facts whose ``occurred_start`` falls in the 

850 date range. Powers temporal-arithmetic queries that previously 

851 had to resort to keyword search over section summaries. 

852 

853 M31 Fix 2: ``session_filter`` scopes to one session's 

854 temporal facts. 

855 

856 M34-3: ``fact_type`` filters to a single fact_type so the 

857 temporal channel's per-type segment doesn't leak across types.""" 

858 

859 async def search_facts_keyword( 

860 self, 

861 bank_id: str, 

862 query: str, 

863 *, 

864 top_k: int = 20, 

865 document_id: str | None = None, 

866 fact_type: str | None = None, 

867 session_filter: str | None = None, 

868 ) -> list["PageIndexFactHit"]: 

869 """M31c — Hindsight-parity BM25/full-text search over fact 

870 text. Complements ``search_facts_semantic`` (vector cosine): 

871 BM25 catches specific noun/number matches (e.g. "500 Mbps", 

872 "Philips LED", "University of Melbourne") that semantic 

873 embeddings tend to under-weight relative to thematically 

874 similar but topically off content. 

875 

876 Implementations: 

877 

878 - Postgres adapter: ``plainto_tsquery`` + ``ts_rank_cd`` over 

879 ``to_tsvector('english', fact_text)`` (computed on-the-fly; 

880 no schema migration needed). Mirrors the 

881 ``search_sections_keyword`` pattern that's been validated 

882 since M9. 

883 - InMemory adapter: token-overlap scoring (count of distinct 

884 query tokens appearing in fact text), case-insensitive. 

885 

886 Returns ``(document_id, line_num, score)``-bearing 

887 ``PageIndexFactHit`` records, ordered by score desc. Score is 

888 rank-rather-than-similarity (BM25 magnitudes are not directly 

889 comparable to vector cosine — RRF fusion in ``fact_recall`` 

890 handles the scale difference). 

891 

892 Diagnostic that motivated this: SSU bench failures showed the 

893 cross-encoder rerank can rank a specific factoid statement 

894 ("User upgraded to 500 Mbps internet plan") deep in the 

895 candidate pool while surfacing thematically-related but 

896 irrelevant content ("commute", "road trip") at the top. 

897 BM25 catches the literal-keyword match that semantic alone 

898 misses.""" 

899 

900 async def count_facts_matching( 

901 self, 

902 bank_id: str, 

903 document_id: str, 

904 *, 

905 entity_pattern: str | None = None, 

906 fact_type: str | None = None, 

907 ) -> int: 

908 """M12.1: deterministic count of facts matching the given 

909 filters. ``entity_pattern`` is a substring (case-insensitive) 

910 matched against any element of the ``entities`` array. Used 

911 by the agent's counting tool for questions like "how many 

912 doctors did I visit?" — agent calls with 

913 ``entity_pattern="role:doctor"`` and gets back the exact 

914 count.""" 

915 

916 

917class PageIndexWikiStore(Protocol): 

918 """Compiled wiki-page persistence and lookup. 

919 

920 A narrow role slice of the section-recall SPI. Consumers should depend on 

921 the narrowest role they use; :class:`PageIndexStore` is the union of all 

922 roles, retained for implementers and existing annotations. 

923 """ 

924 

925 async def save_wiki_page( 

926 self, 

927 *, 

928 page: "WikiPage", 

929 embedding: list[float] | None, 

930 provenance: list[tuple[str, int]], 

931 ) -> str: 

932 """M10.1: persist one observation as a wiki page + its 

933 section-grain provenance + (optional) embedding for the wiki 

934 recall strategy. 

935 

936 Implementations must: 

937 - Insert / upsert the wiki_page row. 

938 - Insert provenance rows ``(wiki_page_id, document_id, line_num)`` 

939 via ``astrocyte_pi_wiki_provenance`` (migration 015). 

940 - Set ``current_embedding`` on the wiki page when the column 

941 exists (migration 018 adds it). Pass ``None`` to skip 

942 embedding (caller does its own deferred indexing). 

943 

944 Returns the page's ``page_id``.""" 

945 

946 async def search_wiki_pages_semantic( 

947 self, 

948 bank_id: str, 

949 query_embedding: list[float], 

950 *, 

951 top_k: int = 5, 

952 document_id: str | None = None, 

953 ) -> list["WikiPageHit"]: 

954 """M10.1: cosine-similarity search over 

955 ``astrocyte_wiki_pages.current_embedding`` (migration 018). 

956 

957 Returns up to ``top_k`` :class:`~astrocyte.types.WikiPageHit` 

958 rows ordered by similarity desc. The optional ``document_id`` 

959 filter scopes to a single document (the bench is per-doc; prod 

960 deployments may want bank-wide).""" 

961 

962 async def count_wiki_pages_for_doc( 

963 self, 

964 bank_id: str, 

965 document_id: str, 

966 ) -> int: 

967 """M10.1: idempotency check for the consolidation pass. 

968 ``compile_sections_for_document`` skips work when this returns 

969 > 0.""" 

970 

971 async def list_wiki_pages_for_doc( 

972 self, 

973 bank_id: str, 

974 document_id: str, 

975 ) -> list["WikiPage"]: 

976 """M12.6: enumerate current-revision wiki pages with 

977 ``scope = 'document:<id>'``. Returns full WikiPage rows with 

978 markdown content populated; used by the revision pass to load 

979 existing pages, send them through an LLM update, and persist 

980 bumped revisions. 

981 

982 Default implementation returns ``[]`` for stores that don't 

983 yet implement wiki enumeration — the revision pass becomes a 

984 no-op on those backends rather than crashing.""" 

985 return [] 

986 

987 async def list_wikis_affected_by_entities( 

988 self, 

989 bank_id: str, 

990 entities: list[str], 

991 *, 

992 min_overlap: int = 1, 

993 limit: int = 8, 

994 ) -> list[tuple["WikiPage", int, list[str]]]: 

995 """M14.2: find existing wiki pages whose provenance sections 

996 share entities with a new source, returning fully-hydrated 

997 WikiPage rows + overlap stats. 

998 

999 Backs Karpathy's update-affected-wikis op. The aggregation 

1000 ``HAVING`` cuts the wiki set down to the small subset (~5-15 

1001 per source) where the entity-overlap signal is real; returning 

1002 full rows means callers don't need a separate ``get_page`` 

1003 round-trip per result. 

1004 

1005 Args: 

1006 bank_id: tenant scope. 

1007 entities: distinct entity names from the new source. 

1008 min_overlap: minimum shared entities to flag a wiki as 

1009 affected. ``1`` is the loosest threshold. 

1010 limit: cap on returned rows (top-N by overlap count). 

1011 

1012 Returns: 

1013 ``[(WikiPage, overlap_count, shared_entities), ...]`` 

1014 sorted descending by ``overlap_count``, ties broken by 

1015 ``page_id`` ascending for stability. 

1016 

1017 Default implementation returns ``[]`` for stores that haven't 

1018 yet been migrated — the update pass becomes a no-op on those 

1019 backends rather than crashing. 

1020 """ 

1021 _ = bank_id, entities, min_overlap, limit # noqa: ARG002 

1022 return [] 

1023 

1024 async def list_distinct_entities( 

1025 self, 

1026 bank_id: str, 

1027 document_id: str, 

1028 *, 

1029 pattern: str | None = None, 

1030 limit: int = 50, 

1031 ) -> list[tuple[str, int]]: 

1032 """PR2.6 / agentic counting tool: list distinct entity names in a 

1033 document with their per-section mention counts. 

1034 

1035 ``pattern`` is a SQL ILIKE substring (case-insensitive) — pass 

1036 ``"doctor"`` to match ``"Dr. Smith"``, ``"%kit%"`` to match any 

1037 entity containing ``"kit"``. ``None`` returns the top-``limit`` 

1038 entities by mention count (useful when the agent doesn't yet 

1039 know what to look for). 

1040 

1041 Returns ``[(entity_name, count), ...]`` ordered by count desc 

1042 then name asc. ``count`` is the number of distinct sections in 

1043 which the entity appears (the (document_id, line_num, 

1044 entity_name) PK in 015 means one section can mention an entity 

1045 at most once). 

1046 

1047 Used by the reflect agent's ``list_entities`` tool to count 

1048 across-session mentions deterministically — fixes the LME 

1049 multi-session counting holes from the PR2.6 gate where the LLM 

1050 couldn't reliably aggregate entity mentions from raw section 

1051 text.""" 

1052 

1053 

1054class PageIndexLifecycle(Protocol): 

1055 """SPI versioning + health. 

1056 

1057 A narrow role slice of the section-recall SPI. Consumers should depend on 

1058 the narrowest role they use; :class:`PageIndexStore` is the union of all 

1059 roles, retained for implementers and existing annotations. 

1060 """ 

1061 

1062 SPI_VERSION: ClassVar[int] = 1 

1063 

1064 async def health(self) -> HealthStatus: 

1065 """Check storage connectivity.""" 

1066 

1067 

1068# --------------------------------------------------------------------------- 

1069# Tier 1: Mental Model Store (first-class — see Hindsight comparison) 

1070# --------------------------------------------------------------------------- 

1071 

1072 

1073@runtime_checkable 

1074 

1075 

1076class PageIndexStore( 

1077 PageIndexDocumentStore, 

1078 PageIndexSectionWriter, 

1079 PageIndexSectionSearch, 

1080 PageIndexFactStore, 

1081 PageIndexWikiStore, 

1082 PageIndexLifecycle, 

1083 Protocol, 

1084): 

1085 """SPI for the section recall store (M9). Optional. 

1086 

1087 Backs the three-layer recall stack defined in 

1088 ``docs/_design/recall.md``. Holds: 

1089 

1090 - ``PageIndexDocument`` — one row per conversation/document, with the 

1091 canonical markdown that the picker slices for synth excerpts. 

1092 - ``PageIndexSection`` — tree nodes (the M9 recall primitive). 

1093 - ``PageIndexSectionEntity`` — Hindsight's unit_entities pattern at 

1094 section grain (PR2 commit A populates). 

1095 - ``PageIndexSectionLink`` — Hindsight's memory_links pattern at 

1096 section grain (PR2 commit D populates). 

1097 

1098 PR1 (this milestone) only exercises ``save_document`` / 

1099 ``save_sections`` / ``load_skeleton`` / ``load_document`` — the 

1100 minimum needed to port the Phase A POC picker to a Postgres-backed 

1101 cache. Entity / link methods land empty in PR1 and are populated in 

1102 PR2. 

1103 

1104 See ``astrocyte.testing.in_memory.InMemoryPageIndexStore`` for the 

1105 reference fixture; ``astrocyte_postgres.PostgresPageIndexStore`` for 

1106 the production backend. 

1107 """ 

1108 

1109 

1110class MentalModelStore(Protocol): 

1111 """SPI for first-class mental-model storage. 

1112 

1113 Mental models are curated, refreshable saved-reflect summaries — the 

1114 "Caroline prefers async updates" / "Project X status: blocked on 

1115 review" durable artifacts that live longer than any single recall. 

1116 

1117 Previously these piggybacked on :class:`WikiStore` via 

1118 ``kind="concept"`` + ``metadata["_mental_model"] = True``, which 

1119 overloaded the wiki layer's lifecycle (revisions, lint issues, 

1120 cross_links) for a fundamentally different concept. This SPI breaks 

1121 them out into their own table with their own lifecycle and version 

1122 history. 

1123 

1124 See ``astrocyte.testing.in_memory.InMemoryMentalModelStore`` for a 

1125 reference and ``astrocyte_postgres.PostgresMentalModelStore`` for the 

1126 production-grade implementation. 

1127 """ 

1128 

1129 SPI_VERSION: ClassVar[int] = 1 

1130 

1131 async def upsert(self, model: "MentalModel", bank_id: str) -> int: 

1132 """Create or refresh a mental model. Upsert semantics — if a 

1133 model with the same ``model_id`` exists in this bank, the 

1134 revision is incremented and the new content replaces the 

1135 current; the previous revision is archived (not deleted). 

1136 

1137 Args: 

1138 model: The mental model to store. ``revision`` and 

1139 ``refreshed_at`` are assigned by the store. 

1140 bank_id: Tenant-scoped bank identifier. 

1141 

1142 Returns: 

1143 The new revision number. 

1144 """ 

1145 pass 

1146 

1147 async def get(self, model_id: str, bank_id: str) -> "MentalModel | None": 

1148 """Retrieve the current revision of a mental model. 

1149 

1150 Returns ``None`` if the model doesn't exist or has been deleted. 

1151 """ 

1152 pass 

1153 

1154 async def list( 

1155 self, 

1156 bank_id: str, 

1157 *, 

1158 scope: str | None = None, 

1159 kind: str | None = None, 

1160 ) -> list["MentalModel"]: 

1161 """List current-revision mental models in a bank. 

1162 

1163 Args: 

1164 bank_id: Tenant-scoped bank identifier. 

1165 scope: If set, return only models with matching scope 

1166 (e.g. ``"person:alice"``, or the default ``"bank"``). 

1167 kind: M14.6 — if set, return only models of the given 

1168 sub-type (``"general"`` | ``"preference"``). When 

1169 ``None``, returns all kinds. 

1170 """ 

1171 pass 

1172 

1173 async def delete(self, model_id: str, bank_id: str) -> bool: 

1174 """Soft-delete a mental model. 

1175 

1176 Returns ``True`` if the model was found and deleted, ``False`` if 

1177 it didn't exist or was already deleted. Implementations may keep 

1178 the row for audit (deleted_at timestamp) or hard-delete; either 

1179 is acceptable as long as ``get`` returns ``None`` afterward. 

1180 """ 

1181 pass 

1182 

1183 async def update_via_ops( 

1184 self, 

1185 model_id: str, 

1186 bank_id: str, 

1187 operations_json: list[dict], 

1188 ) -> "tuple[int, dict] | None": 

1189 """M21 — apply structured delta operations to a mental model. 

1190 

1191 Loads the current model, applies the operations against its 

1192 ``structured_doc`` via 

1193 :func:`astrocyte.pipeline.delta_ops.apply_operations`, 

1194 re-renders ``content`` from the new doc, and upserts the 

1195 result. Lazy-migrates legacy rows whose ``structured_doc`` is 

1196 ``None`` by parsing the raw ``content`` markdown on first 

1197 refresh. 

1198 

1199 Args: 

1200 model_id: The model to modify. 

1201 bank_id: Tenant-scoped bank identifier. 

1202 operations_json: List of JSON-shaped op dicts (each 

1203 matching one of the :class:`Operation` variants in 

1204 :mod:`astrocyte.pipeline.delta_ops`). Schema-invalid 

1205 ops are dropped by the operation parser; per-op 

1206 validation drops ops that reference unknown sections. 

1207 

1208 Returns: 

1209 ``(new_revision, applied_delta_summary)`` on success. 

1210 ``None`` when the model doesn't exist. 

1211 The summary is the audit log from 

1212 :class:`~astrocyte.pipeline.delta_ops.AppliedDelta` — 

1213 ``{"applied": [...], "skipped": [...], "changed": bool}`` 

1214 — useful for telemetry / debugging the LLM's output. 

1215 

1216 Conservative-failure contract: invalid ops drop with a logged 

1217 reason; the document never gets worse than its input. 

1218 """ 

1219 pass 

1220 

1221 async def refresh( 

1222 self, 

1223 model_id: str, 

1224 bank_id: str, 

1225 new_source_ids: list[str], 

1226 ) -> "MentalModel | None": 

1227 """M28 — re-derive a mental model from an expanded source set. 

1228 

1229 Hindsight parity: after retain adds new memories that pertain to 

1230 an existing mental model, ``refresh`` re-runs the compile against 

1231 the extended ``source_ids`` and bumps the revision. The new 

1232 sources are merged into the existing ``source_ids`` (deduped, 

1233 order-preserving for existing entries). 

1234 

1235 Args: 

1236 model_id: The model to refresh. 

1237 bank_id: Tenant-scoped bank identifier. 

1238 new_source_ids: Source-memory ids to fold into the model's 

1239 provenance. Duplicates of existing ids are deduped. 

1240 

1241 Returns: 

1242 The refreshed :class:`MentalModel` (post-upsert) with the 

1243 new revision number and merged source ids. ``None`` if the 

1244 model doesn't exist. 

1245 

1246 Future enhancement: the production implementation will invoke 

1247 the LLM compile pipeline against the merged source set; the 

1248 current SPI provides the surface so callers (gateway, MCP) 

1249 can wire the refresh flow today. 

1250 """ 

1251 pass 

1252 

1253 async def health(self) -> HealthStatus: 

1254 """Check storage connectivity.""" 

1255 pass 

1256 

1257 

1258# --------------------------------------------------------------------------- 

1259# Tier 1: Source Store (M10 — documents + chunks normalisation) 

1260# --------------------------------------------------------------------------- 

1261 

1262 

1263@runtime_checkable 

1264class SourceStore(Protocol): 

1265 """SPI for source-document and chunk storage. 

1266 

1267 Backs the three-layer hierarchy ``SourceDocument → SourceChunk → 

1268 VectorItem`` so retained memories can preserve provenance back to 

1269 the originating document. Optional: deployments without a SourceStore 

1270 keep using the prior flat-vectors retain path. 

1271 

1272 See ``astrocyte.testing.in_memory.InMemorySourceStore`` for a 

1273 reference and ``astrocyte_postgres.PostgresSourceStore`` for the 

1274 production-grade implementation. 

1275 """ 

1276 

1277 SPI_VERSION: ClassVar[int] = 1 

1278 

1279 async def store_document(self, document: "SourceDocument") -> str: 

1280 """Create or update a source document. 

1281 

1282 Returns the stored ``document.id``. Implementations should treat 

1283 ``content_hash`` as the dedup key when set: a second call with 

1284 the same ``(bank_id, content_hash)`` should NOT create a 

1285 duplicate row, but instead return the existing id. 

1286 """ 

1287 pass 

1288 

1289 async def get_document( 

1290 self, 

1291 document_id: str, 

1292 bank_id: str, 

1293 ) -> "SourceDocument | None": 

1294 """Retrieve a source document by id. Returns ``None`` if missing 

1295 or soft-deleted.""" 

1296 pass 

1297 

1298 async def find_document_by_hash( 

1299 self, 

1300 content_hash: str, 

1301 bank_id: str, 

1302 ) -> "SourceDocument | None": 

1303 """Look up an existing document by its content hash for dedup 

1304 before re-ingest.""" 

1305 pass 

1306 

1307 async def list_documents( 

1308 self, 

1309 bank_id: str, 

1310 *, 

1311 limit: int = 100, 

1312 ) -> list["SourceDocument"]: 

1313 """List source documents in a bank, newest first.""" 

1314 pass 

1315 

1316 async def delete_document(self, document_id: str, bank_id: str) -> bool: 

1317 """Soft-delete a document. Cascades to its chunks via the 

1318 underlying schema. Does NOT cascade to vectors that reference 

1319 the chunks (those use their own ``forgotten_at`` lifecycle and 

1320 keep their text — the source is just no longer linkable). 

1321 

1322 Returns ``True`` if the document was found and deleted, 

1323 ``False`` if it didn't exist or was already deleted. 

1324 """ 

1325 pass 

1326 

1327 async def store_chunks(self, chunks: list["SourceChunk"]) -> list[str]: 

1328 """Bulk-insert chunks. Returns their ids in input order. 

1329 

1330 Dedup: a chunk with a ``(bank_id, content_hash)`` pair that 

1331 already exists must NOT create a duplicate; the returned id 

1332 should be the existing chunk's id. 

1333 """ 

1334 pass 

1335 

1336 async def get_chunk(self, chunk_id: str, bank_id: str) -> "SourceChunk | None": 

1337 """Retrieve a chunk by id. Returns ``None`` if missing.""" 

1338 pass 

1339 

1340 async def list_chunks( 

1341 self, 

1342 document_id: str, 

1343 bank_id: str, 

1344 ) -> list["SourceChunk"]: 

1345 """List all chunks of a document, ordered by ``chunk_index``.""" 

1346 pass 

1347 

1348 async def find_chunk_by_hash( 

1349 self, 

1350 content_hash: str, 

1351 bank_id: str, 

1352 ) -> "SourceChunk | None": 

1353 """Lookup an existing chunk by its content hash for dedup 

1354 before re-storing.""" 

1355 pass 

1356 

1357 async def health(self) -> HealthStatus: 

1358 """Check storage connectivity.""" 

1359 pass 

1360 

1361 

1362# --------------------------------------------------------------------------- 

1363# Engine Provider 

1364# --------------------------------------------------------------------------- 

1365 

1366 

1367@runtime_checkable 

1368class EngineProvider(Protocol): 

1369 """SPI for full-stack memory engines. 

1370 

1371 Engine providers own the entire memory pipeline — embedding, storage, 

1372 retrieval, and synthesis. Astrocyte delegates retain/recall/reflect 

1373 to the engine but still enforces its policy layer (PII, rate limits, 

1374 access control) around every call. 

1375 

1376 Examples: Mem0, Zep, Mystique/Hindsight. 

1377 """ 

1378 

1379 SPI_VERSION: ClassVar[int] = 1 

1380 

1381 def capabilities(self) -> EngineCapabilities: 

1382 """Declare what this engine supports (reflect, forget, etc.). 

1383 

1384 Called once at init. Astrocyte uses this to decide which operations 

1385 to delegate vs. handle internally. 

1386 """ 

1387 pass 

1388 

1389 async def health(self) -> HealthStatus: 

1390 """Liveness and readiness check.""" 

1391 pass 

1392 

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

1394 """Store content into memory. The engine decides how to chunk, 

1395 embed, and index the content. 

1396 """ 

1397 pass 

1398 

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

1400 """Retrieve relevant memories for a query. The engine owns 

1401 the retrieval strategy (semantic, keyword, graph, hybrid). 

1402 """ 

1403 pass 

1404 

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

1406 """Synthesize an answer from memory. Optional — only called if 

1407 ``capabilities().supports_reflect`` is True. 

1408 """ 

1409 pass 

1410 

1411 async def forget(self, request: ForgetRequest) -> ForgetResult: 

1412 """Remove or archive memories. Optional — only called if 

1413 ``capabilities().supports_forget`` is True. 

1414 """ 

1415 pass 

1416 

1417 

1418# --------------------------------------------------------------------------- 

1419# LLM Provider 

1420# --------------------------------------------------------------------------- 

1421 

1422 

1423@runtime_checkable 

1424class LLMProvider(Protocol): 

1425 """SPI for LLM access needed by the Astrocyte core pipeline. 

1426 

1427 Storage-pipeline deployments require an LLMProvider for embedding 

1428 generation and (optionally) for reflect-time synthesis. Engine 

1429 providers typically bring their own LLM access, but Astrocyte may 

1430 still use this for MIP intent routing or policy evaluation. 

1431 """ 

1432 

1433 SPI_VERSION: ClassVar[int] = 1 

1434 

1435 async def complete( 

1436 self, 

1437 messages: list[Message], 

1438 model: str | None = None, 

1439 max_tokens: int = 1024, 

1440 temperature: float = 0.0, 

1441 tools: list[ToolDefinition] | None = None, 

1442 tool_choice: str | None = None, 

1443 response_format: dict | None = None, 

1444 ) -> Completion: 

1445 """Generate a text completion from a message sequence. 

1446 

1447 Used by reflect, MIP intent routing, and consolidation summarization. 

1448 

1449 ``tools`` (optional) — when provided, the provider passes them as 

1450 native function-calling tools to the underlying API and the 

1451 returned :class:`Completion`'s ``tool_calls`` field carries any 

1452 invocations the model emitted. ``tool_choice`` may be ``"auto"`` 

1453 (default when tools is set), ``"required"`` (force a tool call), 

1454 or a specific tool name. Providers that don't support tools 

1455 SHOULD ignore both args and return ``tool_calls=None`` so callers 

1456 can feature-detect. 

1457 

1458 ``response_format`` (optional) — a provider-specific structured- 

1459 output spec passed straight through to the underlying API. For 

1460 OpenAI this is the ``response_format`` request body parameter 

1461 (e.g. ``{"type": "json_schema", "json_schema": {"name": "facts", 

1462 "schema": {...}, "strict": True}}``). When set, the provider 

1463 constrains the model's output to the schema at decode time — 

1464 callers that need malformed-JSON-free responses (e.g. structured 

1465 fact extraction) should set this. Providers that don't support 

1466 structured outputs SHOULD ignore the arg and return their normal 

1467 text completion so callers can feature-detect by parsing. 

1468 """ 

1469 pass 

1470 

1471 async def embed( 

1472 self, 

1473 texts: list[str], 

1474 model: str | None = None, 

1475 ) -> list[list[float]]: 

1476 """Generate embedding vectors for the given texts. 

1477 

1478 Used by the Tier 1 retain pipeline to embed content before storage. 

1479 

1480 Raises: 

1481 NotImplementedError: If this provider does not support embeddings 

1482 (e.g., a local model without an embedding endpoint). 

1483 """ 

1484 pass 

1485 

1486 def capabilities(self) -> LLMCapabilities: 

1487 """Declare LLM capabilities (supported models, embedding dimensions, etc.).""" 

1488 pass 

1489 

1490 

1491# --------------------------------------------------------------------------- 

1492# Outbound Transport Provider (optional, cross-cutting) 

1493# --------------------------------------------------------------------------- 

1494 

1495 

1496@runtime_checkable 

1497class OutboundTransportProvider(Protocol): 

1498 """SPI for credential gateways and enterprise HTTP/TLS proxies. 

1499 

1500 Optional, cross-cutting provider. When registered, Astrocyte calls 

1501 ``apply()`` on every outbound HTTP client context (e.g., to inject 

1502 auth headers, configure mTLS, or route through a corporate proxy). 

1503 """ 

1504 

1505 SPI_VERSION: ClassVar[int] = 1 

1506 

1507 def apply(self, ctx: HttpClientContext) -> None: 

1508 """Configure the HTTP client context before an outbound request. 

1509 

1510 Typical uses: inject Bearer tokens, set proxy URLs, configure TLS 

1511 client certificates. 

1512 """ 

1513 pass 

1514 

1515 def subprocess_env(self) -> dict[str, str]: 

1516 """Return environment variables for subprocess calls that need 

1517 the same transport configuration (e.g., ``HTTPS_PROXY``, auth tokens). 

1518 """ 

1519 pass 

1520 

1521 def capabilities(self) -> TransportCapabilities: 

1522 """Declare transport capabilities (proxy support, mTLS, etc.).""" 

1523 pass 

1524 

1525 

1526# --------------------------------------------------------------------------- 

1527# SPI Version Negotiation 

1528# --------------------------------------------------------------------------- 

1529 

1530# Supported SPI versions per protocol 

1531_SUPPORTED_VERSIONS: dict[str, set[int]] = { 

1532 "VectorStore": {1}, 

1533 "GraphStore": {1}, 

1534 "DocumentStore": {1}, 

1535 "WikiStore": {1}, 

1536 "MentalModelStore": {1}, 

1537 "SourceStore": {1}, 

1538 "EngineProvider": {1}, 

1539 "LLMProvider": {1}, 

1540 "OutboundTransportProvider": {1}, 

1541} 

1542 

1543 

1544def check_spi_version(provider: object, protocol_name: str) -> int: 

1545 """Validate a provider's SPI_VERSION against supported versions. 

1546 

1547 Returns the provider's version if accepted. 

1548 Raises ConfigError if the version is unsupported. 

1549 """ 

1550 from astrocyte.errors import ConfigError 

1551 

1552 version = getattr(provider, "SPI_VERSION", None) 

1553 if version is None: 

1554 # No version declared — assume v1 for backwards compatibility 

1555 return 1 

1556 

1557 supported = _SUPPORTED_VERSIONS.get(protocol_name, {1}) 

1558 if version not in supported: 

1559 raise ConfigError( 

1560 f"{protocol_name} SPI version {version} is not supported. Supported versions: {sorted(supported)}" 

1561 ) 

1562 return version