Coverage for astrocyte/types.py: 98%
742 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
1"""Astrocyte core types — all DTOs for the framework.
3Every type here is FFI-safe: no Any, no callables, no generators.
4Fields use only: str, int, float, bool, None, list, dict, datetime, dataclass.
5See docs/_design/implementation-language-strategy.md for constraints.
6"""
8from __future__ import annotations
10import json as _json
11from dataclasses import asdict, dataclass, field
12from datetime import date, datetime
13from typing import Literal
15from astrocyte.mip.schema import ForgetSpec, PipelineSpec
17# ---------------------------------------------------------------------------
18# Metadata value type — recursive union replacing Any for FFI safety
19# ---------------------------------------------------------------------------
20MetadataValue = str | int | float | bool | None
21Metadata = dict[str, MetadataValue]
23# ---------------------------------------------------------------------------
24# Common
25# ---------------------------------------------------------------------------
28@dataclass
29class HealthStatus:
30 healthy: bool
31 message: str | None = None
32 latency_ms: float | None = None
33 last_check_at: datetime | None = None
36# ---------------------------------------------------------------------------
37# Tier 1: Vector Store
38# ---------------------------------------------------------------------------
41@dataclass
42class VectorItem:
43 id: str
44 bank_id: str
45 vector: list[float]
46 text: str
47 metadata: Metadata | None = None
48 tags: list[str] | None = None
49 fact_type: str | None = None # "world", "experience", "observation"
50 occurred_at: datetime | None = None
51 memory_layer: str | None = None # "fact", "observation", "model" — memory hierarchy
52 retained_at: datetime | None = None # UTC wall-clock when this item was stored (M9)
53 #: Optional backreference to the originating ``SourceChunk.id`` (M10).
54 #: When set, the vector store persists it onto ``astrocyte_vectors.chunk_id``
55 #: so recall can resolve provenance (`document_id`, `source_uri`) and
56 #: trigger chunk-level expansion. Nullable for backward compat — vectors
57 #: ingested without a SourceStore retain stamp ``None``.
58 chunk_id: str | None = None
60 def __post_init__(self) -> None:
61 if not self.text:
62 raise ValueError("VectorItem.text must be non-empty")
65@dataclass
66class VectorFilters:
67 bank_id: str | None = None
68 tags: list[str] | None = None
69 fact_types: list[str] | None = None
70 time_range: tuple[datetime, datetime] | None = None
71 metadata_filters: Metadata | None = None
72 as_of: datetime | None = None # Time-travel: only return items retained on or before this timestamp (M9)
73 #: M31 Fix 2 — opaque session identifier. When set, vector_store
74 #: adapters that support session scoping return only entries whose
75 #: ``metadata['session_id']`` matches. Adapters that don't yet
76 #: implement this filter MUST ignore it (best-effort semantics:
77 #: caller gets no scoping, not an error). Production wiring:
78 #: ``orchestrator.recall`` reads ``RecallRequest.session_id`` and
79 #: populates this field; per-adapter SQL/index filter is the
80 #: M32 cycle's adapter follow-up.
81 session_id: str | None = None
84@dataclass
85class VectorHit:
86 id: str
87 text: str
88 score: float # 0.0 – 1.0 similarity
89 metadata: Metadata | None = None
90 tags: list[str] | None = None
91 fact_type: str | None = None
92 occurred_at: datetime | None = None
93 memory_layer: str | None = None # "fact", "observation", "model"
94 retained_at: datetime | None = None # UTC timestamp when item was retained (M9)
95 #: M10: backreference to the originating ``SourceChunk.id``. When set,
96 #: callers can resolve provenance via ``SourceStore.get_chunk(chunk_id)
97 #: → SourceStore.get_document(chunk.document_id)`` to surface citations.
98 #: ``None`` for legacy vectors retained without a SourceStore.
99 chunk_id: str | None = None
101 def __post_init__(self) -> None:
102 if self.score < 0.0:
103 raise ValueError(f"VectorHit.score must be >= 0.0, got {self.score}")
106@dataclass
107class MemoryChange:
108 """One entry in a bank's change feed (team memory sync, ``VectorStore.list_changes``).
110 The changes feed is every change to a synced row, in ``(changed_at, id)``
111 order. ``changed_at`` is the row's last change to any synced field: when
112 it was stored, rewritten (metadata, and later claim status, trust or
113 staleness flags) or forgotten. A live row carries its current values, so
114 a later change shows up as an upsert of the same id. A forgotten row is a
115 **tombstone**: ``deleted`` is true and only ``id``, ``bank_id`` and
116 ``changed_at`` are set, so a forgotten memory's text never leaves the
117 store through the feed.
118 """
120 id: str
121 bank_id: str
122 changed_at: datetime
123 deleted: bool = False
124 text: str | None = None
125 occurred_at: datetime | None = None
126 retained_at: datetime | None = None
127 tags: list[str] | None = None
128 fact_type: str | None = None
129 memory_layer: str | None = None
130 metadata: Metadata | None = None
133@dataclass
134class MemoryChangePage:
135 """A page of :class:`MemoryChange` entries from ``Astrocyte.list_changes``.
137 ``next_cursor`` is the opaque position after the last change on this page
138 (the request's own cursor when the page is empty, ``None`` only when the
139 bank has no changes at all). Store it and pass it back to resume without
140 gaps or repeats. ``has_more`` is true when more changes were already
141 waiting past this page.
142 """
144 changes: list[MemoryChange]
145 next_cursor: str | None = None
146 has_more: bool = False
149@dataclass
150class SyncPushRecord:
151 """One memory pushed by a team-memory client (``Astrocyte.push_records``).
153 Stored as exactly one row with the client's ``id`` (8–64 of
154 ``[A-Za-z0-9_-]``): no chunking, no extraction, re-embedded by the server.
155 ``content_hash`` (``"sha256:<hex>"`` of the UTF-8 ``text``), when given,
156 must match the text as sent.
157 """
159 id: str
160 text: str
161 occurred_at: datetime | None = None
162 tags: list[str] | None = None
163 fact_type: str | None = None
164 metadata: Metadata | None = None
165 content_hash: str | None = None
168@dataclass
169class SyncPushResult:
170 """What became of one :class:`SyncPushRecord`.
172 ``status`` is ``stored`` (a new row with the pushed id), ``unchanged``
173 (that id already holds this text in this bank; idempotent re-push),
174 ``duplicate`` (a near-duplicate of the bank's memory ``duplicate_of``;
175 nothing stored) or ``rejected`` (``reason`` says why; nothing stored).
176 """
178 id: str
179 status: Literal["stored", "unchanged", "duplicate", "rejected"]
180 duplicate_of: str | None = None
181 reason: str | None = None
184# ---------------------------------------------------------------------------
185# Tier 1: Graph Store
186# ---------------------------------------------------------------------------
189@dataclass
190class Entity:
191 id: str
192 name: str
193 entity_type: str # PERSON, ORG, LOCATION, …
194 aliases: list[str] | None = None
195 metadata: Metadata | None = None
196 #: Per-entity name embedding for the Hindsight-inspired entity-resolution
197 #: cascade. When present, the resolver uses cosine similarity against
198 #: candidate embeddings to decide whether two surface forms refer to the
199 #: same canonical entity, falling back to LLM disambiguation only for
200 #: genuinely ambiguous pairs. Optional and nullable — a ``None`` value
201 #: signals "no embedding tier available for this entity" and the cascade
202 #: degrades gracefully to trigram + LLM.
203 embedding: list[float] | None = None
204 #: Hindsight-parity mention count — how many times this entity has been
205 #: resolved-to during retain. Treated as a soft popularity signal in the
206 #: composite cascade (cheap tiebreaker, never a primary decider). Adapter
207 #: stores increment this on every successful canonical resolution; new
208 #: entities start at 1.
209 mention_count: int = 1
212@dataclass
213class EntityCandidateMatch:
214 """Scored candidate produced by ``GraphStore.find_entity_candidates_scored``.
216 The Hindsight-inspired entity-resolution cascade asks the graph store for
217 candidates pre-scored against four cheap signals — trigram, embedding,
218 co-occurrence, and temporal proximity — so the resolver can decide
219 whether to autolink, skip, or escalate to the LLM without paying for
220 additional database round-trips.
222 Fields:
223 entity: The candidate entity.
224 name_similarity: Trigram similarity of the candidate's name against
225 the query name in ``[0.0, 1.0]``. Adapters compute this with
226 ``pg_trgm.similarity()`` (PostgreSQL) or ``difflib.SequenceMatcher``
227 (in-memory).
228 embedding_similarity: Cosine similarity of the candidate's stored
229 embedding against the supplied query embedding in ``[0.0, 1.0]``,
230 or ``None`` when either side has no embedding stored.
231 co_occurring_names: Names of entities that co-occur with this
232 candidate via ``EntityLink(link_type="co_occurs")``. Lowercased.
233 Used by the resolver to compute overlap with the new entity's
234 nearby entities — strong evidence two surface forms refer to
235 the same canonical entity when their contexts overlap.
236 last_seen: The candidate's last-activity timestamp (typically
237 ``updated_at``). Used to compute temporal proximity to the new
238 entity's event date — recent candidates score higher for the
239 same name. ``None`` when not stored.
240 """
242 entity: Entity
243 name_similarity: float
244 embedding_similarity: float | None = None
245 co_occurring_names: list[str] = field(default_factory=list)
246 last_seen: datetime | None = None
247 #: Hindsight-parity popularity signal — how many memories this entity has
248 #: been resolved-to. Used by :meth:`EntityResolver._composite_score` as a
249 #: soft tiebreaker (capped contribution; never overrides name/cooccurrence
250 #: signals on its own).
251 mention_count: int = 1
254@dataclass
255class EntityLink:
256 """A typed relationship between two entities in the knowledge graph.
258 M11: fields renamed from ``source_entity_id``/``target_entity_id`` to
259 ``entity_a``/``entity_b`` to be direction-neutral; ``evidence``,
260 ``confidence``, and ``created_at`` added for entity resolution provenance.
261 """
263 entity_a: str
264 """ID of the first entity in the relationship."""
266 entity_b: str
267 """ID of the second entity in the relationship."""
269 link_type: str
270 """Relationship label — e.g. ``"alias_of"``, ``"co_occurs"``, ``"works_at"``."""
272 evidence: str = ""
273 """Verbatim quote from the source memory that justifies this link."""
275 confidence: float = 1.0
276 """0–1 confidence score. 1.0 = rule-derived; < 1.0 = LLM-confirmed."""
278 created_at: datetime | None = None
279 """UTC wall-clock time this link was created. None for legacy links."""
281 metadata: Metadata | None = None
282 """Optional extra key-value pairs (preserved for backward compatibility)."""
285@dataclass
286class MemoryEntityAssociation:
287 memory_id: str
288 entity_id: str
291@dataclass
292class MemoryLink:
293 """A typed directional link between two memories (Hindsight parity).
295 Distinct from :class:`EntityLink` (which connects entities). Memory
296 links capture relationships between fact-level units — the granularity
297 Hindsight's ``link_expansion_retrieval`` walks for causal chains and
298 semantic-kNN edges.
300 Three link types are first-class in the link-expansion retrieval
301 signal:
302 - ``"caused_by"`` — extracted at retain time from cause-effect text
303 ("she lost her job, so she couldn't pay rent").
304 - ``"semantic"`` — precomputed kNN (each new memory linked to its
305 top-K most similar prior memories at insert time, similarity ≥ 0.7).
306 - ``"entity_overlap"`` — query-time signal computed from shared
307 entities (not persisted; included here for documentation parity).
309 Direction matters: ``source_memory_id`` is the "from" side. For
310 ``caused_by`` semantics, the source is the EFFECT and the target is
311 the CAUSE. (Hindsight's convention; preserved here for parity.)
312 """
314 source_memory_id: str
315 target_memory_id: str
316 link_type: str
317 evidence: str = ""
318 confidence: float = 1.0
319 weight: float = 1.0
320 created_at: datetime | None = None
321 metadata: Metadata | None = None
324@dataclass
325class GraphHit:
326 memory_id: str
327 text: str
328 connected_entities: list[str]
329 depth: int
330 score: float
333# ---------------------------------------------------------------------------
334# Tier 1: Document Store
335# ---------------------------------------------------------------------------
338@dataclass
339class Document:
340 id: str
341 text: str
342 metadata: Metadata | None = None
343 tags: list[str] | None = None
346@dataclass
347class DocumentFilters:
348 tags: list[str] | None = None
349 metadata_filters: Metadata | None = None
352@dataclass
353class DocumentHit:
354 document_id: str
355 text: str
356 score: float # BM25 relevance
357 metadata: Metadata | None = None
360# ---------------------------------------------------------------------------
361# Engine Provider — requests / results
362# ---------------------------------------------------------------------------
365@dataclass
366class RetainRequest:
367 content: str
368 bank_id: str
369 metadata: Metadata | None = None
370 tags: list[str] | None = None
371 occurred_at: datetime | None = None
372 source: str | None = None
373 content_type: str = "text" # "text", "conversation", "document", "email", ...
374 extraction_profile: str | None = None # key in astrocyte.yml extraction_profiles (M3)
375 #: Optional pipeline overrides from a MIP RoutingDecision. When set, fields
376 #: take precedence over extraction profile and content_type defaults during
377 #: chunking and dedup. Persisted onto each stored chunk via ``_mip.*`` keys.
378 mip_pipeline: PipelineSpec | None = None
379 #: Name of the MIP rule whose action produced ``mip_pipeline``. Persisted on
380 #: stored chunks as ``_mip.rule`` so recall can warn on rule-version drift.
381 mip_rule_name: str | None = None
384@dataclass
385class RetainResult:
386 stored: bool
387 memory_id: str | None = None
388 deduplicated: bool = False
389 error: str | None = None
390 retention_action: str | None = None # "add" | "update" | "merge" | "skip" | "delete" (curated retain)
391 curated: bool = False # Whether LLM curation was used
392 memory_layer: str | None = None # Layer assigned during curation
395@dataclass
396class RecallRequest:
397 query: str
398 bank_id: str
399 max_results: int = 10
400 max_tokens: int | None = None
401 fact_types: list[str] | None = None
402 tags: list[str] | None = None
403 time_range: tuple[datetime, datetime] | None = None
404 include_sources: bool = False
405 layer_weights: dict[str, float] | None = None # {"fact": 1.0, "observation": 1.5, "model": 2.0}
406 detail_level: str | None = None # "titles" | "bodies" | "full" | None (default=full)
407 external_context: list[MemoryHit] | None = None # External RAG/graph results for cross-source fusion
408 as_of: datetime | None = None # Time-travel: recall as if it were this UTC moment (M9)
409 #: Reference date for resolving relative temporal phrases in the
410 #: query (``"yesterday"``, ``"last week"``, ``"3 days ago"``).
411 #: SEPARATE from ``as_of``: ``as_of`` is a ``retained_at`` time-travel
412 #: filter (M9 audit/legal-hold use case); ``query_reference_date`` is
413 #: ONLY consumed by the query analyzer to anchor relative phrases.
414 #:
415 #: When unset, the query analyzer uses ``as_of`` if set, else
416 #: ``datetime.now()`` — preserving prior behaviour. Set this on
417 #: bench/eval paths whose dataset predates the run wall-clock (e.g.
418 #: LongMemEval is 2023-vintage; running in 2026 without anchoring
419 #: makes "yesterday" wrong by ~3 years). Crucially, setting only
420 #: ``query_reference_date`` (and leaving ``as_of=None``) does NOT
421 #: filter out memories retained after that date — the entire corpus
422 #: stays in scope. See the May 2026 LME post-mortem.
423 query_reference_date: datetime | None = None
424 #: Per-call disposition override. When set, the orchestrator's
425 #: abstention decision derives from ``dispositions.skepticism``
426 #: (1=trust everything → never abstain, 5=skeptical → aggressive
427 #: abstention) — see ``_abstention_floor_for_skepticism``. Falls
428 #: back to deployment defaults when ``None``. Use this to make a
429 #: single deployment serve both adversarial-resistant agents and
430 #: trust-the-model assistants without forking the YAML config.
431 dispositions: "Dispositions | None" = None
432 #: M31 Fix 2 — opaque session identifier. When the calling
433 #: application knows which conversation session a query targets,
434 #: this scopes the underlying SPI calls (``search_facts_*`` /
435 #: ``search_sections_*``) to facts and sections whose anchoring
436 #: section has matching ``session_id``. ``None`` (default) keeps
437 #: cross-session retrieval behaviour. Plumbed from
438 #: ``RecallParams.session_id`` via ``_make_recall_request``.
439 session_id: str | None = None
442@dataclass
443class MemoryHit:
444 text: str
445 score: float # 0.0 – 1.0 relevance
446 fact_type: str | None = None
447 metadata: Metadata | None = None
448 tags: list[str] | None = None
449 occurred_at: datetime | None = None
450 source: str | None = None
451 memory_id: str | None = None
452 bank_id: str | None = None # set by multi-bank / hybrid recall
453 memory_layer: str | None = None # "fact", "observation", "model"
454 utility_score: float | None = None # 0.0 – 1.0 composite utility
455 retained_at: datetime | None = None # UTC timestamp when item was retained (M9)
456 chunk_id: str | None = None # M10: source-chunk backreference (when source_store is wired)
459@dataclass
460class RecallTrace:
461 strategies_used: list[str] | None = None
462 total_candidates: int | None = None
463 fusion_method: str | None = None
464 latency_ms: float | None = None
465 strategy_timings_ms: dict[str, float] | None = None
466 strategy_candidate_counts: dict[str, int] | None = None
467 tier_used: int | None = None # Which retrieval tier resolved the query
468 layer_distribution: dict[str, int] | None = None # {"fact": 5, "observation": 3, "model": 1}
469 cache_hit: bool | None = None # Whether recall cache was used
470 wiki_tier_used: bool | None = None # True when wiki tier satisfied the query (M8 W5)
473@dataclass
474class RecallResult:
475 hits: list[MemoryHit]
476 total_available: int
477 truncated: bool
478 trace: RecallTrace | None = None
479 #: Optional labeled sections + rules for synthesis (M7 structured recall authority).
480 authority_context: str | None = None
481 #: Top raw cosine-similarity score from the semantic strategy (0.0 when no semantic
482 #: results were found). Used by the reflect evidence-strict gate to detect uncertain
483 #: retrieval and force citation rather than letting the LLM hallucinate from
484 #: tangential memories.
485 top_semantic_score: float = 0.0
488@dataclass
489class HistoryResult:
490 """Result of ``brain.history()`` — what the agent knew at a past point in time (M9).
492 Wraps a :class:`RecallResult` and carries the ``as_of`` timestamp so
493 callers can log/display the reconstruction point without parsing the request.
494 """
496 hits: list[MemoryHit]
497 total_available: int
498 truncated: bool
499 as_of: datetime # The UTC timestamp used for the time-travel query
500 bank_id: str
501 trace: RecallTrace | None = None
504@dataclass
505class GapItem:
506 """A single knowledge gap identified by ``brain.audit()`` (M10).
508 A gap is a topic or question that the memory bank cannot answer
509 adequately — either because no memories cover it, or because coverage
510 is too thin to draw a reliable conclusion.
511 """
513 topic: str
514 """Short label for the missing or under-covered topic (e.g. ``"Alice's current role"``)."""
516 severity: Literal["high", "medium", "low"]
517 """How critical the gap is.
519 - ``"high"`` — likely to cause a wrong or confidently-wrong answer.
520 - ``"medium"`` — partial coverage; answer may be incomplete.
521 - ``"low"`` — minor; nuance or context is missing.
522 """
524 reason: str
525 """One-sentence explanation of why the gap exists."""
528@dataclass
529class AuditResult:
530 """Result of ``brain.audit()`` — structured gap analysis for a scope (M10).
532 Summarises what the agent *doesn't* know about a given topic, together
533 with a 0–1 coverage score and provenance counts.
534 """
536 scope: str
537 """The scope string passed to ``brain.audit()``."""
539 bank_id: str
540 """The bank that was audited."""
542 gaps: list[GapItem]
543 """Identified knowledge gaps, ordered roughly by severity."""
545 coverage_score: float
546 """0–1 composite score (memory density × recency × topic breadth).
548 1.0 means the bank covers the scope well; < 0.5 indicates sparse coverage.
549 """
551 memories_scanned: int
552 """Number of memories retrieved and fed to the audit judge."""
554 trace: RecallTrace | None = None
555 """Diagnostic trace from the recall pass, if available."""
558@dataclass
559class Dispositions:
560 """Personality modifiers for synthesis."""
562 skepticism: int = 3 # 1 (trusting) to 5 (skeptical)
563 literalism: int = 3 # 1 (flexible) to 5 (rigid)
564 empathy: int = 3 # 1 (detached) to 5 (empathetic)
566 def __post_init__(self) -> None:
567 for field_name in ("skepticism", "literalism", "empathy"):
568 val = getattr(self, field_name)
569 if not (1 <= val <= 5):
570 raise ValueError(f"Dispositions.{field_name} must be 1–5, got {val}")
573@dataclass
574class ReflectRequest:
575 query: str
576 bank_id: str
577 max_tokens: int | None = None
578 include_sources: bool = True
579 dispositions: Dispositions | None = None
580 #: Optional tag filter forwarded to the dispatcher's internal recall.
581 #: When set, reflect's underlying retrieval is scoped to memories
582 #: carrying every listed tag — closing the leak where single-bank
583 #: ``Astrocyte.reflect(tags=...)`` previously dropped the filter.
584 tags: list[str] | None = None
585 #: M9 time-travel anchor: filters reflect's underlying recall to
586 #: ``retained_at <= as_of``. Use for legal-hold / audit replay
587 #: ("show me what the system knew at time X"). NOT for anchoring
588 #: relative phrases — see ``query_reference_date`` below for that.
589 as_of: datetime | None = None
590 #: Reference date for resolving relative temporal phrases in the
591 #: query (``"yesterday"``, ``"last week"``, ``"3 days ago"``).
592 #: SEPARATE from ``as_of``: setting ONLY this anchors phrase
593 #: resolution without filtering out memories retained after that
594 #: date. Required for benchmarks whose dataset predates the run
595 #: wall-clock — e.g. LongMemEval (2023-vintage) measured in 2026
596 #: must set ``query_reference_date=question_date`` so "yesterday"
597 #: resolves to question_date - 1 day, not run_wall_clock - 1 day.
598 #: Mirrors ``RecallRequest.query_reference_date``; the orchestrator
599 #: forwards this into every sub_recall the reflect path builds.
600 query_reference_date: datetime | None = None
603@dataclass
604class ReflectResult:
605 answer: str
606 confidence: float | None = None
607 sources: list[MemoryHit] | None = None
608 observations: list[str] | None = None
609 #: Same structured block as :attr:`RecallResult.authority_context` when reflect used recall authority.
610 authority_context: str | None = None
613@dataclass
614class ForgetRequest:
615 bank_id: str
616 memory_ids: list[str] | None = None
617 tags: list[str] | None = None
618 before_date: datetime | None = None
619 scope: str | None = None # "all" or None for selective
622@dataclass
623class ForgetResult:
624 deleted_count: int
625 archived_count: int = 0
628# ---------------------------------------------------------------------------
629# Engine capabilities
630# ---------------------------------------------------------------------------
633@dataclass(frozen=True)
634class EngineCapabilities:
635 supports_reflect: bool = False
636 supports_forget: bool = False
637 supports_semantic_search: bool = True
638 supports_keyword_search: bool = False
639 supports_graph_search: bool = False
640 supports_temporal_search: bool = False
641 supports_dispositions: bool = False
642 supports_consolidation: bool = False
643 supports_entities: bool = False
644 supports_tags: bool = False
645 supports_metadata: bool = True
646 supports_compile: bool = False # M8: wiki compile via brain.compile()
647 max_retain_bytes: int | None = None
648 max_recall_results: int | None = None
649 max_embedding_dims: int | None = None
652# ---------------------------------------------------------------------------
653# LLM Provider
654# ---------------------------------------------------------------------------
657@dataclass
658class ContentPart:
659 """Tagged union for multimodal content."""
661 type: str # "text", "image_url", "image_base64", "audio_url", "audio_base64"
662 text: str | None = None
663 image_url: str | None = None
664 image_base64: str | None = None
665 audio_url: str | None = None
666 audio_base64: str | None = None
667 media_type: str | None = None # MIME type for base64 content, e.g. "image/png", "image/jpeg"
670@dataclass
671class Message:
672 role: str # "system", "user", "assistant", "tool"
673 content: str | list[ContentPart] = ""
674 #: When ``role == "assistant"`` and the model emitted tool calls, the
675 #: provider adapter places them here so downstream consumers can
676 #: round-trip them back into a follow-up turn (along with the
677 #: matching ``role="tool"`` results).
678 tool_calls: list[ToolCall] | None = None
679 #: When ``role == "tool"``, the OpenAI / Anthropic spec requires this
680 #: field to point back at the originating ``ToolCall.id``. The provider
681 #: adapter uses it to reconstruct the wire-format tool result message.
682 tool_call_id: str | None = None
683 #: When ``role == "tool"``, the human-readable tool name. Carried for
684 #: providers (and for our own logging) that surface the name on
685 #: tool result messages.
686 name: str | None = None
689@dataclass
690class TokenUsage:
691 input_tokens: int
692 output_tokens: int
695#: JSON-shaped value type for tool-call arguments and JSON Schema —
696#: replaces ``Any`` to satisfy the FFI-safety constraint on DTOs
697#: (see :data:`Metadata` for the same idiom on memory metadata).
698JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
701@dataclass
702class ToolCall:
703 """A single tool invocation requested by an LLM during native function calling.
705 Mirrors the OpenAI/Anthropic tool-call shape: each call carries an
706 opaque ``id`` (for round-tripping a tool result back to the model),
707 a ``name`` matching one of the tools provided in the request, and
708 the model's chosen ``arguments`` as a parsed dict.
710 The agentic reflect loop (Hindsight parity) consumes these instead
711 of parsing JSON out of the response text — significantly more
712 reliable than the JSON-in-prose protocol.
713 """
715 id: str
716 name: str
717 arguments: dict[str, JsonValue]
720@dataclass
721class ToolDefinition:
722 """A tool the LLM can call. JSON-Schema-shaped, OpenAI-compatible.
724 ``parameters`` is a JSON Schema object; the provider adapter is
725 responsible for translating to the wire format the underlying API
726 expects (OpenAI: ``tools=[{"type": "function", "function": {...}}]``;
727 Anthropic: ``tools=[{"name": ..., "input_schema": ...}]``).
728 """
730 name: str
731 description: str
732 parameters: dict[str, JsonValue]
735@dataclass
736class Completion:
737 text: str
738 model: str
739 usage: TokenUsage | None = None
740 #: Tool calls the LLM emitted, when ``tools`` were supplied to
741 #: :meth:`LLMProvider.complete`. ``None`` means the provider did
742 #: not produce tool calls (or doesn't support them — feature-detect
743 #: with ``getattr`` rather than assuming).
744 tool_calls: list[ToolCall] | None = None
747@dataclass(frozen=True)
748class LLMCapabilities:
749 supports_multimodal_completion: bool = False
750 modalities_supported: tuple[str, ...] | None = None
751 supports_multimodal_embedding: bool = False
752 supports_batch_embed: bool = True
755# ---------------------------------------------------------------------------
756# Outbound Transport
757# ---------------------------------------------------------------------------
760@dataclass
761class HttpClientContext:
762 proxy: str | None = None
763 ca_bundle: str | None = None
764 headers: dict[str, str] | None = None
765 timeouts: dict[str, float] | None = None
768@dataclass(frozen=True)
769class TransportCapabilities:
770 supports_proxy: bool = False
771 supports_custom_ca: bool = False
772 supports_client_cert: bool = False
773 supports_headers: bool = False
776# ---------------------------------------------------------------------------
777# Multi-bank orchestration
778# ---------------------------------------------------------------------------
781@dataclass
782class MultiBankStrategy:
783 """Multi-bank recall behavior. Default ``parallel`` matches legacy ``banks=[...]`` without an explicit strategy."""
785 mode: Literal["cascade", "parallel", "first_match"] = "parallel"
786 min_results_to_stop: int = 3
787 cascade_order: list[str] | None = None
788 bank_weights: dict[str, float] | None = None
789 dedup_across_banks: bool = True
792# ---------------------------------------------------------------------------
793# Access control
794# ---------------------------------------------------------------------------
797_VALID_PERMISSIONS = {"read", "write", "forget", "admin", "*"}
800@dataclass
801class AccessGrant:
802 bank_id: str # or "*"
803 principal: str # or "*"
804 permissions: list[str] # ["read", "write", "forget", "admin"]
806 def __post_init__(self) -> None:
807 invalid = set(self.permissions) - _VALID_PERMISSIONS
808 if invalid:
809 raise ValueError(f"AccessGrant.permissions contains invalid values: {invalid}")
812@dataclass
813class ActorIdentity:
814 """Structured actor for access control and bank resolution (ADR-002)."""
816 type: str # "user" | "agent" | "service"
817 id: str
818 claims: dict[str, str] | None = None
821@dataclass
822class AstrocyteContext:
823 """Caller identity for access control.
825 ``principal`` remains the backwards-compatible primary string. When ``actor``
826 is set, identity resolution uses ``actor`` (and optional ``on_behalf_of`` for OBO);
827 ``principal`` is still useful for logging and integrations that have not migrated.
829 ``groups`` are group principals the actor belongs to (e.g. ``team:api``);
830 grants to a group apply to its members. ``grants`` are extra grants bound to
831 this caller (e.g. by a gateway token) and are added to the configured ones.
832 Both are set by the code that authenticated the caller, never by the caller.
833 """
835 principal: str # e.g. "agent:support-bot-1", "user:calvin"
836 actor: ActorIdentity | None = None
837 on_behalf_of: ActorIdentity | None = None
838 tenant_id: str | None = None
839 groups: list[str] | None = None
840 grants: list[AccessGrant] | None = None
843# ---------------------------------------------------------------------------
844# Event hooks
845# ---------------------------------------------------------------------------
848@dataclass
849class HookEvent:
850 event_id: str
851 type: str # e.g. "on_retain", "on_pii_detected"
852 timestamp: datetime
853 bank_id: str | None = None
854 data: Metadata | None = None
855 trace_id: str | None = None
858# ---------------------------------------------------------------------------
859# Data governance
860# ---------------------------------------------------------------------------
863@dataclass
864class DataClassification:
865 level: int # 0-3
866 label: str # "public", "internal", "confidential", "restricted"
867 categories: list[str] | None = None # ["PII", "PHI", …] for restricted
868 classified_by: str = "rules" # "caller", "rules", "llm"
869 classified_at: datetime | None = None
872# ---------------------------------------------------------------------------
873# Lifecycle / audit
874# ---------------------------------------------------------------------------
877@dataclass
878class LegalHold:
879 hold_id: str
880 bank_id: str
881 reason: str
882 set_at: datetime
883 set_by: str # "user:api", "system:compliance"
886@dataclass
887class LifecycleAction:
888 """Result of a lifecycle TTL evaluation on a single memory."""
890 memory_id: str
891 action: str # "archive" | "delete" | "keep"
892 reason: str # "ttl_unretrieved" | "ttl_archived_expired" | "recent" | "exempt" | "legal_hold"
895@dataclass
896class LifecycleRunResult:
897 archived_count: int
898 deleted_count: int
899 skipped_count: int
900 actions: list[LifecycleAction]
903@dataclass
904class AuditEvent:
905 event_type: str
906 bank_id: str
907 actor: str # "system:ttl", "user:api", "compliance:forget", …
908 timestamp: datetime
909 memory_ids: list[str] | None = None
910 reason: str | None = None
911 metadata: Metadata | None = None
914@dataclass
915class ForgetSelector:
916 bank_ids: list[str]
917 scope: str | None = None # "all" or None for selective
918 tags: list[str] | None = None
919 before_date: datetime | None = None
920 memory_ids: list[str] | None = None
923# ---------------------------------------------------------------------------
924# Analytics
925# ---------------------------------------------------------------------------
928@dataclass
929class HealthIssue:
930 severity: Literal["info", "warning", "critical"]
931 code: str
932 message: str
933 recommendation: str
936@dataclass
937class BankHealth:
938 bank_id: str
939 score: float # 0.0 – 1.0
940 status: Literal["healthy", "warning", "unhealthy"]
941 issues: list[HealthIssue]
942 metrics: dict[str, float]
943 assessed_at: datetime
946@dataclass
947class MemoryUsage:
948 memory_id: str
949 text: str
950 recall_count: int
951 last_recalled_at: datetime
954@dataclass
955class QualityDataPoint:
956 date: date
957 retain_count: int
958 recall_count: int
959 recall_hit_rate: float
960 avg_recall_score: float
961 dedup_rate: float
962 reflect_success_rate: float
965@dataclass
966class UtilizationReport:
967 bank_id: str
968 total_memories: int
969 active_memories: int # recalled >= 1x in last 30 days
970 stale_memories: int # never recalled in 30 days
971 never_recalled: int
972 top_recalled: list[MemoryUsage]
973 fact_type_distribution: dict[str, int]
974 tag_distribution: dict[str, int]
975 assessed_at: datetime
978@dataclass
979class QualityTrends:
980 bank_id: str
981 data_points: list[QualityDataPoint]
984# ---------------------------------------------------------------------------
985# Evaluation
986# ---------------------------------------------------------------------------
989@dataclass
990class EvalMetrics:
991 recall_precision: float
992 recall_hit_rate: float
993 recall_mrr: float
994 recall_ndcg: float
995 retain_latency_p50_ms: float
996 retain_latency_p95_ms: float
997 recall_latency_p50_ms: float
998 recall_latency_p95_ms: float
999 total_tokens_used: int
1000 total_duration_seconds: float
1001 reflect_accuracy: float | None = None
1002 reflect_completeness: float | None = None
1003 reflect_hallucination_rate: float | None = None
1004 reflect_latency_p50_ms: float | None = None
1005 reflect_latency_p95_ms: float | None = None
1006 # Tier-3 metrics: populated by ``BenchmarkMetricsCollector`` when the
1007 # bench wires it up. All optional so legacy callers stay valid.
1009 # ── Quality ──────────────────────────────────────────────
1010 #: Fraction of questions where at least one retrieved memory had
1011 #: token-overlap ≥ threshold with the gold answer. Distinguishes
1012 #: "missed evidence" from "had evidence, synthesised wrong".
1013 recall_coverage: float | None = None
1014 #: Cross-tab of (recall_hit, reflect_hit) outcomes. Keys:
1015 #: ``"both_hit"``, ``"recall_hit_reflect_miss"``,
1016 #: ``"recall_miss_reflect_hit"``, ``"both_miss"``.
1017 recall_reflect_gap: dict[str, int] | None = None
1018 #: For adversarial questions: fraction where reflect correctly abstained
1019 #: ("Insufficient evidence" / "I don't have …" patterns) instead of
1020 #: hallucinating an answer.
1021 abstention_rate_adversarial: float | None = None
1023 # ── Cost & efficiency ────────────────────────────────────
1024 #: Tokens by pipeline phase. Keys: ``"retain"``, ``"eval"``,
1025 #: ``"persona_compile"``, ``"observation_consolidation"``, ``"other"``.
1026 tokens_by_phase: dict[str, int] | None = None
1027 #: Total HTTP API calls across the run.
1028 api_calls_total: int | None = None
1029 #: HTTP API calls by endpoint. Typical keys: ``"chat/completions"``,
1030 #: ``"embeddings"``.
1031 api_calls_by_endpoint: dict[str, int] | None = None
1032 #: Total cost in USD computed from per-model pricing × actual tokens.
1033 cost_total_usd: float | None = None
1034 #: Mean cost per question.
1035 cost_per_question_usd: float | None = None
1037 # ── Latency (tail) ───────────────────────────────────────
1038 retain_latency_p99_ms: float | None = None
1039 recall_latency_p99_ms: float | None = None
1040 reflect_latency_p99_ms: float | None = None
1041 #: Median end-to-end question time (recall + reflect).
1042 e2e_per_question_p50_ms: float | None = None
1043 e2e_per_question_p95_ms: float | None = None
1045 # ── Robustness ───────────────────────────────────────────
1046 #: Counts of categorised errors. Typical keys: ``"pool_timeout"``,
1047 #: ``"deadlock"``, ``"openai_429"``, ``"openai_5xx"``, ``"other"``.
1048 error_count_by_type: dict[str, int] | None = None
1049 #: Number of OpenAI retries triggered (rate limits, transient errors).
1050 openai_retry_count: int | None = None
1051 #: Number of retain calls that returned ``stored=False`` or raised.
1052 failed_retain_count: int | None = None
1054 # ── Memory-architecture (cascade observability) ──────────
1055 #: Counts of entity-resolution cascade decisions. Typical keys:
1056 #: ``"trigram_autolink"``, ``"embedding_autolink"``,
1057 #: ``"composite_autolink"``, ``"llm_disambiguation"``, ``"skipped"``,
1058 #: ``"created_new"``.
1059 cascade_decisions: dict[str, int] | None = None
1060 #: Number of new entities resolved to an existing canonical via Path B
1061 #: (count of pre-store ID rewrites).
1062 entities_resolved_count: int | None = None
1063 #: Number of new entities that became fresh canonicals (no match).
1064 entities_created_count: int | None = None
1065 #: Histogram of composite scores keyed by bucket label
1066 #: (``"0.0-0.1"``, ``"0.1-0.2"``, …, ``"0.9-1.0"``).
1067 composite_score_distribution: dict[str, int] | None = None
1068 #: Total wiki-page rows in the bank at end-of-run.
1069 wiki_pages_total: int | None = None
1070 #: Mean number of wiki pages per unique persona name. >1 indicates
1071 #: scoping (per-conversation pages); 1 indicates single canonical.
1072 wiki_pages_per_persona: float | None = None
1075@dataclass
1076class QueryResult:
1077 query: str
1078 expected: list[str]
1079 actual: list[MemoryHit]
1080 relevant_found: int
1081 precision: float
1082 reciprocal_rank: float
1083 latency_ms: float
1086@dataclass
1087class EvalResult:
1088 suite: str
1089 provider: str
1090 provider_tier: str
1091 timestamp: datetime
1092 metrics: EvalMetrics
1093 per_query_results: list[QueryResult]
1094 config_snapshot: Metadata | None = None
1096 def to_dict(self) -> dict[str, object]:
1097 """Serialize to a JSON-safe dict (datetime → ISO 8601 string)."""
1099 def _convert(obj: object) -> object:
1100 if isinstance(obj, (datetime, date)):
1101 return obj.isoformat()
1102 return obj
1104 raw = asdict(self)
1105 return _json.loads(_json.dumps(raw, default=_convert))
1107 def to_json(self, *, indent: int = 2) -> str:
1108 """Serialize to a JSON string."""
1109 return _json.dumps(self.to_dict(), indent=indent)
1112@dataclass
1113class RegressionAlert:
1114 metric: str
1115 current_value: float
1116 baseline_value: float
1117 delta: float
1118 delta_percent: float
1119 severity: Literal["warning", "critical"]
1122# ---------------------------------------------------------------------------
1123# MIP routing
1124# ---------------------------------------------------------------------------
1127@dataclass
1128class RoutingDecision:
1129 """Output of MIP routing — tells Astrocyte where/how to store."""
1131 bank_id: str | None = None
1132 tags: list[str] | None = None
1133 retain_policy: str | None = None # "default" | "redact_before_store" | "encrypt" | "reject"
1134 resolved_by: str = "passthrough" # "mechanical" | "intent" | "passthrough"
1135 rule_name: str | None = None
1136 confidence: float = 1.0
1137 reasoning: str | None = None # LLM justification if intent layer used
1138 pipeline: PipelineSpec | None = None # Optional pipeline-shaping overrides from rule
1139 forget: ForgetSpec | None = None # Optional forget-policy overrides from rule (Phase 4)
1140 observability_tags: list[str] | None = None # Per-rule operator labels (Phase 5)
1143# ---------------------------------------------------------------------------
1144# M8: Wiki Compile
1145# ---------------------------------------------------------------------------
1147WikiPageKind = Literal["entity", "topic", "concept"]
1150@dataclass
1151class WikiPage:
1152 """A compiled topic/entity/concept page synthesised from raw memories (M8).
1154 WikiPages are additive artefacts — raw memories are never removed when a
1155 page is compiled. Each page carries ``source_ids`` back to every raw memory
1156 that contributed, enabling provenance tracing and recompile-on-forget.
1158 Pages are mutable: each compile pass produces a new revision. Past revisions
1159 are kept in the WikiStore audit log (not indexed for recall).
1160 """
1162 page_id: str # Stable ID, e.g. "topic:incident-response", "entity:alice"
1163 bank_id: str
1164 kind: WikiPageKind # "entity" | "topic" | "concept"
1165 title: str
1166 content: str # LLM-maintained markdown
1167 scope: str # Scope string used for this compile (tag name or cluster label)
1168 source_ids: list[str] # Raw memory IDs that contributed (provenance)
1169 cross_links: list[str] # Other page_ids referenced in this page
1170 revision: int # Monotonically increasing, starts at 1
1171 revised_at: datetime
1172 tags: list[str] | None = None # Inherited from contributing memories
1173 metadata: Metadata | None = None
1176@dataclass
1177class WikiPageHit:
1178 """A wiki page returned from a semantic search during recall tiering."""
1180 page_id: str
1181 title: str
1182 content: str
1183 scope: str
1184 kind: str
1185 score: float # 0.0 – 1.0 similarity
1186 source_ids: list[str]
1187 bank_id: str
1190@dataclass(frozen=True)
1191class MentalModel:
1192 """A first-class curated saved-reflect summary.
1194 Mental models are durable, refreshable artifacts — the "Caroline
1195 prefers async updates" / "Project X status: blocked on review" rows
1196 that outlive any single recall and serve as authoritative summaries
1197 when the recall pipeline elects to use the compiled layer.
1199 Stored in the dedicated :class:`~astrocyte.provider.MentalModelStore`
1200 SPI (formerly piggybacked on :class:`WikiStore` with
1201 ``kind="concept"`` + ``metadata["_mental_model"] = True``; that
1202 discriminator pattern was an architecture smell that we cut to a
1203 proper table in v1.x — see ``docs/_plugins/benchmark-presets.md``).
1205 Attributes:
1206 model_id: Stable identifier within the bank (e.g.
1207 ``"model:alice-prefs"``).
1208 bank_id: Tenant-scoped bank identifier.
1209 title: Human-readable display title.
1210 content: The summary body, typically markdown.
1211 scope: Scope key — ``"bank"`` for bank-wide models, or a
1212 specific tag like ``"person:alice"`` to scope to a topic.
1213 source_ids: Raw memory IDs that contributed to this summary
1214 (provenance — enables refresh-on-forget).
1215 revision: Monotonically increasing version number, starts at 1.
1216 Bumped by ``MentalModelStore.upsert`` on each refresh.
1217 refreshed_at: Timestamp of the most recent refresh.
1218 kind: Sub-type discriminator (M14.6). Defaults to ``"general"``
1219 for backwards compatibility with M11.2-era rows. Other
1220 values: ``"preference"`` for consolidated user-preference
1221 rows produced by ``astrocyte.pipeline.preference_compile``.
1222 Mirrors Hindsight's ``mental_models.subtype`` column.
1223 """
1225 model_id: str
1226 bank_id: str
1227 title: str
1228 content: str
1229 scope: str
1230 source_ids: list[str]
1231 revision: int
1232 refreshed_at: datetime
1233 kind: str = "general"
1234 #: M21 — structured representation of the document, stored as
1235 #: JSON-shaped dict (the :meth:`pydantic.BaseModel.model_dump`
1236 #: of :class:`astrocyte.pipeline.structured_doc.StructuredDocument`).
1237 #: When ``None`` (legacy rows), callers may lazy-migrate by parsing
1238 #: ``content`` via
1239 #: :func:`astrocyte.pipeline.structured_doc.parse_markdown`. New
1240 #: writes through :class:`~astrocyte.provider.MentalModelStore`
1241 #: populate this field; the ``content`` column stays as the
1242 #: rendered markdown for backwards-compatible reads. Stored on
1243 #: Postgres as ``structured_doc JSONB`` (see migration 032).
1244 #:
1245 #: Type is plain ``dict | None`` for FFI safety (per the file-level
1246 #: rule: no ``Any``, no callables, no generators); use
1247 #: ``StructuredDocument.model_validate(model.structured_doc)`` to
1248 #: get the typed object in-process.
1249 structured_doc: dict | None = None
1250 #: M40 — Per-source-id evidence timestamps, parallel to
1251 #: :attr:`source_ids`. Captures *when* each contributing memory was
1252 #: observed (in the conversation's reference timeline, not wall-
1253 #: clock-now), so that :func:`astrocyte.pipeline.trend.compute_trend`
1254 #: can classify the model as STABLE / STRENGTHENING / WEAKENING /
1255 #: NEW / STALE relative to a query's reference date.
1256 #:
1257 #: Mirrors the ``_obs_source_timestamps`` pattern on observations
1258 #: (see :mod:`astrocyte.pipeline.observation`). When ``None`` (legacy
1259 #: rows pre-M40), trend computation falls back to ``refreshed_at`` as
1260 #: a single-point timestamp — coarse but never garbage. New writes
1261 #: populate this field at consolidation time.
1262 #:
1263 #: Stored on Postgres as ``source_timestamps TIMESTAMPTZ[]`` (see
1264 #: migration 037). Length must equal ``len(source_ids)`` when set —
1265 #: the lists are positionally aligned.
1266 source_timestamps: list[datetime] | None = None
1269# ---------------------------------------------------------------------------
1270# Source documents and chunks (M10 — provenance + dedup)
1271# ---------------------------------------------------------------------------
1272#
1273# Three-layer hierarchy: SourceDocument → SourceChunk → VectorItem.
1274#
1275# Pre-M10, retained memories were anonymous flat rows in
1276# ``astrocyte_vectors`` with no record of where they came from.
1277# M10 introduces the optional :class:`~astrocyte.provider.SourceStore`
1278# SPI which lets callers preserve the source ↔ chunk ↔ memory chain so
1279# that:
1280#
1281# 1. **Provenance** — every memory traces back through its chunk to its
1282# originating document
1283# 2. **Dedup** — content_hash on documents and chunks prevents storing
1284# duplicate sources twice
1285# 3. **Re-extraction** — chunks can be re-processed without losing the
1286# original document text
1287# 4. **Source attribution** — recall results can answer "this answer
1288# came from these chunks of these documents"
1289#
1290# Backward compat: existing memories without a chunk parent (chunk_id IS
1291# NULL on astrocyte_vectors) continue to work unchanged. Adoption is
1292# entirely opt-in at the call site.
1295@dataclass(frozen=True)
1296class SourceDocument:
1297 """A top-level source document we ingested into a bank.
1299 The "source" of one or more :class:`SourceChunk` rows, which in turn
1300 are the source of one or more memory rows in the vector store.
1302 Attributes:
1303 id: Stable identifier (caller-supplied, must be unique per bank).
1304 bank_id: Tenant-scoped bank identifier.
1305 title: Optional human-readable title (e.g. file name, page title).
1306 source_uri: Optional pointer back to where this document originated
1307 (URL, file path, message-bus offset, etc.). Free-form.
1308 content_hash: Optional SHA-256 (hex) of the ORIGINAL document
1309 content — used by :meth:`SourceStore.find_document_by_hash`
1310 for dedup before re-ingest.
1311 content_type: Optional MIME type (``text/plain``,
1312 ``application/pdf``, etc.).
1313 metadata: Free-form JSON-serialisable metadata.
1314 created_at: When the document was first stored (assigned by the
1315 store; placeholder values from callers are overwritten).
1316 """
1318 id: str
1319 bank_id: str
1320 title: str | None = None
1321 source_uri: str | None = None
1322 content_hash: str | None = None
1323 content_type: str | None = None
1324 metadata: Metadata | None = None
1325 created_at: datetime | None = None
1328@dataclass(frozen=True)
1329class SourceChunk:
1330 """A chunk of a :class:`SourceDocument`.
1332 Sized + ordered by the chunking strategy used at retain time
1333 (paragraph / sentence / fixed-token / etc.). One chunk typically
1334 produces one memory in the vector store, but the relationship is
1335 1:N — a chunk can produce zero memories (e.g. policy filtered) or
1336 multiple (e.g. structured-fact extraction yielded N facts).
1338 Attributes:
1339 id: Stable identifier (caller-supplied OR generated by chunker).
1340 bank_id: Tenant-scoped bank identifier.
1341 document_id: ``SourceDocument.id`` this chunk belongs to.
1342 chunk_index: Ordering within the document, starts at 0.
1343 text: The chunk's text content.
1344 content_hash: SHA-256 (hex) of ``text`` — used for dedup so the
1345 same exact chunk ingested twice doesn't double up.
1346 metadata: Free-form metadata (e.g. character offsets, page
1347 numbers, speaker tags from chat transcripts).
1348 created_at: When the chunk was first stored.
1349 """
1351 id: str
1352 bank_id: str
1353 document_id: str
1354 chunk_index: int
1355 text: str
1356 content_hash: str | None = None
1357 metadata: Metadata | None = None
1358 created_at: datetime | None = None
1361@dataclass
1362class CompileScope:
1363 """A resolved compile scope — either from a tag or a DBSCAN cluster label."""
1365 scope: str # Scope string (tag name or cluster label)
1366 source: Literal["tag", "cluster", "explicit"] # How it was discovered
1367 memory_ids: list[str] # Memory IDs belonging to this scope
1370@dataclass
1371class CompileRequest:
1372 bank_id: str
1373 scope: str | None = None # If None, triggers full scope discovery (§3.2)
1376@dataclass
1377class CompileResult:
1378 """Result of a brain.compile() call."""
1380 bank_id: str
1381 scopes_compiled: list[str] # Scope strings that produced wiki pages
1382 pages_created: int
1383 pages_updated: int
1384 noise_memories: int # Untagged memories DBSCAN could not cluster (held for next cycle)
1385 tokens_used: int
1386 elapsed_ms: int
1387 error: str | None = None
1390# ---------------------------------------------------------------------------
1391# Section recall (M9): PageIndex tree + section graph
1392# ---------------------------------------------------------------------------
1395@dataclass
1396class PageIndexDocument:
1397 """One conversation/document in the PageIndex tier.
1399 Holds the canonical markdown so the picker can slice section excerpts
1400 by line_num without round-tripping to a separate document store, plus
1401 the reference_date used by the synthesiser to resolve relative phrases
1402 like "two months ago".
1403 """
1405 id: str # UUID-stringly typed for storage-adapter portability
1406 bank_id: str
1407 source_id: str # e.g. "conv-26", "lme-user-42"
1408 md_text: str
1409 reference_date: datetime | None = None
1410 built_at: datetime | None = None
1413@dataclass
1414class PageIndexSection:
1415 """One tree node in the PageIndex tier — the M9 recall primitive.
1417 The picker fetches a list of these (without ``summary_embedding``,
1418 which is only needed by the semantic-strategy SQL) as the "skeleton"
1419 it navigates over.
1420 """
1422 document_id: str
1423 line_num: int
1424 node_id: str # PageIndex's "0001"-style id
1425 title: str
1426 summary: str | None = None
1427 summary_embedding: list[float] | None = None
1428 speaker: str | None = None # 'user' | 'assistant' | None — LME assistant-recall
1429 session_date: datetime | None = None
1430 parent_node: str | None = None
1431 depth: int = 0
1432 # M11.1: structured event time the section's most-prominent
1433 # discussed event happened. Different from ``session_date`` —
1434 # "Yesterday I went to the doctor" in a session dated May 8
1435 # has ``session_date=May 8`` but ``occurred_start=May 7``.
1436 # Populated by per-section LLM extraction at retain time;
1437 # ``None`` when the LLM didn't surface a specific event.
1438 occurred_start: datetime | None = None
1439 occurred_end: datetime | None = None
1440 # M31 Fix 2 — opaque session identifier propagated from the source
1441 # conversation. Real systems pass session_id when the application
1442 # knows which session a query is about; the recall path filters to
1443 # facts/sections whose session_id matches. ``None`` for document-
1444 # ingest paths (markdown docs have no session concept). Stored as
1445 # TEXT column ``session_id`` on ``astrocyte_pi_sections`` via
1446 # migration 035.
1447 session_id: str | None = None
1450@dataclass
1451class MemoryFact:
1452 """Astrocyte's fact-grain memory unit — the Memory Engine analogue of
1453 Hindsight's :class:`MemoryUnit`.
1455 Originally introduced as ``PageIndexFact`` in M12.1 when fact-grain
1456 was first added bolted to PageIndex sections (one section = one chat
1457 session). Renamed in the M18b post-ship cleanup (2026-05-17) to
1458 reflect its actual role: this is the Memory Engine's primary fact
1459 type, not a Document-Engine concept. ``PageIndexFact`` remains as
1460 a deprecated alias at the bottom of this file.
1462 **Section anchoring is now OPTIONAL** (was NOT NULL pre-M18b
1463 cleanup, migration 029). Facts ingested through a
1464 Document/Conversation Engine path get a ``(document_id, line_num)``
1465 section anchor for cheap context-expansion and cascade-delete
1466 semantics. Facts written directly via a future top-level retain
1467 API (Hindsight-parity) can leave both fields ``None`` — Hindsight's
1468 ``MemoryUnit.document_id`` is similarly nullable.
1470 Anchored facts (typical today):
1471 - ``document_id``: parent tree document UUID
1472 - ``line_num``: section position within the tree
1473 - FK to ``astrocyte_pi_sections``; ON DELETE CASCADE applies
1475 Top-level facts (Hindsight-parity, future use):
1476 - ``document_id = None``, ``line_num = None``
1477 - Live directly under the bank; no cascade source
1478 """
1480 id: str
1481 bank_id: str
1482 text: str
1483 fact_type: str # 'experience' | 'preference' | 'world' | 'plan' | 'opinion' (M25: assistant_statement deprecated → experience+speaker='assistant')
1484 document_id: str | None = None
1485 line_num: int | None = None
1486 speaker: str | None = None
1487 occurred_start: datetime | None = None
1488 occurred_end: datetime | None = None
1489 entities: list[str] | None = None
1490 embedding: list[float] | None = None
1491 #: M27 — per-fact confidence (0.0-1.0) emitted by the extraction LLM.
1492 #: ``None`` for legacy rows + facts where the LLM did not emit one.
1493 #: Surfaced in the answerer context so the model can hedge / abstain
1494 #: on low-confidence facts and trust high-confidence ones. Mirrors
1495 #: Hindsight's ``memory_units.confidence_score`` (CHECK 0..1).
1496 confidence_score: float | None = None
1497 #: M27 — when the fact was MENTIONED in conversation, distinct from
1498 #: ``occurred_start`` (when the event happened). For section-anchored
1499 #: facts this is the section's ``session_date``. ``None`` for
1500 #: top-level facts or legacy rows. Hindsight calls this
1501 #: ``mentioned_at`` on memory_units; the prompt explicitly tells
1502 #: the answerer to distinguish "when it happened" from "when it
1503 #: was discussed".
1504 mentioned_at: datetime | None = None
1505 #: M31 Fix 4 — single resolved absolute date for the fact's most-
1506 #: prominent event. Populated at retain time by
1507 #: :func:`astrocyte.pipeline.temporal_resolution.resolve_event_date`
1508 #: when the fact's text contains a relative phrase ("last Tuesday",
1509 #: "3 days ago"), parsed against the section's ``session_date``.
1510 #: Distinct from ``occurred_start`` / ``occurred_end`` (LLM-emitted
1511 #: explicit event-time range) and ``mentioned_at`` (the discussion
1512 #: date). ``None`` when no relative date phrase is detected, when
1513 #: the section has no anchor, or for legacy rows. Surfaced in the
1514 #: answerer context so gpt-4o-mini doesn't have to do date math
1515 #: at query time — the 60-70% LME temporal-reasoning ceiling we
1516 #: kept hitting.
1517 event_date: datetime | None = None
1519 @property
1520 def chunk_id(self) -> str | None:
1521 """Stable composite identity of the source chunk (Hindsight parity).
1523 The Astrocyte equivalent of Hindsight's ``memory_units.chunk_id``
1524 FK. Composed from ``(document_id, line_num)`` — the same
1525 positional anchor that already links facts to their
1526 :class:`PageIndexSection` source. Returns ``None`` for
1527 top-level facts that have no document anchor.
1529 The format ``"<document_id>:<line_num>"`` is stable across
1530 runs as long as the document's section tree is unchanged.
1531 Downstream consumers (notably the bench harness's
1532 ``format_context_structured`` answerer prompt) use this id
1533 to pair each fact with its source-chunk text inline,
1534 mirroring Hindsight's ``_format_context_structured`` output.
1536 See ``docs/_design/`` (M24 architectural fix) for why an
1537 explicit identity surface matters even though the underlying
1538 ``(document_id, line_num)`` pair is unchanged.
1539 """
1540 if self.document_id is None or self.line_num is None:
1541 return None
1542 return f"{self.document_id}:{self.line_num}"
1545@dataclass
1546class MemoryFactHit:
1547 """A :class:`MemoryFact` returned from a fact-grain search strategy.
1549 Section anchor fields (``document_id``, ``line_num``) are nullable
1550 — top-level facts (no document anchor) return ``None`` for both.
1551 """
1553 fact_id: str
1554 text: str
1555 fact_type: str
1556 speaker: str | None
1557 occurred_start: datetime | None
1558 occurred_end: datetime | None
1559 entities: list[str]
1560 score: float
1561 document_id: str | None = None
1562 line_num: int | None = None
1563 #: M27 — per-fact confidence (0.0-1.0) carried through from
1564 #: :attr:`MemoryFact.confidence_score`. ``None`` for legacy rows.
1565 confidence_score: float | None = None
1566 #: M27 — when the fact was mentioned in conversation (session date),
1567 #: distinct from ``occurred_start`` (when the event happened).
1568 mentioned_at: datetime | None = None
1569 #: M31 Fix 4 — resolved absolute date for the fact's most-prominent
1570 #: event, carried through from :attr:`MemoryFact.event_date`. See
1571 #: the MemoryFact docstring for the retain-time resolution
1572 #: contract. ``None`` for legacy rows or facts whose text had no
1573 #: parseable relative phrase.
1574 event_date: datetime | None = None
1576 @property
1577 def chunk_id(self) -> str | None:
1578 """Stable composite identity of the source chunk (Hindsight parity).
1580 Mirrors :attr:`MemoryFact.chunk_id`. The bench harness uses
1581 this to look up the section text and inject it inline with
1582 the fact in the answerer's structured context.
1583 """
1584 if self.document_id is None or self.line_num is None:
1585 return None
1586 return f"{self.document_id}:{self.line_num}"
1589# ─── Backward-compat aliases (M18b post-ship rename, 2026-05-17) ───
1590#
1591# The pre-M18b names ``PageIndexFact`` / ``PageIndexFactHit`` remain
1592# usable indefinitely; new code should prefer ``MemoryFact`` /
1593# ``MemoryFactHit``. The aliases let the 15 existing call sites
1594# migrate gradually rather than in one churning commit. Both names
1595# resolve to the same dataclass — ``isinstance(x, PageIndexFact)``
1596# and ``isinstance(x, MemoryFact)`` are equivalent.
1597PageIndexFact = MemoryFact
1598PageIndexFactHit = MemoryFactHit
1601@dataclass
1602class PageIndexSectionEntity:
1603 """A (section, entity) tuple — Hindsight's unit_entities pattern at section grain."""
1605 document_id: str
1606 line_num: int
1607 entity_name: str
1610@dataclass
1611class PageIndexSectionLink:
1612 """An edge between two sections — Hindsight's memory_links pattern at section grain.
1614 ``link_type`` discriminates: 'semantic_knn' | 'causal' | 'supersedes' | 'elaborates'.
1615 See ADR-006 §4.2 and migration 015.
1616 """
1618 from_doc: str
1619 from_line: int
1620 to_doc: str
1621 to_line: int
1622 link_type: str
1623 weight: float
1626# ---------------------------------------------------------------------------
1627# PII detection (used by policy layer)
1628# ---------------------------------------------------------------------------
1631@dataclass
1632class PiiMatch:
1633 pii_type: str # "email", "phone", "ssn", "credit_card", "ip_address", …
1634 start: int
1635 end: int
1636 matched_text: str
1637 replacement: str | None = None