Coverage for astrocyte/provider.py: 69%
184 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
1"""Astrocyte provider protocols — the SPIs that backends implement.
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.).
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``
12Each protocol has a SPI_VERSION ClassVar for compatibility checking.
13All methods are async except capabilities() and transport methods.
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"""
21from __future__ import annotations
23from datetime import datetime
24from typing import TYPE_CHECKING, ClassVar, Protocol, runtime_checkable
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 )
70# ---------------------------------------------------------------------------
71# Tier 1: Storage Providers
72# ---------------------------------------------------------------------------
75@runtime_checkable
76class VectorStore(Protocol):
77 """SPI for vector database adapters. Required for Tier 1.
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.
84 See ``astrocyte_postgres.store.PostgresStore`` for a production example,
85 or ``astrocyte.testing.in_memory.InMemoryVectorStore`` for a minimal reference.
86 """
88 SPI_VERSION: ClassVar[int] = 1
90 async def store_vectors(self, items: list[VectorItem]) -> list[str]:
91 """Persist vectors with metadata. Upsert semantics — existing IDs are overwritten.
93 Implementations must validate that each item's vector length matches the
94 configured embedding dimensions.
96 Returns:
97 List of stored IDs (same order as ``items``). If ``items`` is empty,
98 implementations must treat this as a no-op and return an empty list.
100 Raises:
101 ValueError: If any vector's length does not match embedding dimensions.
102 """
103 pass
105 async def search_similar(
106 self,
107 query_vector: list[float],
108 bank_id: str,
109 limit: int = 10,
110 filters: VectorFilters | None = None,
111 ) -> list[VectorHit]:
112 """Find vectors most similar to ``query_vector`` within a bank.
114 Returns:
115 Hits sorted by similarity descending. Each hit's ``score`` must be
116 in the range [0.0, 1.0] where 1.0 is an exact match. Returns an
117 empty list if ``bank_id`` does not exist.
119 Raises:
120 ValueError: If ``query_vector`` length does not match embedding dimensions.
121 """
122 pass
124 async def delete(self, ids: list[str], bank_id: str) -> int:
125 """Delete vectors by ID within a bank. Bank isolation is required —
126 an ID in ``bank-2`` must not be deleted when ``bank_id`` is ``bank-1``.
128 Returns:
129 Count of vectors actually deleted. Returns 0 for empty ``ids`` list.
130 """
131 pass
133 async def list_vectors(
134 self,
135 bank_id: str,
136 offset: int = 0,
137 limit: int = 100,
138 ) -> list[VectorItem]:
139 """List vectors in a bank with pagination. Used by consolidation
140 to scan and deduplicate vectors.
142 Implementations must return vectors in a **stable order** (e.g., by ID)
143 so that pagination produces consistent, complete results.
145 Returns:
146 Up to ``limit`` vectors starting at ``offset``. Empty list if
147 the bank does not exist or ``offset`` is past the end.
148 """
149 pass
151 async def health(self) -> HealthStatus:
152 """Check database connectivity.
154 Returns:
155 ``HealthStatus(healthy=True, ...)`` if the database is reachable,
156 ``HealthStatus(healthy=False, ...)`` with a diagnostic message otherwise.
157 """
158 pass
161@runtime_checkable
162class GraphStore(Protocol):
163 """SPI for graph database adapters. Optional for Tier 1.
165 Implement this protocol to add entity/relationship-based retrieval
166 alongside vector search. Astrocyte's multi-strategy retrieval pipeline
167 will query both VectorStore and GraphStore in parallel when available.
168 """
170 SPI_VERSION: ClassVar[int] = 1
172 async def store_entities(self, entities: list[Entity], bank_id: str) -> list[str]:
173 """Persist or update entities (people, concepts, objects) in the graph.
175 Returns:
176 List of stored entity IDs.
177 """
178 pass
180 async def store_links(self, links: list[EntityLink], bank_id: str) -> list[str]:
181 """Persist relationships between entities.
183 Returns:
184 List of stored link IDs.
185 """
186 pass
188 async def link_memories_to_entities(
189 self,
190 associations: list[MemoryEntityAssociation],
191 bank_id: str,
192 ) -> None:
193 """Associate memory IDs (from VectorStore) with entity IDs in the graph.
195 This enables graph-based recall: "find memories connected to entity X."
196 """
197 pass
199 async def store_memory_links(
200 self,
201 links: list["MemoryLink"],
202 bank_id: str,
203 ) -> list[str]:
204 """Persist directional memory-to-memory links (Hindsight parity).
206 Used for causal chains (``caused_by``) and the precomputed
207 semantic kNN graph (``semantic``). Adapters that don't
208 implement this should silently no-op (return ``[]``) — the
209 spread / link-expansion path probes via ``getattr`` and
210 degrades to entity-only when the method is absent.
212 Returns the list of stored link IDs.
213 """
214 return []
216 async def find_memory_links(
217 self,
218 seed_memory_ids: list[str],
219 bank_id: str,
220 *,
221 link_types: list[str] | None = None,
222 limit: int = 200,
223 ) -> list["MemoryLink"]:
224 """Return memory links touching any seed (either direction).
226 Adapter contract: results SHOULD include links where the seed
227 is the source OR the target — the link-expansion retrieval
228 treats both directions for ``semantic`` and only the target
229 direction for ``caused_by`` (effect → cause). Callers filter
230 as needed.
232 Adapters that haven't implemented this return ``[]``; the
233 link-expansion retrieval probes via ``getattr`` and skips the
234 memory-link signal when the method is absent.
235 """
236 return []
238 async def query_neighbors(
239 self,
240 entity_ids: list[str],
241 bank_id: str,
242 max_depth: int = 2,
243 limit: int = 20,
244 ) -> list[GraphHit]:
245 """Traverse the graph from ``entity_ids`` up to ``max_depth`` hops
246 and return connected memories.
248 Returns:
249 Graph hits sorted by relevance (e.g., shortest path, edge weight).
250 """
251 pass
253 async def query_entities(
254 self,
255 query: str,
256 bank_id: str,
257 limit: int = 10,
258 ) -> list[Entity]:
259 """Search entities by name or alias (fuzzy or exact match).
261 Returns:
262 Matching entities, useful for resolving entity references before
263 calling ``query_neighbors``.
264 """
265 pass
267 async def find_entity_candidates(
268 self,
269 name: str,
270 bank_id: str,
271 threshold: float = 0.8,
272 limit: int = 5,
273 ) -> list[Entity]:
274 """Return entities whose name is similar to *name* above *threshold*.
276 Used by the entity resolution pipeline (M11) to find candidate
277 entities that may be aliases of a newly-extracted entity before
278 calling the LLM confirmation step.
280 A simple implementation may use case-insensitive substring matching;
281 production adapters should use vector similarity or full-text search.
283 Returns:
284 Candidate entities ordered by similarity descending.
285 """
286 pass
288 async def find_entity_candidates_scored(
289 self,
290 name: str,
291 bank_id: str,
292 *,
293 name_embedding: list[float] | None = None,
294 trigram_threshold: float = 0.3,
295 limit: int = 10,
296 ) -> list[EntityCandidateMatch]:
297 """Return scored entity candidates for the Hindsight entity-resolution cascade.
299 Scores each candidate against two cheap signals:
301 - ``name_similarity`` — trigram similarity between the candidate's
302 name and ``name`` in ``[0.0, 1.0]``. Production adapters use
303 ``pg_trgm.similarity()``; the in-memory adapter approximates with
304 :class:`difflib.SequenceMatcher`.
305 - ``embedding_similarity`` — cosine similarity between the
306 candidate's stored embedding and ``name_embedding``, or ``None``
307 when either side has no embedding.
309 The resolver uses these scores to autolink, skip, or escalate to LLM
310 without further round-trips.
312 Args:
313 name: Surface form to resolve.
314 bank_id: Bank scope.
315 name_embedding: Optional embedding of ``name`` for the embedding
316 tier. When ``None``, ``embedding_similarity`` is ``None`` for
317 every result and the cascade falls back to trigram + LLM.
318 trigram_threshold: Adapter-side prefilter — candidates with a
319 trigram similarity strictly below this value are dropped.
320 Default ``0.3`` matches PostgreSQL's ``pg_trgm.similarity_threshold``.
321 limit: Maximum candidates returned, ordered by descending score.
323 Returns:
324 ``list[EntityCandidateMatch]`` ordered by best score first.
325 Defaults to an empty list when no candidates clear the threshold.
326 """
327 pass
329 async def store_entity_link(self, link: EntityLink, bank_id: str) -> str:
330 """Persist a single typed relationship between two entities.
332 Unlike ``store_links`` (bulk, co-occurrence), this method is called
333 by the entity resolution pipeline for confirmed alias links that carry
334 ``evidence`` and ``confidence``.
336 Returns:
337 The stored link ID.
338 """
339 pass
341 async def health(self) -> HealthStatus:
342 """Check database connectivity."""
343 pass
346@runtime_checkable
347class DocumentStore(Protocol):
348 """SPI for document storage with full-text search. Optional for Tier 1.
350 Implement this protocol to add BM25/lexical search alongside vector
351 similarity. Useful for keyword-heavy queries where semantic search
352 alone may miss exact matches.
353 """
355 SPI_VERSION: ClassVar[int] = 1
357 async def store_document(self, document: Document, bank_id: str) -> str:
358 """Persist a source document for full-text indexing.
360 Returns:
361 The stored document ID.
362 """
363 pass
365 async def search_fulltext(
366 self,
367 query: str,
368 bank_id: str,
369 limit: int = 10,
370 filters: DocumentFilters | None = None,
371 ) -> list[DocumentHit]:
372 """BM25 full-text search over stored content.
374 Returns:
375 Hits sorted by BM25 relevance score descending.
376 """
377 pass
379 async def get_document(self, document_id: str, bank_id: str) -> Document | None:
380 """Retrieve a single document by ID.
382 Returns:
383 The document, or ``None`` if not found in this bank.
384 """
385 pass
387 async def health(self) -> HealthStatus:
388 """Check database connectivity."""
389 pass
392# ---------------------------------------------------------------------------
393# Tier 1: Wiki Store (M8)
394# ---------------------------------------------------------------------------
397@runtime_checkable
398class WikiStore(Protocol):
399 """SPI for wiki page storage (M8 LLM wiki compile). Optional.
401 WikiStore persists structured WikiPage metadata. Vector embeddings of
402 compiled pages are stored separately in the VectorStore with
403 ``memory_layer="compiled"`` and ``fact_type="wiki"``, so recall tiering
404 can search them via the standard ``search_similar`` path.
406 Implement this protocol to enable ``brain.compile()`` persistence.
407 See ``astrocyte.testing.in_memory.InMemoryWikiStore`` for a reference.
408 """
410 SPI_VERSION: ClassVar[int] = 1
412 async def upsert_page(self, page: WikiPage, bank_id: str) -> str:
413 """Create or update a wiki page. Upsert semantics — if a page with
414 the same ``page_id`` exists in this bank, its revision is incremented
415 and content replaced. The previous revision is archived (not deleted).
417 Returns:
418 The stored ``page_id``.
419 """
420 pass
422 async def get_page(self, page_id: str, bank_id: str) -> WikiPage | None:
423 """Retrieve the current revision of a wiki page by ID.
425 Returns:
426 The page, or ``None`` if not found in this bank.
427 """
428 pass
430 async def list_pages(
431 self,
432 bank_id: str,
433 scope: str | None = None,
434 kind: str | None = None,
435 ) -> list[WikiPage]:
436 """List current-revision wiki pages for a bank.
438 Args:
439 bank_id: The bank to list pages for.
440 scope: If set, return only pages whose ``scope`` matches exactly.
441 kind: If set, return only pages of this kind ("entity", "topic", "concept").
443 Returns:
444 All matching pages, unsorted.
445 """
446 pass
448 async def delete_page(self, page_id: str, bank_id: str) -> bool:
449 """Delete a wiki page (current revision and audit log).
451 Returns:
452 ``True`` if the page was found and deleted, ``False`` if not found.
453 """
454 pass
456 async def health(self) -> HealthStatus:
457 """Check storage connectivity."""
458 pass
461# ---------------------------------------------------------------------------
462# Section recall (M9): PageIndex tree + section graph store
463# ---------------------------------------------------------------------------
466@runtime_checkable
467class PageIndexStore(Protocol):
468 """SPI for the section recall store (M9). Optional.
470 Backs the three-layer recall stack defined in
471 ``docs/_design/recall.md``. Holds:
473 - ``PageIndexDocument`` — one row per conversation/document, with the
474 canonical markdown that the picker slices for synth excerpts.
475 - ``PageIndexSection`` — tree nodes (the M9 recall primitive).
476 - ``PageIndexSectionEntity`` — Hindsight's unit_entities pattern at
477 section grain (PR2 commit A populates).
478 - ``PageIndexSectionLink`` — Hindsight's memory_links pattern at
479 section grain (PR2 commit D populates).
481 PR1 (this milestone) only exercises ``save_document`` /
482 ``save_sections`` / ``load_skeleton`` / ``load_document`` — the
483 minimum needed to port the Phase A POC picker to a Postgres-backed
484 cache. Entity / link methods land empty in PR1 and are populated in
485 PR2.
487 See ``astrocyte.testing.in_memory.InMemoryPageIndexStore`` for the
488 reference fixture; ``astrocyte_postgres.PostgresPageIndexStore`` for
489 the production backend.
490 """
492 SPI_VERSION: ClassVar[int] = 1
494 async def save_document(self, doc: PageIndexDocument) -> str:
495 """Upsert a document. Returns the document id (UUID string).
497 Upsert keyed on ``(bank_id, source_id)`` — re-running tree-build
498 for the same conversation replaces the prior row.
499 """
501 async def save_sections(
502 self,
503 document_id: str,
504 sections: list[PageIndexSection],
505 ) -> int:
506 """Bulk-replace all sections for a document. Returns the count
507 written. Existing rows for ``document_id`` are deleted first
508 (the tree rebuild is treated as atomic — no partial trees)."""
510 async def load_document(
511 self,
512 bank_id: str,
513 source_id: str,
514 ) -> PageIndexDocument | None:
515 """Fetch one document by (bank_id, source_id). Returns ``None``
516 when not found (caller decides whether to build it)."""
518 async def load_skeleton(self, document_id: str) -> list[PageIndexSection]:
519 """Fetch all sections for a document, ordered by ``line_num``.
521 Returned sections do NOT carry ``summary_embedding`` (that field
522 is only used by the semantic strategy in PR2; the picker doesn't
523 need it). Implementations may project it out for cheaper reads.
524 """
526 async def save_section_embeddings(
527 self,
528 document_id: str,
529 embeddings: list[tuple[int, list[float]]],
530 ) -> int:
531 """PR2 commit A: bulk-update ``summary_embedding`` on existing
532 section rows. Skips rows whose ``line_num`` doesn't already
533 exist (the tree-build step is the source of truth for which
534 sections exist). Returns rows updated."""
536 async def save_section_entities(
537 self,
538 entities: list[PageIndexSectionEntity],
539 ) -> int:
540 """Bulk-insert entity-mention rows. Idempotent on the composite
541 primary key. Returns rows written. PR2 commit A populates."""
543 async def save_section_links(
544 self,
545 links: list[PageIndexSectionLink],
546 ) -> int:
547 """Bulk-insert section-link rows. Idempotent on the composite
548 primary key. Returns rows written. PR2 commit B/D populates."""
550 async def populate_semantic_knn_links(
551 self,
552 document_id: str,
553 *,
554 top_k: int = 5,
555 min_similarity: float = 0.5,
556 ) -> int:
557 """PR2 D.7.1: Populate ``section_links`` with kNN edges over
558 ``summary_embedding``. For each section, link to its top_k
559 most-similar OTHER sections in the same document with
560 ``link_type='semantic_knn'`` and weight = cosine similarity.
562 Pure SQL — no LLM call. Idempotent. Returns rows inserted.
564 Why this exists: D.7's LLM-based link extraction over-emits on
565 LoCoMo and severely under-emits on LME (5 vs 100 links/doc).
566 kNN restores dense topical bridging for graph_expand."""
568 # ── PR2 commit B: query methods for the 5 parallel strategies ──
569 #
570 # These are pure read methods that the section recall orchestrator
571 # (``astrocyte.pipeline.section_recall``) calls in parallel. Each
572 # returns a ranked list of ``(document_id, line_num, score)``
573 # tuples; the orchestrator fuses them via RRF.
574 #
575 # Single-bank scoping for now (PR2). Cross-bank scoping comes when
576 # multi-bank section recall ships in M10+.
578 async def search_sections_semantic(
579 self,
580 bank_id: str,
581 query_embedding: list[float],
582 *,
583 top_k: int = 20,
584 session_filter: str | None = None,
585 ) -> list[tuple[str, int, float]]:
586 """PR2 commit B / semantic strategy: cosine-similarity over
587 ``summary_embedding``. Returns ``(document_id, line_num, score)``
588 tuples, ordered by similarity desc. Score is in [0, 1] (1 - distance).
590 M31 Fix 2: ``session_filter`` (opaque str) scopes results to
591 sections whose ``session_id`` matches. ``None`` (default)
592 retrieves cross-session.
593 """
595 async def search_sections_keyword(
596 self,
597 bank_id: str,
598 query: str,
599 *,
600 top_k: int = 20,
601 speaker: str | None = None,
602 document_id: str | None = None,
603 session_filter: str | None = None,
604 ) -> list[tuple[str, int, float]]:
605 """PR2 commit B / keyword strategy: full-text search (``tsvector``)
606 over section titles + summaries. Returns ``(document_id,
607 line_num, score)`` tuples. The optional ``speaker`` filter is
608 for LME assistant-recall (``WHERE speaker = 'assistant'``).
609 The optional ``document_id`` filter (PR2.6) scopes the search
610 to a single document — used by ``temporal_arithmetic.find_event_date``
611 to avoid bank-wide top-K starvation when 50+ documents share a
612 bank and the matching one isn't in the unfiltered top-5.
614 M31 Fix 2: ``session_filter`` (opaque str) scopes to sections
615 whose ``session_id`` matches."""
617 async def search_sections_by_entities(
618 self,
619 bank_id: str,
620 entity_names: list[str],
621 *,
622 top_k: int = 20,
623 session_filter: str | None = None,
624 ) -> list[tuple[str, int, float]]:
625 """PR2 commit B / entity strategy: Hindsight's CTE pattern.
626 Returns ``(document_id, line_num, score)`` where score is the
627 count of distinct matching entity_names per section.
629 M31 Fix 2: ``session_filter`` joins to sections to scope by
630 ``session_id``."""
632 async def search_sections_temporal(
633 self,
634 bank_id: str,
635 date_range: tuple[datetime, datetime],
636 *,
637 top_k: int = 20,
638 session_filter: str | None = None,
639 ) -> list[tuple[str, int, float]]:
640 """PR2 commit B / temporal strategy: date-range filter on
641 ``session_date`` (uses the partial btree index from migration
642 015). Score is uniform (1.0) — temporal is a filter, not a
643 ranker; ranking happens in fusion.
645 M31 Fix 2: ``session_filter`` scopes to sections whose
646 ``session_id`` matches."""
648 async def expand_section_links(
649 self,
650 seeds: list[tuple[str, int]],
651 *,
652 link_types: list[str] | None = None,
653 top_k: int = 20,
654 ) -> list[tuple[str, int, float]]:
655 """PR2 commit B / graph-expand strategy: 1-hop expansion through
656 ``section_links`` from the given ``(document_id, line_num)``
657 seeds. ``link_types`` filters to e.g. ['semantic_knn', 'causal'];
658 None means all types. Score is link weight."""
660 async def expand_sections_by_shared_entities(
661 self,
662 bank_id: str,
663 seeds: list[tuple[str, int]],
664 *,
665 top_k: int = 20,
666 exclude_seeds: bool = True,
667 ) -> list[tuple[str, int, float]]:
668 """Fix 3 (conv-run-4) / entity spreading activation: 1-hop
669 expansion through entity co-occurrence in
670 ``astrocyte_pi_section_entities``.
672 For each seed ``(document_id, line_num)``, find every section
673 in the same bank that shares at least one entity with the seed.
674 Returns ``(document_id, line_num, score)`` where score is the
675 count of distinct shared entities. ``exclude_seeds=True``
676 filters the seeds themselves out of the result.
678 Why this is distinct from ``expand_section_links``: links rely
679 on ``astrocyte_pi_section_links`` being populated (semantic_knn
680 and the LLM-extracted causal/elaborates edges). In conversation
681 ingest the link density is sparse, so the existing graph_expand
682 strategy can't bridge Denver → Red Rocks even when both
683 sections mention the same entity. Entity spread uses the
684 already-populated ``astrocyte_pi_section_entities`` table
685 directly, which gives a denser bridge with the same SQL cost.
686 """
688 # ── M12.1: fact-grain layer (atomic facts alongside sections) ───────
690 async def save_facts(self, facts: list["PageIndexFact"]) -> int:
691 """M12.1: persist atomic facts extracted from sections.
693 Each fact's ``embedding`` may be ``None`` (caller embeds in a
694 separate batched pass) or populated. Returns the number of rows
695 inserted. Implementations should validate that
696 ``(document_id, line_num)`` references an existing section and
697 let the FK cascade handle deletion."""
699 async def update_fact_embeddings(
700 self,
701 embeddings: list[tuple[str, list[float]]],
702 ) -> int:
703 """M12.1: write the embedding column for a batch of facts.
705 ``embeddings`` is ``[(fact_id, embedding_vector), ...]``.
706 Returns the number of rows updated. Separated from
707 :meth:`save_facts` so the bench can extract facts and embed
708 them in two parallel passes — Hindsight's retain shape."""
710 async def search_facts_semantic(
711 self,
712 bank_id: str,
713 query_embedding: list[float],
714 *,
715 top_k: int = 20,
716 document_id: str | None = None,
717 fact_type: str | None = None,
718 session_filter: str | None = None,
719 ) -> list["PageIndexFactHit"]:
720 """M12.1: cosine-similarity search over fact embeddings.
722 M31 Fix 2: ``session_filter`` scopes results to facts whose
723 anchoring section has matching ``session_id`` (EXISTS subquery
724 in the Postgres adapter, _matches_session helper in the
725 in-memory adapter). Top-level facts (no anchor) are excluded
726 when ``session_filter`` is set."""
728 async def search_facts_by_entity(
729 self,
730 bank_id: str,
731 entity_name: str,
732 *,
733 top_k: int = 50,
734 document_id: str | None = None,
735 fact_type: str | None = None,
736 session_filter: str | None = None,
737 ) -> list["PageIndexFactHit"]:
738 """M12.1: list facts whose ``entities`` array contains
739 ``entity_name`` (or a case-insensitive variant). Used by the
740 agent's counting tool — e.g. for "how many doctors" the agent
741 queries ``entity_name="role:doctor"`` and counts the rows.
743 M31 Fix 2: ``session_filter`` mirrors the semantic search
744 contract. For cross-session link-expansion usage (M27),
745 ``session_filter`` should be ``None`` (the default) so the
746 whole point of cross-session traversal is preserved.
748 M34-3: ``fact_type`` filters to a single fact_type (e.g.
749 ``'preference'``) so per-fact-type segmented retrieval can
750 produce independent candidate pools per type."""
752 async def search_facts_temporal(
753 self,
754 bank_id: str,
755 date_range: tuple[datetime, datetime],
756 *,
757 top_k: int = 50,
758 document_id: str | None = None,
759 fact_type: str | None = None,
760 session_filter: str | None = None,
761 ) -> list["PageIndexFactHit"]:
762 """M12.1: list facts whose ``occurred_start`` falls in the
763 date range. Powers temporal-arithmetic queries that previously
764 had to resort to keyword search over section summaries.
766 M31 Fix 2: ``session_filter`` scopes to one session's
767 temporal facts.
769 M34-3: ``fact_type`` filters to a single fact_type so the
770 temporal channel's per-type segment doesn't leak across types."""
772 async def search_facts_keyword(
773 self,
774 bank_id: str,
775 query: str,
776 *,
777 top_k: int = 20,
778 document_id: str | None = None,
779 fact_type: str | None = None,
780 session_filter: str | None = None,
781 ) -> list["PageIndexFactHit"]:
782 """M31c — Hindsight-parity BM25/full-text search over fact
783 text. Complements ``search_facts_semantic`` (vector cosine):
784 BM25 catches specific noun/number matches (e.g. "500 Mbps",
785 "Philips LED", "University of Melbourne") that semantic
786 embeddings tend to under-weight relative to thematically
787 similar but topically off content.
789 Implementations:
791 - Postgres adapter: ``plainto_tsquery`` + ``ts_rank_cd`` over
792 ``to_tsvector('english', fact_text)`` (computed on-the-fly;
793 no schema migration needed). Mirrors the
794 ``search_sections_keyword`` pattern that's been validated
795 since M9.
796 - InMemory adapter: token-overlap scoring (count of distinct
797 query tokens appearing in fact text), case-insensitive.
799 Returns ``(document_id, line_num, score)``-bearing
800 ``PageIndexFactHit`` records, ordered by score desc. Score is
801 rank-rather-than-similarity (BM25 magnitudes are not directly
802 comparable to vector cosine — RRF fusion in ``fact_recall``
803 handles the scale difference).
805 Diagnostic that motivated this: SSU bench failures showed the
806 cross-encoder rerank can rank a specific factoid statement
807 ("User upgraded to 500 Mbps internet plan") deep in the
808 candidate pool while surfacing thematically-related but
809 irrelevant content ("commute", "road trip") at the top.
810 BM25 catches the literal-keyword match that semantic alone
811 misses."""
813 async def count_facts_matching(
814 self,
815 bank_id: str,
816 document_id: str,
817 *,
818 entity_pattern: str | None = None,
819 fact_type: str | None = None,
820 ) -> int:
821 """M12.1: deterministic count of facts matching the given
822 filters. ``entity_pattern`` is a substring (case-insensitive)
823 matched against any element of the ``entities`` array. Used
824 by the agent's counting tool for questions like "how many
825 doctors did I visit?" — agent calls with
826 ``entity_pattern="role:doctor"`` and gets back the exact
827 count."""
829 async def save_section_event_dates(
830 self,
831 document_id: str,
832 event_dates: list[tuple[int, datetime, datetime | None]],
833 ) -> int:
834 """M11.1: update ``occurred_start`` / ``occurred_end`` on
835 existing section rows.
837 ``event_dates`` is a list of ``(line_num, start, end_or_None)``
838 tuples. Rows not in the list are left untouched. Returns the
839 number of rows updated. Used by the bench retain pipeline
840 after section_event_extraction runs."""
842 async def load_sections_with_embeddings(
843 self,
844 bank_id: str,
845 document_id: str,
846 ) -> list["PageIndexSection"]:
847 """M10.1: load sections including ``summary_embedding`` (which
848 :meth:`load_skeleton` deliberately drops as a cost optimisation).
850 Used by :func:`section_compile.compile_sections_for_document` to
851 cluster sections via DBSCAN. Returns sections in arbitrary order;
852 caller sorts as needed."""
854 async def save_wiki_page(
855 self,
856 *,
857 page: "WikiPage",
858 embedding: list[float] | None,
859 provenance: list[tuple[str, int]],
860 ) -> str:
861 """M10.1: persist one observation as a wiki page + its
862 section-grain provenance + (optional) embedding for the wiki
863 recall strategy.
865 Implementations must:
866 - Insert / upsert the wiki_page row.
867 - Insert provenance rows ``(wiki_page_id, document_id, line_num)``
868 via ``astrocyte_pi_wiki_provenance`` (migration 015).
869 - Set ``current_embedding`` on the wiki page when the column
870 exists (migration 018 adds it). Pass ``None`` to skip
871 embedding (caller does its own deferred indexing).
873 Returns the page's ``page_id``."""
875 async def search_wiki_pages_semantic(
876 self,
877 bank_id: str,
878 query_embedding: list[float],
879 *,
880 top_k: int = 5,
881 document_id: str | None = None,
882 ) -> list["WikiPageHit"]:
883 """M10.1: cosine-similarity search over
884 ``astrocyte_wiki_pages.current_embedding`` (migration 018).
886 Returns up to ``top_k`` :class:`~astrocyte.types.WikiPageHit`
887 rows ordered by similarity desc. The optional ``document_id``
888 filter scopes to a single document (the bench is per-doc; prod
889 deployments may want bank-wide)."""
891 async def count_wiki_pages_for_doc(
892 self,
893 bank_id: str,
894 document_id: str,
895 ) -> int:
896 """M10.1: idempotency check for the consolidation pass.
897 ``compile_sections_for_document`` skips work when this returns
898 > 0."""
900 async def list_wiki_pages_for_doc(
901 self,
902 bank_id: str,
903 document_id: str,
904 ) -> list["WikiPage"]:
905 """M12.6: enumerate current-revision wiki pages with
906 ``scope = 'document:<id>'``. Returns full WikiPage rows with
907 markdown content populated; used by the revision pass to load
908 existing pages, send them through an LLM update, and persist
909 bumped revisions.
911 Default implementation returns ``[]`` for stores that don't
912 yet implement wiki enumeration — the revision pass becomes a
913 no-op on those backends rather than crashing."""
914 return []
916 async def list_wikis_affected_by_entities(
917 self,
918 bank_id: str,
919 entities: list[str],
920 *,
921 min_overlap: int = 1,
922 limit: int = 8,
923 ) -> list[tuple["WikiPage", int, list[str]]]:
924 """M14.2: find existing wiki pages whose provenance sections
925 share entities with a new source, returning fully-hydrated
926 WikiPage rows + overlap stats.
928 Backs Karpathy's update-affected-wikis op. The aggregation
929 ``HAVING`` cuts the wiki set down to the small subset (~5-15
930 per source) where the entity-overlap signal is real; returning
931 full rows means callers don't need a separate ``get_page``
932 round-trip per result.
934 Args:
935 bank_id: tenant scope.
936 entities: distinct entity names from the new source.
937 min_overlap: minimum shared entities to flag a wiki as
938 affected. ``1`` is the loosest threshold.
939 limit: cap on returned rows (top-N by overlap count).
941 Returns:
942 ``[(WikiPage, overlap_count, shared_entities), ...]``
943 sorted descending by ``overlap_count``, ties broken by
944 ``page_id`` ascending for stability.
946 Default implementation returns ``[]`` for stores that haven't
947 yet been migrated — the update pass becomes a no-op on those
948 backends rather than crashing.
949 """
950 _ = bank_id, entities, min_overlap, limit # noqa: ARG002
951 return []
953 async def list_distinct_entities(
954 self,
955 bank_id: str,
956 document_id: str,
957 *,
958 pattern: str | None = None,
959 limit: int = 50,
960 ) -> list[tuple[str, int]]:
961 """PR2.6 / agentic counting tool: list distinct entity names in a
962 document with their per-section mention counts.
964 ``pattern`` is a SQL ILIKE substring (case-insensitive) — pass
965 ``"doctor"`` to match ``"Dr. Smith"``, ``"%kit%"`` to match any
966 entity containing ``"kit"``. ``None`` returns the top-``limit``
967 entities by mention count (useful when the agent doesn't yet
968 know what to look for).
970 Returns ``[(entity_name, count), ...]`` ordered by count desc
971 then name asc. ``count`` is the number of distinct sections in
972 which the entity appears (the (document_id, line_num,
973 entity_name) PK in 015 means one section can mention an entity
974 at most once).
976 Used by the reflect agent's ``list_entities`` tool to count
977 across-session mentions deterministically — fixes the LME
978 multi-session counting holes from the PR2.6 gate where the LLM
979 couldn't reliably aggregate entity mentions from raw section
980 text."""
982 async def health(self) -> HealthStatus:
983 """Check storage connectivity."""
986# ---------------------------------------------------------------------------
987# Tier 1: Mental Model Store (first-class — see Hindsight comparison)
988# ---------------------------------------------------------------------------
991@runtime_checkable
992class MentalModelStore(Protocol):
993 """SPI for first-class mental-model storage.
995 Mental models are curated, refreshable saved-reflect summaries — the
996 "Caroline prefers async updates" / "Project X status: blocked on
997 review" durable artifacts that live longer than any single recall.
999 Previously these piggybacked on :class:`WikiStore` via
1000 ``kind="concept"`` + ``metadata["_mental_model"] = True``, which
1001 overloaded the wiki layer's lifecycle (revisions, lint issues,
1002 cross_links) for a fundamentally different concept. This SPI breaks
1003 them out into their own table with their own lifecycle and version
1004 history.
1006 See ``astrocyte.testing.in_memory.InMemoryMentalModelStore`` for a
1007 reference and ``astrocyte_postgres.PostgresMentalModelStore`` for the
1008 production-grade implementation.
1009 """
1011 SPI_VERSION: ClassVar[int] = 1
1013 async def upsert(self, model: "MentalModel", bank_id: str) -> int:
1014 """Create or refresh a mental model. Upsert semantics — if a
1015 model with the same ``model_id`` exists in this bank, the
1016 revision is incremented and the new content replaces the
1017 current; the previous revision is archived (not deleted).
1019 Args:
1020 model: The mental model to store. ``revision`` and
1021 ``refreshed_at`` are assigned by the store.
1022 bank_id: Tenant-scoped bank identifier.
1024 Returns:
1025 The new revision number.
1026 """
1027 pass
1029 async def get(self, model_id: str, bank_id: str) -> "MentalModel | None":
1030 """Retrieve the current revision of a mental model.
1032 Returns ``None`` if the model doesn't exist or has been deleted.
1033 """
1034 pass
1036 async def list(
1037 self,
1038 bank_id: str,
1039 *,
1040 scope: str | None = None,
1041 kind: str | None = None,
1042 ) -> list["MentalModel"]:
1043 """List current-revision mental models in a bank.
1045 Args:
1046 bank_id: Tenant-scoped bank identifier.
1047 scope: If set, return only models with matching scope
1048 (e.g. ``"person:alice"``, or the default ``"bank"``).
1049 kind: M14.6 — if set, return only models of the given
1050 sub-type (``"general"`` | ``"preference"``). When
1051 ``None``, returns all kinds.
1052 """
1053 pass
1055 async def delete(self, model_id: str, bank_id: str) -> bool:
1056 """Soft-delete a mental model.
1058 Returns ``True`` if the model was found and deleted, ``False`` if
1059 it didn't exist or was already deleted. Implementations may keep
1060 the row for audit (deleted_at timestamp) or hard-delete; either
1061 is acceptable as long as ``get`` returns ``None`` afterward.
1062 """
1063 pass
1065 async def update_via_ops(
1066 self,
1067 model_id: str,
1068 bank_id: str,
1069 operations_json: list[dict],
1070 ) -> "tuple[int, dict] | None":
1071 """M21 — apply structured delta operations to a mental model.
1073 Loads the current model, applies the operations against its
1074 ``structured_doc`` via
1075 :func:`astrocyte.pipeline.delta_ops.apply_operations`,
1076 re-renders ``content`` from the new doc, and upserts the
1077 result. Lazy-migrates legacy rows whose ``structured_doc`` is
1078 ``None`` by parsing the raw ``content`` markdown on first
1079 refresh.
1081 Args:
1082 model_id: The model to modify.
1083 bank_id: Tenant-scoped bank identifier.
1084 operations_json: List of JSON-shaped op dicts (each
1085 matching one of the :class:`Operation` variants in
1086 :mod:`astrocyte.pipeline.delta_ops`). Schema-invalid
1087 ops are dropped by the operation parser; per-op
1088 validation drops ops that reference unknown sections.
1090 Returns:
1091 ``(new_revision, applied_delta_summary)`` on success.
1092 ``None`` when the model doesn't exist.
1093 The summary is the audit log from
1094 :class:`~astrocyte.pipeline.delta_ops.AppliedDelta` —
1095 ``{"applied": [...], "skipped": [...], "changed": bool}``
1096 — useful for telemetry / debugging the LLM's output.
1098 Conservative-failure contract: invalid ops drop with a logged
1099 reason; the document never gets worse than its input.
1100 """
1101 pass
1103 async def refresh(
1104 self,
1105 model_id: str,
1106 bank_id: str,
1107 new_source_ids: list[str],
1108 ) -> "MentalModel | None":
1109 """M28 — re-derive a mental model from an expanded source set.
1111 Hindsight parity: after retain adds new memories that pertain to
1112 an existing mental model, ``refresh`` re-runs the compile against
1113 the extended ``source_ids`` and bumps the revision. The new
1114 sources are merged into the existing ``source_ids`` (deduped,
1115 order-preserving for existing entries).
1117 Args:
1118 model_id: The model to refresh.
1119 bank_id: Tenant-scoped bank identifier.
1120 new_source_ids: Source-memory ids to fold into the model's
1121 provenance. Duplicates of existing ids are deduped.
1123 Returns:
1124 The refreshed :class:`MentalModel` (post-upsert) with the
1125 new revision number and merged source ids. ``None`` if the
1126 model doesn't exist.
1128 Future enhancement: the production implementation will invoke
1129 the LLM compile pipeline against the merged source set; the
1130 current SPI provides the surface so callers (gateway, MCP)
1131 can wire the refresh flow today.
1132 """
1133 pass
1135 async def health(self) -> HealthStatus:
1136 """Check storage connectivity."""
1137 pass
1140# ---------------------------------------------------------------------------
1141# Tier 1: Source Store (M10 — documents + chunks normalisation)
1142# ---------------------------------------------------------------------------
1145@runtime_checkable
1146class SourceStore(Protocol):
1147 """SPI for source-document and chunk storage.
1149 Backs the three-layer hierarchy ``SourceDocument → SourceChunk →
1150 VectorItem`` so retained memories can preserve provenance back to
1151 the originating document. Optional: deployments without a SourceStore
1152 keep using the prior flat-vectors retain path.
1154 See ``astrocyte.testing.in_memory.InMemorySourceStore`` for a
1155 reference and ``astrocyte_postgres.PostgresSourceStore`` for the
1156 production-grade implementation.
1157 """
1159 SPI_VERSION: ClassVar[int] = 1
1161 async def store_document(self, document: "SourceDocument") -> str:
1162 """Create or update a source document.
1164 Returns the stored ``document.id``. Implementations should treat
1165 ``content_hash`` as the dedup key when set: a second call with
1166 the same ``(bank_id, content_hash)`` should NOT create a
1167 duplicate row, but instead return the existing id.
1168 """
1169 pass
1171 async def get_document(
1172 self,
1173 document_id: str,
1174 bank_id: str,
1175 ) -> "SourceDocument | None":
1176 """Retrieve a source document by id. Returns ``None`` if missing
1177 or soft-deleted."""
1178 pass
1180 async def find_document_by_hash(
1181 self,
1182 content_hash: str,
1183 bank_id: str,
1184 ) -> "SourceDocument | None":
1185 """Look up an existing document by its content hash for dedup
1186 before re-ingest."""
1187 pass
1189 async def list_documents(
1190 self,
1191 bank_id: str,
1192 *,
1193 limit: int = 100,
1194 ) -> list["SourceDocument"]:
1195 """List source documents in a bank, newest first."""
1196 pass
1198 async def delete_document(self, document_id: str, bank_id: str) -> bool:
1199 """Soft-delete a document. Cascades to its chunks via the
1200 underlying schema. Does NOT cascade to vectors that reference
1201 the chunks (those use their own ``forgotten_at`` lifecycle and
1202 keep their text — the source is just no longer linkable).
1204 Returns ``True`` if the document was found and deleted,
1205 ``False`` if it didn't exist or was already deleted.
1206 """
1207 pass
1209 async def store_chunks(self, chunks: list["SourceChunk"]) -> list[str]:
1210 """Bulk-insert chunks. Returns their ids in input order.
1212 Dedup: a chunk with a ``(bank_id, content_hash)`` pair that
1213 already exists must NOT create a duplicate; the returned id
1214 should be the existing chunk's id.
1215 """
1216 pass
1218 async def get_chunk(self, chunk_id: str, bank_id: str) -> "SourceChunk | None":
1219 """Retrieve a chunk by id. Returns ``None`` if missing."""
1220 pass
1222 async def list_chunks(
1223 self,
1224 document_id: str,
1225 bank_id: str,
1226 ) -> list["SourceChunk"]:
1227 """List all chunks of a document, ordered by ``chunk_index``."""
1228 pass
1230 async def find_chunk_by_hash(
1231 self,
1232 content_hash: str,
1233 bank_id: str,
1234 ) -> "SourceChunk | None":
1235 """Lookup an existing chunk by its content hash for dedup
1236 before re-storing."""
1237 pass
1239 async def health(self) -> HealthStatus:
1240 """Check storage connectivity."""
1241 pass
1244# ---------------------------------------------------------------------------
1245# Engine Provider
1246# ---------------------------------------------------------------------------
1249@runtime_checkable
1250class EngineProvider(Protocol):
1251 """SPI for full-stack memory engines.
1253 Engine providers own the entire memory pipeline — embedding, storage,
1254 retrieval, and synthesis. Astrocyte delegates retain/recall/reflect
1255 to the engine but still enforces its policy layer (PII, rate limits,
1256 access control) around every call.
1258 Examples: Mem0, Zep, Mystique/Hindsight.
1259 """
1261 SPI_VERSION: ClassVar[int] = 1
1263 def capabilities(self) -> EngineCapabilities:
1264 """Declare what this engine supports (reflect, forget, etc.).
1266 Called once at init. Astrocyte uses this to decide which operations
1267 to delegate vs. handle internally.
1268 """
1269 pass
1271 async def health(self) -> HealthStatus:
1272 """Liveness and readiness check."""
1273 pass
1275 async def retain(self, request: RetainRequest) -> RetainResult:
1276 """Store content into memory. The engine decides how to chunk,
1277 embed, and index the content.
1278 """
1279 pass
1281 async def recall(self, request: RecallRequest) -> RecallResult:
1282 """Retrieve relevant memories for a query. The engine owns
1283 the retrieval strategy (semantic, keyword, graph, hybrid).
1284 """
1285 pass
1287 async def reflect(self, request: ReflectRequest) -> ReflectResult:
1288 """Synthesize an answer from memory. Optional — only called if
1289 ``capabilities().supports_reflect`` is True.
1290 """
1291 pass
1293 async def forget(self, request: ForgetRequest) -> ForgetResult:
1294 """Remove or archive memories. Optional — only called if
1295 ``capabilities().supports_forget`` is True.
1296 """
1297 pass
1300# ---------------------------------------------------------------------------
1301# LLM Provider
1302# ---------------------------------------------------------------------------
1305@runtime_checkable
1306class LLMProvider(Protocol):
1307 """SPI for LLM access needed by the Astrocyte core pipeline.
1309 Storage-pipeline deployments require an LLMProvider for embedding
1310 generation and (optionally) for reflect-time synthesis. Engine
1311 providers typically bring their own LLM access, but Astrocyte may
1312 still use this for MIP intent routing or policy evaluation.
1313 """
1315 SPI_VERSION: ClassVar[int] = 1
1317 async def complete(
1318 self,
1319 messages: list[Message],
1320 model: str | None = None,
1321 max_tokens: int = 1024,
1322 temperature: float = 0.0,
1323 tools: list[ToolDefinition] | None = None,
1324 tool_choice: str | None = None,
1325 response_format: dict | None = None,
1326 ) -> Completion:
1327 """Generate a text completion from a message sequence.
1329 Used by reflect, MIP intent routing, and consolidation summarization.
1331 ``tools`` (optional) — when provided, the provider passes them as
1332 native function-calling tools to the underlying API and the
1333 returned :class:`Completion`'s ``tool_calls`` field carries any
1334 invocations the model emitted. ``tool_choice`` may be ``"auto"``
1335 (default when tools is set), ``"required"`` (force a tool call),
1336 or a specific tool name. Providers that don't support tools
1337 SHOULD ignore both args and return ``tool_calls=None`` so callers
1338 can feature-detect.
1340 ``response_format`` (optional) — a provider-specific structured-
1341 output spec passed straight through to the underlying API. For
1342 OpenAI this is the ``response_format`` request body parameter
1343 (e.g. ``{"type": "json_schema", "json_schema": {"name": "facts",
1344 "schema": {...}, "strict": True}}``). When set, the provider
1345 constrains the model's output to the schema at decode time —
1346 callers that need malformed-JSON-free responses (e.g. structured
1347 fact extraction) should set this. Providers that don't support
1348 structured outputs SHOULD ignore the arg and return their normal
1349 text completion so callers can feature-detect by parsing.
1350 """
1351 pass
1353 async def embed(
1354 self,
1355 texts: list[str],
1356 model: str | None = None,
1357 ) -> list[list[float]]:
1358 """Generate embedding vectors for the given texts.
1360 Used by the Tier 1 retain pipeline to embed content before storage.
1362 Raises:
1363 NotImplementedError: If this provider does not support embeddings
1364 (e.g., a local model without an embedding endpoint).
1365 """
1366 pass
1368 def capabilities(self) -> LLMCapabilities:
1369 """Declare LLM capabilities (supported models, embedding dimensions, etc.)."""
1370 pass
1373# ---------------------------------------------------------------------------
1374# Outbound Transport Provider (optional, cross-cutting)
1375# ---------------------------------------------------------------------------
1378@runtime_checkable
1379class OutboundTransportProvider(Protocol):
1380 """SPI for credential gateways and enterprise HTTP/TLS proxies.
1382 Optional, cross-cutting provider. When registered, Astrocyte calls
1383 ``apply()`` on every outbound HTTP client context (e.g., to inject
1384 auth headers, configure mTLS, or route through a corporate proxy).
1385 """
1387 SPI_VERSION: ClassVar[int] = 1
1389 def apply(self, ctx: HttpClientContext) -> None:
1390 """Configure the HTTP client context before an outbound request.
1392 Typical uses: inject Bearer tokens, set proxy URLs, configure TLS
1393 client certificates.
1394 """
1395 pass
1397 def subprocess_env(self) -> dict[str, str]:
1398 """Return environment variables for subprocess calls that need
1399 the same transport configuration (e.g., ``HTTPS_PROXY``, auth tokens).
1400 """
1401 pass
1403 def capabilities(self) -> TransportCapabilities:
1404 """Declare transport capabilities (proxy support, mTLS, etc.)."""
1405 pass
1408# ---------------------------------------------------------------------------
1409# SPI Version Negotiation
1410# ---------------------------------------------------------------------------
1412# Supported SPI versions per protocol
1413_SUPPORTED_VERSIONS: dict[str, set[int]] = {
1414 "VectorStore": {1},
1415 "GraphStore": {1},
1416 "DocumentStore": {1},
1417 "WikiStore": {1},
1418 "MentalModelStore": {1},
1419 "SourceStore": {1},
1420 "EngineProvider": {1},
1421 "LLMProvider": {1},
1422 "OutboundTransportProvider": {1},
1423}
1426def check_spi_version(provider: object, protocol_name: str) -> int:
1427 """Validate a provider's SPI_VERSION against supported versions.
1429 Returns the provider's version if accepted.
1430 Raises ConfigError if the version is unsupported.
1431 """
1432 from astrocyte.errors import ConfigError
1434 version = getattr(provider, "SPI_VERSION", None)
1435 if version is None:
1436 # No version declared — assume v1 for backwards compatibility
1437 return 1
1439 supported = _SUPPORTED_VERSIONS.get(protocol_name, {1})
1440 if version not in supported:
1441 raise ConfigError(
1442 f"{protocol_name} SPI version {version} is not supported. Supported versions: {sorted(supported)}"
1443 )
1444 return version