Coverage for astrocyte/types.py: 98%
707 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 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# ---------------------------------------------------------------------------
107# Tier 1: Graph Store
108# ---------------------------------------------------------------------------
111@dataclass
112class Entity:
113 id: str
114 name: str
115 entity_type: str # PERSON, ORG, LOCATION, …
116 aliases: list[str] | None = None
117 metadata: Metadata | None = None
118 #: Per-entity name embedding for the Hindsight-inspired entity-resolution
119 #: cascade. When present, the resolver uses cosine similarity against
120 #: candidate embeddings to decide whether two surface forms refer to the
121 #: same canonical entity, falling back to LLM disambiguation only for
122 #: genuinely ambiguous pairs. Optional and nullable — a ``None`` value
123 #: signals "no embedding tier available for this entity" and the cascade
124 #: degrades gracefully to trigram + LLM.
125 embedding: list[float] | None = None
126 #: Hindsight-parity mention count — how many times this entity has been
127 #: resolved-to during retain. Treated as a soft popularity signal in the
128 #: composite cascade (cheap tiebreaker, never a primary decider). Adapter
129 #: stores increment this on every successful canonical resolution; new
130 #: entities start at 1.
131 mention_count: int = 1
134@dataclass
135class EntityCandidateMatch:
136 """Scored candidate produced by ``GraphStore.find_entity_candidates_scored``.
138 The Hindsight-inspired entity-resolution cascade asks the graph store for
139 candidates pre-scored against four cheap signals — trigram, embedding,
140 co-occurrence, and temporal proximity — so the resolver can decide
141 whether to autolink, skip, or escalate to the LLM without paying for
142 additional database round-trips.
144 Fields:
145 entity: The candidate entity.
146 name_similarity: Trigram similarity of the candidate's name against
147 the query name in ``[0.0, 1.0]``. Adapters compute this with
148 ``pg_trgm.similarity()`` (PostgreSQL) or ``difflib.SequenceMatcher``
149 (in-memory).
150 embedding_similarity: Cosine similarity of the candidate's stored
151 embedding against the supplied query embedding in ``[0.0, 1.0]``,
152 or ``None`` when either side has no embedding stored.
153 co_occurring_names: Names of entities that co-occur with this
154 candidate via ``EntityLink(link_type="co_occurs")``. Lowercased.
155 Used by the resolver to compute overlap with the new entity's
156 nearby entities — strong evidence two surface forms refer to
157 the same canonical entity when their contexts overlap.
158 last_seen: The candidate's last-activity timestamp (typically
159 ``updated_at``). Used to compute temporal proximity to the new
160 entity's event date — recent candidates score higher for the
161 same name. ``None`` when not stored.
162 """
164 entity: Entity
165 name_similarity: float
166 embedding_similarity: float | None = None
167 co_occurring_names: list[str] = field(default_factory=list)
168 last_seen: datetime | None = None
169 #: Hindsight-parity popularity signal — how many memories this entity has
170 #: been resolved-to. Used by :meth:`EntityResolver._composite_score` as a
171 #: soft tiebreaker (capped contribution; never overrides name/cooccurrence
172 #: signals on its own).
173 mention_count: int = 1
176@dataclass
177class EntityLink:
178 """A typed relationship between two entities in the knowledge graph.
180 M11: fields renamed from ``source_entity_id``/``target_entity_id`` to
181 ``entity_a``/``entity_b`` to be direction-neutral; ``evidence``,
182 ``confidence``, and ``created_at`` added for entity resolution provenance.
183 """
185 entity_a: str
186 """ID of the first entity in the relationship."""
188 entity_b: str
189 """ID of the second entity in the relationship."""
191 link_type: str
192 """Relationship label — e.g. ``"alias_of"``, ``"co_occurs"``, ``"works_at"``."""
194 evidence: str = ""
195 """Verbatim quote from the source memory that justifies this link."""
197 confidence: float = 1.0
198 """0–1 confidence score. 1.0 = rule-derived; < 1.0 = LLM-confirmed."""
200 created_at: datetime | None = None
201 """UTC wall-clock time this link was created. None for legacy links."""
203 metadata: Metadata | None = None
204 """Optional extra key-value pairs (preserved for backward compatibility)."""
207@dataclass
208class MemoryEntityAssociation:
209 memory_id: str
210 entity_id: str
213@dataclass
214class MemoryLink:
215 """A typed directional link between two memories (Hindsight parity).
217 Distinct from :class:`EntityLink` (which connects entities). Memory
218 links capture relationships between fact-level units — the granularity
219 Hindsight's ``link_expansion_retrieval`` walks for causal chains and
220 semantic-kNN edges.
222 Three link types are first-class in the link-expansion retrieval
223 signal:
224 - ``"caused_by"`` — extracted at retain time from cause-effect text
225 ("she lost her job, so she couldn't pay rent").
226 - ``"semantic"`` — precomputed kNN (each new memory linked to its
227 top-K most similar prior memories at insert time, similarity ≥ 0.7).
228 - ``"entity_overlap"`` — query-time signal computed from shared
229 entities (not persisted; included here for documentation parity).
231 Direction matters: ``source_memory_id`` is the "from" side. For
232 ``caused_by`` semantics, the source is the EFFECT and the target is
233 the CAUSE. (Hindsight's convention; preserved here for parity.)
234 """
236 source_memory_id: str
237 target_memory_id: str
238 link_type: str
239 evidence: str = ""
240 confidence: float = 1.0
241 weight: float = 1.0
242 created_at: datetime | None = None
243 metadata: Metadata | None = None
246@dataclass
247class GraphHit:
248 memory_id: str
249 text: str
250 connected_entities: list[str]
251 depth: int
252 score: float
255# ---------------------------------------------------------------------------
256# Tier 1: Document Store
257# ---------------------------------------------------------------------------
260@dataclass
261class Document:
262 id: str
263 text: str
264 metadata: Metadata | None = None
265 tags: list[str] | None = None
268@dataclass
269class DocumentFilters:
270 tags: list[str] | None = None
271 metadata_filters: Metadata | None = None
274@dataclass
275class DocumentHit:
276 document_id: str
277 text: str
278 score: float # BM25 relevance
279 metadata: Metadata | None = None
282# ---------------------------------------------------------------------------
283# Engine Provider — requests / results
284# ---------------------------------------------------------------------------
287@dataclass
288class RetainRequest:
289 content: str
290 bank_id: str
291 metadata: Metadata | None = None
292 tags: list[str] | None = None
293 occurred_at: datetime | None = None
294 source: str | None = None
295 content_type: str = "text" # "text", "conversation", "document", "email", ...
296 extraction_profile: str | None = None # key in astrocyte.yml extraction_profiles (M3)
297 #: Optional pipeline overrides from a MIP RoutingDecision. When set, fields
298 #: take precedence over extraction profile and content_type defaults during
299 #: chunking and dedup. Persisted onto each stored chunk via ``_mip.*`` keys.
300 mip_pipeline: PipelineSpec | None = None
301 #: Name of the MIP rule whose action produced ``mip_pipeline``. Persisted on
302 #: stored chunks as ``_mip.rule`` so recall can warn on rule-version drift.
303 mip_rule_name: str | None = None
306@dataclass
307class RetainResult:
308 stored: bool
309 memory_id: str | None = None
310 deduplicated: bool = False
311 error: str | None = None
312 retention_action: str | None = None # "add" | "update" | "merge" | "skip" | "delete" (curated retain)
313 curated: bool = False # Whether LLM curation was used
314 memory_layer: str | None = None # Layer assigned during curation
317@dataclass
318class RecallRequest:
319 query: str
320 bank_id: str
321 max_results: int = 10
322 max_tokens: int | None = None
323 fact_types: list[str] | None = None
324 tags: list[str] | None = None
325 time_range: tuple[datetime, datetime] | None = None
326 include_sources: bool = False
327 layer_weights: dict[str, float] | None = None # {"fact": 1.0, "observation": 1.5, "model": 2.0}
328 detail_level: str | None = None # "titles" | "bodies" | "full" | None (default=full)
329 external_context: list[MemoryHit] | None = None # External RAG/graph results for cross-source fusion
330 as_of: datetime | None = None # Time-travel: recall as if it were this UTC moment (M9)
331 #: Reference date for resolving relative temporal phrases in the
332 #: query (``"yesterday"``, ``"last week"``, ``"3 days ago"``).
333 #: SEPARATE from ``as_of``: ``as_of`` is a ``retained_at`` time-travel
334 #: filter (M9 audit/legal-hold use case); ``query_reference_date`` is
335 #: ONLY consumed by the query analyzer to anchor relative phrases.
336 #:
337 #: When unset, the query analyzer uses ``as_of`` if set, else
338 #: ``datetime.now()`` — preserving prior behaviour. Set this on
339 #: bench/eval paths whose dataset predates the run wall-clock (e.g.
340 #: LongMemEval is 2023-vintage; running in 2026 without anchoring
341 #: makes "yesterday" wrong by ~3 years). Crucially, setting only
342 #: ``query_reference_date`` (and leaving ``as_of=None``) does NOT
343 #: filter out memories retained after that date — the entire corpus
344 #: stays in scope. See the May 2026 LME post-mortem.
345 query_reference_date: datetime | None = None
346 #: Per-call disposition override. When set, the orchestrator's
347 #: abstention decision derives from ``dispositions.skepticism``
348 #: (1=trust everything → never abstain, 5=skeptical → aggressive
349 #: abstention) — see ``_abstention_floor_for_skepticism``. Falls
350 #: back to deployment defaults when ``None``. Use this to make a
351 #: single deployment serve both adversarial-resistant agents and
352 #: trust-the-model assistants without forking the YAML config.
353 dispositions: "Dispositions | None" = None
354 #: M31 Fix 2 — opaque session identifier. When the calling
355 #: application knows which conversation session a query targets,
356 #: this scopes the underlying SPI calls (``search_facts_*`` /
357 #: ``search_sections_*``) to facts and sections whose anchoring
358 #: section has matching ``session_id``. ``None`` (default) keeps
359 #: cross-session retrieval behaviour. Plumbed from
360 #: ``RecallParams.session_id`` via ``_make_recall_request``.
361 session_id: str | None = None
364@dataclass
365class MemoryHit:
366 text: str
367 score: float # 0.0 – 1.0 relevance
368 fact_type: str | None = None
369 metadata: Metadata | None = None
370 tags: list[str] | None = None
371 occurred_at: datetime | None = None
372 source: str | None = None
373 memory_id: str | None = None
374 bank_id: str | None = None # set by multi-bank / hybrid recall
375 memory_layer: str | None = None # "fact", "observation", "model"
376 utility_score: float | None = None # 0.0 – 1.0 composite utility
377 retained_at: datetime | None = None # UTC timestamp when item was retained (M9)
378 chunk_id: str | None = None # M10: source-chunk backreference (when source_store is wired)
381@dataclass
382class RecallTrace:
383 strategies_used: list[str] | None = None
384 total_candidates: int | None = None
385 fusion_method: str | None = None
386 latency_ms: float | None = None
387 strategy_timings_ms: dict[str, float] | None = None
388 strategy_candidate_counts: dict[str, int] | None = None
389 tier_used: int | None = None # Which retrieval tier resolved the query
390 layer_distribution: dict[str, int] | None = None # {"fact": 5, "observation": 3, "model": 1}
391 cache_hit: bool | None = None # Whether recall cache was used
392 wiki_tier_used: bool | None = None # True when wiki tier satisfied the query (M8 W5)
395@dataclass
396class RecallResult:
397 hits: list[MemoryHit]
398 total_available: int
399 truncated: bool
400 trace: RecallTrace | None = None
401 #: Optional labeled sections + rules for synthesis (M7 structured recall authority).
402 authority_context: str | None = None
403 #: Top raw cosine-similarity score from the semantic strategy (0.0 when no semantic
404 #: results were found). Used by the reflect evidence-strict gate to detect uncertain
405 #: retrieval and force citation rather than letting the LLM hallucinate from
406 #: tangential memories.
407 top_semantic_score: float = 0.0
410@dataclass
411class HistoryResult:
412 """Result of ``brain.history()`` — what the agent knew at a past point in time (M9).
414 Wraps a :class:`RecallResult` and carries the ``as_of`` timestamp so
415 callers can log/display the reconstruction point without parsing the request.
416 """
418 hits: list[MemoryHit]
419 total_available: int
420 truncated: bool
421 as_of: datetime # The UTC timestamp used for the time-travel query
422 bank_id: str
423 trace: RecallTrace | None = None
426@dataclass
427class GapItem:
428 """A single knowledge gap identified by ``brain.audit()`` (M10).
430 A gap is a topic or question that the memory bank cannot answer
431 adequately — either because no memories cover it, or because coverage
432 is too thin to draw a reliable conclusion.
433 """
435 topic: str
436 """Short label for the missing or under-covered topic (e.g. ``"Alice's current role"``)."""
438 severity: Literal["high", "medium", "low"]
439 """How critical the gap is.
441 - ``"high"`` — likely to cause a wrong or confidently-wrong answer.
442 - ``"medium"`` — partial coverage; answer may be incomplete.
443 - ``"low"`` — minor; nuance or context is missing.
444 """
446 reason: str
447 """One-sentence explanation of why the gap exists."""
450@dataclass
451class AuditResult:
452 """Result of ``brain.audit()`` — structured gap analysis for a scope (M10).
454 Summarises what the agent *doesn't* know about a given topic, together
455 with a 0–1 coverage score and provenance counts.
456 """
458 scope: str
459 """The scope string passed to ``brain.audit()``."""
461 bank_id: str
462 """The bank that was audited."""
464 gaps: list[GapItem]
465 """Identified knowledge gaps, ordered roughly by severity."""
467 coverage_score: float
468 """0–1 composite score (memory density × recency × topic breadth).
470 1.0 means the bank covers the scope well; < 0.5 indicates sparse coverage.
471 """
473 memories_scanned: int
474 """Number of memories retrieved and fed to the audit judge."""
476 trace: RecallTrace | None = None
477 """Diagnostic trace from the recall pass, if available."""
480@dataclass
481class Dispositions:
482 """Personality modifiers for synthesis."""
484 skepticism: int = 3 # 1 (trusting) to 5 (skeptical)
485 literalism: int = 3 # 1 (flexible) to 5 (rigid)
486 empathy: int = 3 # 1 (detached) to 5 (empathetic)
488 def __post_init__(self) -> None:
489 for field_name in ("skepticism", "literalism", "empathy"):
490 val = getattr(self, field_name)
491 if not (1 <= val <= 5):
492 raise ValueError(f"Dispositions.{field_name} must be 1–5, got {val}")
495@dataclass
496class ReflectRequest:
497 query: str
498 bank_id: str
499 max_tokens: int | None = None
500 include_sources: bool = True
501 dispositions: Dispositions | None = None
502 #: Optional tag filter forwarded to the dispatcher's internal recall.
503 #: When set, reflect's underlying retrieval is scoped to memories
504 #: carrying every listed tag — closing the leak where single-bank
505 #: ``Astrocyte.reflect(tags=...)`` previously dropped the filter.
506 tags: list[str] | None = None
507 #: M9 time-travel anchor: filters reflect's underlying recall to
508 #: ``retained_at <= as_of``. Use for legal-hold / audit replay
509 #: ("show me what the system knew at time X"). NOT for anchoring
510 #: relative phrases — see ``query_reference_date`` below for that.
511 as_of: datetime | None = None
512 #: Reference date for resolving relative temporal phrases in the
513 #: query (``"yesterday"``, ``"last week"``, ``"3 days ago"``).
514 #: SEPARATE from ``as_of``: setting ONLY this anchors phrase
515 #: resolution without filtering out memories retained after that
516 #: date. Required for benchmarks whose dataset predates the run
517 #: wall-clock — e.g. LongMemEval (2023-vintage) measured in 2026
518 #: must set ``query_reference_date=question_date`` so "yesterday"
519 #: resolves to question_date - 1 day, not run_wall_clock - 1 day.
520 #: Mirrors ``RecallRequest.query_reference_date``; the orchestrator
521 #: forwards this into every sub_recall the reflect path builds.
522 query_reference_date: datetime | None = None
525@dataclass
526class ReflectResult:
527 answer: str
528 confidence: float | None = None
529 sources: list[MemoryHit] | None = None
530 observations: list[str] | None = None
531 #: Same structured block as :attr:`RecallResult.authority_context` when reflect used recall authority.
532 authority_context: str | None = None
535@dataclass
536class ForgetRequest:
537 bank_id: str
538 memory_ids: list[str] | None = None
539 tags: list[str] | None = None
540 before_date: datetime | None = None
541 scope: str | None = None # "all" or None for selective
544@dataclass
545class ForgetResult:
546 deleted_count: int
547 archived_count: int = 0
550# ---------------------------------------------------------------------------
551# Engine capabilities
552# ---------------------------------------------------------------------------
555@dataclass(frozen=True)
556class EngineCapabilities:
557 supports_reflect: bool = False
558 supports_forget: bool = False
559 supports_semantic_search: bool = True
560 supports_keyword_search: bool = False
561 supports_graph_search: bool = False
562 supports_temporal_search: bool = False
563 supports_dispositions: bool = False
564 supports_consolidation: bool = False
565 supports_entities: bool = False
566 supports_tags: bool = False
567 supports_metadata: bool = True
568 supports_compile: bool = False # M8: wiki compile via brain.compile()
569 max_retain_bytes: int | None = None
570 max_recall_results: int | None = None
571 max_embedding_dims: int | None = None
574# ---------------------------------------------------------------------------
575# LLM Provider
576# ---------------------------------------------------------------------------
579@dataclass
580class ContentPart:
581 """Tagged union for multimodal content."""
583 type: str # "text", "image_url", "image_base64", "audio_url", "audio_base64"
584 text: str | None = None
585 image_url: str | None = None
586 image_base64: str | None = None
587 audio_url: str | None = None
588 audio_base64: str | None = None
589 media_type: str | None = None # MIME type for base64 content, e.g. "image/png", "image/jpeg"
592@dataclass
593class Message:
594 role: str # "system", "user", "assistant", "tool"
595 content: str | list[ContentPart] = ""
596 #: When ``role == "assistant"`` and the model emitted tool calls, the
597 #: provider adapter places them here so downstream consumers can
598 #: round-trip them back into a follow-up turn (along with the
599 #: matching ``role="tool"`` results).
600 tool_calls: list[ToolCall] | None = None
601 #: When ``role == "tool"``, the OpenAI / Anthropic spec requires this
602 #: field to point back at the originating ``ToolCall.id``. The provider
603 #: adapter uses it to reconstruct the wire-format tool result message.
604 tool_call_id: str | None = None
605 #: When ``role == "tool"``, the human-readable tool name. Carried for
606 #: providers (and for our own logging) that surface the name on
607 #: tool result messages.
608 name: str | None = None
611@dataclass
612class TokenUsage:
613 input_tokens: int
614 output_tokens: int
617#: JSON-shaped value type for tool-call arguments and JSON Schema —
618#: replaces ``Any`` to satisfy the FFI-safety constraint on DTOs
619#: (see :data:`Metadata` for the same idiom on memory metadata).
620JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
623@dataclass
624class ToolCall:
625 """A single tool invocation requested by an LLM during native function calling.
627 Mirrors the OpenAI/Anthropic tool-call shape: each call carries an
628 opaque ``id`` (for round-tripping a tool result back to the model),
629 a ``name`` matching one of the tools provided in the request, and
630 the model's chosen ``arguments`` as a parsed dict.
632 The agentic reflect loop (Hindsight parity) consumes these instead
633 of parsing JSON out of the response text — significantly more
634 reliable than the JSON-in-prose protocol.
635 """
637 id: str
638 name: str
639 arguments: dict[str, JsonValue]
642@dataclass
643class ToolDefinition:
644 """A tool the LLM can call. JSON-Schema-shaped, OpenAI-compatible.
646 ``parameters`` is a JSON Schema object; the provider adapter is
647 responsible for translating to the wire format the underlying API
648 expects (OpenAI: ``tools=[{"type": "function", "function": {...}}]``;
649 Anthropic: ``tools=[{"name": ..., "input_schema": ...}]``).
650 """
652 name: str
653 description: str
654 parameters: dict[str, JsonValue]
657@dataclass
658class Completion:
659 text: str
660 model: str
661 usage: TokenUsage | None = None
662 #: Tool calls the LLM emitted, when ``tools`` were supplied to
663 #: :meth:`LLMProvider.complete`. ``None`` means the provider did
664 #: not produce tool calls (or doesn't support them — feature-detect
665 #: with ``getattr`` rather than assuming).
666 tool_calls: list[ToolCall] | None = None
669@dataclass(frozen=True)
670class LLMCapabilities:
671 supports_multimodal_completion: bool = False
672 modalities_supported: tuple[str, ...] | None = None
673 supports_multimodal_embedding: bool = False
674 supports_batch_embed: bool = True
677# ---------------------------------------------------------------------------
678# Outbound Transport
679# ---------------------------------------------------------------------------
682@dataclass
683class HttpClientContext:
684 proxy: str | None = None
685 ca_bundle: str | None = None
686 headers: dict[str, str] | None = None
687 timeouts: dict[str, float] | None = None
690@dataclass(frozen=True)
691class TransportCapabilities:
692 supports_proxy: bool = False
693 supports_custom_ca: bool = False
694 supports_client_cert: bool = False
695 supports_headers: bool = False
698# ---------------------------------------------------------------------------
699# Multi-bank orchestration
700# ---------------------------------------------------------------------------
703@dataclass
704class MultiBankStrategy:
705 """Multi-bank recall behavior. Default ``parallel`` matches legacy ``banks=[...]`` without an explicit strategy."""
707 mode: Literal["cascade", "parallel", "first_match"] = "parallel"
708 min_results_to_stop: int = 3
709 cascade_order: list[str] | None = None
710 bank_weights: dict[str, float] | None = None
711 dedup_across_banks: bool = True
714# ---------------------------------------------------------------------------
715# Access control
716# ---------------------------------------------------------------------------
719_VALID_PERMISSIONS = {"read", "write", "forget", "admin", "*"}
722@dataclass
723class AccessGrant:
724 bank_id: str # or "*"
725 principal: str # or "*"
726 permissions: list[str] # ["read", "write", "forget", "admin"]
728 def __post_init__(self) -> None:
729 invalid = set(self.permissions) - _VALID_PERMISSIONS
730 if invalid:
731 raise ValueError(f"AccessGrant.permissions contains invalid values: {invalid}")
734@dataclass
735class ActorIdentity:
736 """Structured actor for access control and bank resolution (ADR-002)."""
738 type: str # "user" | "agent" | "service"
739 id: str
740 claims: dict[str, str] | None = None
743@dataclass
744class AstrocyteContext:
745 """Caller identity for access control.
747 ``principal`` remains the backwards-compatible primary string. When ``actor``
748 is set, identity resolution uses ``actor`` (and optional ``on_behalf_of`` for OBO);
749 ``principal`` is still useful for logging and integrations that have not migrated.
750 """
752 principal: str # e.g. "agent:support-bot-1", "user:calvin"
753 actor: ActorIdentity | None = None
754 on_behalf_of: ActorIdentity | None = None
755 tenant_id: str | None = None
758# ---------------------------------------------------------------------------
759# Event hooks
760# ---------------------------------------------------------------------------
763@dataclass
764class HookEvent:
765 event_id: str
766 type: str # e.g. "on_retain", "on_pii_detected"
767 timestamp: datetime
768 bank_id: str | None = None
769 data: Metadata | None = None
770 trace_id: str | None = None
773# ---------------------------------------------------------------------------
774# Data governance
775# ---------------------------------------------------------------------------
778@dataclass
779class DataClassification:
780 level: int # 0-3
781 label: str # "public", "internal", "confidential", "restricted"
782 categories: list[str] | None = None # ["PII", "PHI", …] for restricted
783 classified_by: str = "rules" # "caller", "rules", "llm"
784 classified_at: datetime | None = None
787# ---------------------------------------------------------------------------
788# Lifecycle / audit
789# ---------------------------------------------------------------------------
792@dataclass
793class LegalHold:
794 hold_id: str
795 bank_id: str
796 reason: str
797 set_at: datetime
798 set_by: str # "user:api", "system:compliance"
801@dataclass
802class LifecycleAction:
803 """Result of a lifecycle TTL evaluation on a single memory."""
805 memory_id: str
806 action: str # "archive" | "delete" | "keep"
807 reason: str # "ttl_unretrieved" | "ttl_archived_expired" | "recent" | "exempt" | "legal_hold"
810@dataclass
811class LifecycleRunResult:
812 archived_count: int
813 deleted_count: int
814 skipped_count: int
815 actions: list[LifecycleAction]
818@dataclass
819class AuditEvent:
820 event_type: str
821 bank_id: str
822 actor: str # "system:ttl", "user:api", "compliance:forget", …
823 timestamp: datetime
824 memory_ids: list[str] | None = None
825 reason: str | None = None
826 metadata: Metadata | None = None
829@dataclass
830class ForgetSelector:
831 bank_ids: list[str]
832 scope: str | None = None # "all" or None for selective
833 tags: list[str] | None = None
834 before_date: datetime | None = None
835 memory_ids: list[str] | None = None
838# ---------------------------------------------------------------------------
839# Analytics
840# ---------------------------------------------------------------------------
843@dataclass
844class HealthIssue:
845 severity: Literal["info", "warning", "critical"]
846 code: str
847 message: str
848 recommendation: str
851@dataclass
852class BankHealth:
853 bank_id: str
854 score: float # 0.0 – 1.0
855 status: Literal["healthy", "warning", "unhealthy"]
856 issues: list[HealthIssue]
857 metrics: dict[str, float]
858 assessed_at: datetime
861@dataclass
862class MemoryUsage:
863 memory_id: str
864 text: str
865 recall_count: int
866 last_recalled_at: datetime
869@dataclass
870class QualityDataPoint:
871 date: date
872 retain_count: int
873 recall_count: int
874 recall_hit_rate: float
875 avg_recall_score: float
876 dedup_rate: float
877 reflect_success_rate: float
880@dataclass
881class UtilizationReport:
882 bank_id: str
883 total_memories: int
884 active_memories: int # recalled >= 1x in last 30 days
885 stale_memories: int # never recalled in 30 days
886 never_recalled: int
887 top_recalled: list[MemoryUsage]
888 fact_type_distribution: dict[str, int]
889 tag_distribution: dict[str, int]
890 assessed_at: datetime
893@dataclass
894class QualityTrends:
895 bank_id: str
896 data_points: list[QualityDataPoint]
899# ---------------------------------------------------------------------------
900# Evaluation
901# ---------------------------------------------------------------------------
904@dataclass
905class EvalMetrics:
906 recall_precision: float
907 recall_hit_rate: float
908 recall_mrr: float
909 recall_ndcg: float
910 retain_latency_p50_ms: float
911 retain_latency_p95_ms: float
912 recall_latency_p50_ms: float
913 recall_latency_p95_ms: float
914 total_tokens_used: int
915 total_duration_seconds: float
916 reflect_accuracy: float | None = None
917 reflect_completeness: float | None = None
918 reflect_hallucination_rate: float | None = None
919 reflect_latency_p50_ms: float | None = None
920 reflect_latency_p95_ms: float | None = None
921 # Tier-3 metrics: populated by ``BenchmarkMetricsCollector`` when the
922 # bench wires it up. All optional so legacy callers stay valid.
924 # ── Quality ──────────────────────────────────────────────
925 #: Fraction of questions where at least one retrieved memory had
926 #: token-overlap ≥ threshold with the gold answer. Distinguishes
927 #: "missed evidence" from "had evidence, synthesised wrong".
928 recall_coverage: float | None = None
929 #: Cross-tab of (recall_hit, reflect_hit) outcomes. Keys:
930 #: ``"both_hit"``, ``"recall_hit_reflect_miss"``,
931 #: ``"recall_miss_reflect_hit"``, ``"both_miss"``.
932 recall_reflect_gap: dict[str, int] | None = None
933 #: For adversarial questions: fraction where reflect correctly abstained
934 #: ("Insufficient evidence" / "I don't have …" patterns) instead of
935 #: hallucinating an answer.
936 abstention_rate_adversarial: float | None = None
938 # ── Cost & efficiency ────────────────────────────────────
939 #: Tokens by pipeline phase. Keys: ``"retain"``, ``"eval"``,
940 #: ``"persona_compile"``, ``"observation_consolidation"``, ``"other"``.
941 tokens_by_phase: dict[str, int] | None = None
942 #: Total HTTP API calls across the run.
943 api_calls_total: int | None = None
944 #: HTTP API calls by endpoint. Typical keys: ``"chat/completions"``,
945 #: ``"embeddings"``.
946 api_calls_by_endpoint: dict[str, int] | None = None
947 #: Total cost in USD computed from per-model pricing × actual tokens.
948 cost_total_usd: float | None = None
949 #: Mean cost per question.
950 cost_per_question_usd: float | None = None
952 # ── Latency (tail) ───────────────────────────────────────
953 retain_latency_p99_ms: float | None = None
954 recall_latency_p99_ms: float | None = None
955 reflect_latency_p99_ms: float | None = None
956 #: Median end-to-end question time (recall + reflect).
957 e2e_per_question_p50_ms: float | None = None
958 e2e_per_question_p95_ms: float | None = None
960 # ── Robustness ───────────────────────────────────────────
961 #: Counts of categorised errors. Typical keys: ``"pool_timeout"``,
962 #: ``"deadlock"``, ``"openai_429"``, ``"openai_5xx"``, ``"other"``.
963 error_count_by_type: dict[str, int] | None = None
964 #: Number of OpenAI retries triggered (rate limits, transient errors).
965 openai_retry_count: int | None = None
966 #: Number of retain calls that returned ``stored=False`` or raised.
967 failed_retain_count: int | None = None
969 # ── Memory-architecture (cascade observability) ──────────
970 #: Counts of entity-resolution cascade decisions. Typical keys:
971 #: ``"trigram_autolink"``, ``"embedding_autolink"``,
972 #: ``"composite_autolink"``, ``"llm_disambiguation"``, ``"skipped"``,
973 #: ``"created_new"``.
974 cascade_decisions: dict[str, int] | None = None
975 #: Number of new entities resolved to an existing canonical via Path B
976 #: (count of pre-store ID rewrites).
977 entities_resolved_count: int | None = None
978 #: Number of new entities that became fresh canonicals (no match).
979 entities_created_count: int | None = None
980 #: Histogram of composite scores keyed by bucket label
981 #: (``"0.0-0.1"``, ``"0.1-0.2"``, …, ``"0.9-1.0"``).
982 composite_score_distribution: dict[str, int] | None = None
983 #: Total wiki-page rows in the bank at end-of-run.
984 wiki_pages_total: int | None = None
985 #: Mean number of wiki pages per unique persona name. >1 indicates
986 #: scoping (per-conversation pages); 1 indicates single canonical.
987 wiki_pages_per_persona: float | None = None
990@dataclass
991class QueryResult:
992 query: str
993 expected: list[str]
994 actual: list[MemoryHit]
995 relevant_found: int
996 precision: float
997 reciprocal_rank: float
998 latency_ms: float
1001@dataclass
1002class EvalResult:
1003 suite: str
1004 provider: str
1005 provider_tier: str
1006 timestamp: datetime
1007 metrics: EvalMetrics
1008 per_query_results: list[QueryResult]
1009 config_snapshot: Metadata | None = None
1011 def to_dict(self) -> dict[str, object]:
1012 """Serialize to a JSON-safe dict (datetime → ISO 8601 string)."""
1014 def _convert(obj: object) -> object:
1015 if isinstance(obj, (datetime, date)):
1016 return obj.isoformat()
1017 return obj
1019 raw = asdict(self)
1020 return _json.loads(_json.dumps(raw, default=_convert))
1022 def to_json(self, *, indent: int = 2) -> str:
1023 """Serialize to a JSON string."""
1024 return _json.dumps(self.to_dict(), indent=indent)
1027@dataclass
1028class RegressionAlert:
1029 metric: str
1030 current_value: float
1031 baseline_value: float
1032 delta: float
1033 delta_percent: float
1034 severity: Literal["warning", "critical"]
1037# ---------------------------------------------------------------------------
1038# MIP routing
1039# ---------------------------------------------------------------------------
1042@dataclass
1043class RoutingDecision:
1044 """Output of MIP routing — tells Astrocyte where/how to store."""
1046 bank_id: str | None = None
1047 tags: list[str] | None = None
1048 retain_policy: str | None = None # "default" | "redact_before_store" | "encrypt" | "reject"
1049 resolved_by: str = "passthrough" # "mechanical" | "intent" | "passthrough"
1050 rule_name: str | None = None
1051 confidence: float = 1.0
1052 reasoning: str | None = None # LLM justification if intent layer used
1053 pipeline: PipelineSpec | None = None # Optional pipeline-shaping overrides from rule
1054 forget: ForgetSpec | None = None # Optional forget-policy overrides from rule (Phase 4)
1055 observability_tags: list[str] | None = None # Per-rule operator labels (Phase 5)
1058# ---------------------------------------------------------------------------
1059# M8: Wiki Compile
1060# ---------------------------------------------------------------------------
1062WikiPageKind = Literal["entity", "topic", "concept"]
1065@dataclass
1066class WikiPage:
1067 """A compiled topic/entity/concept page synthesised from raw memories (M8).
1069 WikiPages are additive artefacts — raw memories are never removed when a
1070 page is compiled. Each page carries ``source_ids`` back to every raw memory
1071 that contributed, enabling provenance tracing and recompile-on-forget.
1073 Pages are mutable: each compile pass produces a new revision. Past revisions
1074 are kept in the WikiStore audit log (not indexed for recall).
1075 """
1077 page_id: str # Stable ID, e.g. "topic:incident-response", "entity:alice"
1078 bank_id: str
1079 kind: WikiPageKind # "entity" | "topic" | "concept"
1080 title: str
1081 content: str # LLM-maintained markdown
1082 scope: str # Scope string used for this compile (tag name or cluster label)
1083 source_ids: list[str] # Raw memory IDs that contributed (provenance)
1084 cross_links: list[str] # Other page_ids referenced in this page
1085 revision: int # Monotonically increasing, starts at 1
1086 revised_at: datetime
1087 tags: list[str] | None = None # Inherited from contributing memories
1088 metadata: Metadata | None = None
1091@dataclass
1092class WikiPageHit:
1093 """A wiki page returned from a semantic search during recall tiering."""
1095 page_id: str
1096 title: str
1097 content: str
1098 scope: str
1099 kind: str
1100 score: float # 0.0 – 1.0 similarity
1101 source_ids: list[str]
1102 bank_id: str
1105@dataclass(frozen=True)
1106class MentalModel:
1107 """A first-class curated saved-reflect summary.
1109 Mental models are durable, refreshable artifacts — the "Caroline
1110 prefers async updates" / "Project X status: blocked on review" rows
1111 that outlive any single recall and serve as authoritative summaries
1112 when the recall pipeline elects to use the compiled layer.
1114 Stored in the dedicated :class:`~astrocyte.provider.MentalModelStore`
1115 SPI (formerly piggybacked on :class:`WikiStore` with
1116 ``kind="concept"`` + ``metadata["_mental_model"] = True``; that
1117 discriminator pattern was an architecture smell that we cut to a
1118 proper table in v1.x — see ``docs/_plugins/benchmark-presets.md``).
1120 Attributes:
1121 model_id: Stable identifier within the bank (e.g.
1122 ``"model:alice-prefs"``).
1123 bank_id: Tenant-scoped bank identifier.
1124 title: Human-readable display title.
1125 content: The summary body, typically markdown.
1126 scope: Scope key — ``"bank"`` for bank-wide models, or a
1127 specific tag like ``"person:alice"`` to scope to a topic.
1128 source_ids: Raw memory IDs that contributed to this summary
1129 (provenance — enables refresh-on-forget).
1130 revision: Monotonically increasing version number, starts at 1.
1131 Bumped by ``MentalModelStore.upsert`` on each refresh.
1132 refreshed_at: Timestamp of the most recent refresh.
1133 kind: Sub-type discriminator (M14.6). Defaults to ``"general"``
1134 for backwards compatibility with M11.2-era rows. Other
1135 values: ``"preference"`` for consolidated user-preference
1136 rows produced by ``astrocyte.pipeline.preference_compile``.
1137 Mirrors Hindsight's ``mental_models.subtype`` column.
1138 """
1140 model_id: str
1141 bank_id: str
1142 title: str
1143 content: str
1144 scope: str
1145 source_ids: list[str]
1146 revision: int
1147 refreshed_at: datetime
1148 kind: str = "general"
1149 #: M21 — structured representation of the document, stored as
1150 #: JSON-shaped dict (the :meth:`pydantic.BaseModel.model_dump`
1151 #: of :class:`astrocyte.pipeline.structured_doc.StructuredDocument`).
1152 #: When ``None`` (legacy rows), callers may lazy-migrate by parsing
1153 #: ``content`` via
1154 #: :func:`astrocyte.pipeline.structured_doc.parse_markdown`. New
1155 #: writes through :class:`~astrocyte.provider.MentalModelStore`
1156 #: populate this field; the ``content`` column stays as the
1157 #: rendered markdown for backwards-compatible reads. Stored on
1158 #: Postgres as ``structured_doc JSONB`` (see migration 032).
1159 #:
1160 #: Type is plain ``dict | None`` for FFI safety (per the file-level
1161 #: rule: no ``Any``, no callables, no generators); use
1162 #: ``StructuredDocument.model_validate(model.structured_doc)`` to
1163 #: get the typed object in-process.
1164 structured_doc: dict | None = None
1165 #: M40 — Per-source-id evidence timestamps, parallel to
1166 #: :attr:`source_ids`. Captures *when* each contributing memory was
1167 #: observed (in the conversation's reference timeline, not wall-
1168 #: clock-now), so that :func:`astrocyte.pipeline.trend.compute_trend`
1169 #: can classify the model as STABLE / STRENGTHENING / WEAKENING /
1170 #: NEW / STALE relative to a query's reference date.
1171 #:
1172 #: Mirrors the ``_obs_source_timestamps`` pattern on observations
1173 #: (see :mod:`astrocyte.pipeline.observation`). When ``None`` (legacy
1174 #: rows pre-M40), trend computation falls back to ``refreshed_at`` as
1175 #: a single-point timestamp — coarse but never garbage. New writes
1176 #: populate this field at consolidation time.
1177 #:
1178 #: Stored on Postgres as ``source_timestamps TIMESTAMPTZ[]`` (see
1179 #: migration 037). Length must equal ``len(source_ids)`` when set —
1180 #: the lists are positionally aligned.
1181 source_timestamps: list[datetime] | None = None
1184# ---------------------------------------------------------------------------
1185# Source documents and chunks (M10 — provenance + dedup)
1186# ---------------------------------------------------------------------------
1187#
1188# Three-layer hierarchy: SourceDocument → SourceChunk → VectorItem.
1189#
1190# Pre-M10, retained memories were anonymous flat rows in
1191# ``astrocyte_vectors`` with no record of where they came from.
1192# M10 introduces the optional :class:`~astrocyte.provider.SourceStore`
1193# SPI which lets callers preserve the source ↔ chunk ↔ memory chain so
1194# that:
1195#
1196# 1. **Provenance** — every memory traces back through its chunk to its
1197# originating document
1198# 2. **Dedup** — content_hash on documents and chunks prevents storing
1199# duplicate sources twice
1200# 3. **Re-extraction** — chunks can be re-processed without losing the
1201# original document text
1202# 4. **Source attribution** — recall results can answer "this answer
1203# came from these chunks of these documents"
1204#
1205# Backward compat: existing memories without a chunk parent (chunk_id IS
1206# NULL on astrocyte_vectors) continue to work unchanged. Adoption is
1207# entirely opt-in at the call site.
1210@dataclass(frozen=True)
1211class SourceDocument:
1212 """A top-level source document we ingested into a bank.
1214 The "source" of one or more :class:`SourceChunk` rows, which in turn
1215 are the source of one or more memory rows in the vector store.
1217 Attributes:
1218 id: Stable identifier (caller-supplied, must be unique per bank).
1219 bank_id: Tenant-scoped bank identifier.
1220 title: Optional human-readable title (e.g. file name, page title).
1221 source_uri: Optional pointer back to where this document originated
1222 (URL, file path, message-bus offset, etc.). Free-form.
1223 content_hash: Optional SHA-256 (hex) of the ORIGINAL document
1224 content — used by :meth:`SourceStore.find_document_by_hash`
1225 for dedup before re-ingest.
1226 content_type: Optional MIME type (``text/plain``,
1227 ``application/pdf``, etc.).
1228 metadata: Free-form JSON-serialisable metadata.
1229 created_at: When the document was first stored (assigned by the
1230 store; placeholder values from callers are overwritten).
1231 """
1233 id: str
1234 bank_id: str
1235 title: str | None = None
1236 source_uri: str | None = None
1237 content_hash: str | None = None
1238 content_type: str | None = None
1239 metadata: Metadata | None = None
1240 created_at: datetime | None = None
1243@dataclass(frozen=True)
1244class SourceChunk:
1245 """A chunk of a :class:`SourceDocument`.
1247 Sized + ordered by the chunking strategy used at retain time
1248 (paragraph / sentence / fixed-token / etc.). One chunk typically
1249 produces one memory in the vector store, but the relationship is
1250 1:N — a chunk can produce zero memories (e.g. policy filtered) or
1251 multiple (e.g. structured-fact extraction yielded N facts).
1253 Attributes:
1254 id: Stable identifier (caller-supplied OR generated by chunker).
1255 bank_id: Tenant-scoped bank identifier.
1256 document_id: ``SourceDocument.id`` this chunk belongs to.
1257 chunk_index: Ordering within the document, starts at 0.
1258 text: The chunk's text content.
1259 content_hash: SHA-256 (hex) of ``text`` — used for dedup so the
1260 same exact chunk ingested twice doesn't double up.
1261 metadata: Free-form metadata (e.g. character offsets, page
1262 numbers, speaker tags from chat transcripts).
1263 created_at: When the chunk was first stored.
1264 """
1266 id: str
1267 bank_id: str
1268 document_id: str
1269 chunk_index: int
1270 text: str
1271 content_hash: str | None = None
1272 metadata: Metadata | None = None
1273 created_at: datetime | None = None
1276@dataclass
1277class CompileScope:
1278 """A resolved compile scope — either from a tag or a DBSCAN cluster label."""
1280 scope: str # Scope string (tag name or cluster label)
1281 source: Literal["tag", "cluster", "explicit"] # How it was discovered
1282 memory_ids: list[str] # Memory IDs belonging to this scope
1285@dataclass
1286class CompileRequest:
1287 bank_id: str
1288 scope: str | None = None # If None, triggers full scope discovery (§3.2)
1291@dataclass
1292class CompileResult:
1293 """Result of a brain.compile() call."""
1295 bank_id: str
1296 scopes_compiled: list[str] # Scope strings that produced wiki pages
1297 pages_created: int
1298 pages_updated: int
1299 noise_memories: int # Untagged memories DBSCAN could not cluster (held for next cycle)
1300 tokens_used: int
1301 elapsed_ms: int
1302 error: str | None = None
1305# ---------------------------------------------------------------------------
1306# Section recall (M9): PageIndex tree + section graph
1307# ---------------------------------------------------------------------------
1310@dataclass
1311class PageIndexDocument:
1312 """One conversation/document in the PageIndex tier.
1314 Holds the canonical markdown so the picker can slice section excerpts
1315 by line_num without round-tripping to a separate document store, plus
1316 the reference_date used by the synthesiser to resolve relative phrases
1317 like "two months ago".
1318 """
1320 id: str # UUID-stringly typed for storage-adapter portability
1321 bank_id: str
1322 source_id: str # e.g. "conv-26", "lme-user-42"
1323 md_text: str
1324 reference_date: datetime | None = None
1325 built_at: datetime | None = None
1328@dataclass
1329class PageIndexSection:
1330 """One tree node in the PageIndex tier — the M9 recall primitive.
1332 The picker fetches a list of these (without ``summary_embedding``,
1333 which is only needed by the semantic-strategy SQL) as the "skeleton"
1334 it navigates over.
1335 """
1337 document_id: str
1338 line_num: int
1339 node_id: str # PageIndex's "0001"-style id
1340 title: str
1341 summary: str | None = None
1342 summary_embedding: list[float] | None = None
1343 speaker: str | None = None # 'user' | 'assistant' | None — LME assistant-recall
1344 session_date: datetime | None = None
1345 parent_node: str | None = None
1346 depth: int = 0
1347 # M11.1: structured event time the section's most-prominent
1348 # discussed event happened. Different from ``session_date`` —
1349 # "Yesterday I went to the doctor" in a session dated May 8
1350 # has ``session_date=May 8`` but ``occurred_start=May 7``.
1351 # Populated by per-section LLM extraction at retain time;
1352 # ``None`` when the LLM didn't surface a specific event.
1353 occurred_start: datetime | None = None
1354 occurred_end: datetime | None = None
1355 # M31 Fix 2 — opaque session identifier propagated from the source
1356 # conversation. Real systems pass session_id when the application
1357 # knows which session a query is about; the recall path filters to
1358 # facts/sections whose session_id matches. ``None`` for document-
1359 # ingest paths (markdown docs have no session concept). Stored as
1360 # TEXT column ``session_id`` on ``astrocyte_pi_sections`` via
1361 # migration 035.
1362 session_id: str | None = None
1365@dataclass
1366class MemoryFact:
1367 """Astrocyte's fact-grain memory unit — the Memory Engine analogue of
1368 Hindsight's :class:`MemoryUnit`.
1370 Originally introduced as ``PageIndexFact`` in M12.1 when fact-grain
1371 was first added bolted to PageIndex sections (one section = one chat
1372 session). Renamed in the M18b post-ship cleanup (2026-05-17) to
1373 reflect its actual role: this is the Memory Engine's primary fact
1374 type, not a Document-Engine concept. ``PageIndexFact`` remains as
1375 a deprecated alias at the bottom of this file.
1377 **Section anchoring is now OPTIONAL** (was NOT NULL pre-M18b
1378 cleanup, migration 029). Facts ingested through a
1379 Document/Conversation Engine path get a ``(document_id, line_num)``
1380 section anchor for cheap context-expansion and cascade-delete
1381 semantics. Facts written directly via a future top-level retain
1382 API (Hindsight-parity) can leave both fields ``None`` — Hindsight's
1383 ``MemoryUnit.document_id`` is similarly nullable.
1385 Anchored facts (typical today):
1386 - ``document_id``: parent tree document UUID
1387 - ``line_num``: section position within the tree
1388 - FK to ``astrocyte_pi_sections``; ON DELETE CASCADE applies
1390 Top-level facts (Hindsight-parity, future use):
1391 - ``document_id = None``, ``line_num = None``
1392 - Live directly under the bank; no cascade source
1393 """
1395 id: str
1396 bank_id: str
1397 text: str
1398 fact_type: str # 'experience' | 'preference' | 'world' | 'plan' | 'opinion' (M25: assistant_statement deprecated → experience+speaker='assistant')
1399 document_id: str | None = None
1400 line_num: int | None = None
1401 speaker: str | None = None
1402 occurred_start: datetime | None = None
1403 occurred_end: datetime | None = None
1404 entities: list[str] | None = None
1405 embedding: list[float] | None = None
1406 #: M27 — per-fact confidence (0.0-1.0) emitted by the extraction LLM.
1407 #: ``None`` for legacy rows + facts where the LLM did not emit one.
1408 #: Surfaced in the answerer context so the model can hedge / abstain
1409 #: on low-confidence facts and trust high-confidence ones. Mirrors
1410 #: Hindsight's ``memory_units.confidence_score`` (CHECK 0..1).
1411 confidence_score: float | None = None
1412 #: M27 — when the fact was MENTIONED in conversation, distinct from
1413 #: ``occurred_start`` (when the event happened). For section-anchored
1414 #: facts this is the section's ``session_date``. ``None`` for
1415 #: top-level facts or legacy rows. Hindsight calls this
1416 #: ``mentioned_at`` on memory_units; the prompt explicitly tells
1417 #: the answerer to distinguish "when it happened" from "when it
1418 #: was discussed".
1419 mentioned_at: datetime | None = None
1420 #: M31 Fix 4 — single resolved absolute date for the fact's most-
1421 #: prominent event. Populated at retain time by
1422 #: :func:`astrocyte.pipeline.temporal_resolution.resolve_event_date`
1423 #: when the fact's text contains a relative phrase ("last Tuesday",
1424 #: "3 days ago"), parsed against the section's ``session_date``.
1425 #: Distinct from ``occurred_start`` / ``occurred_end`` (LLM-emitted
1426 #: explicit event-time range) and ``mentioned_at`` (the discussion
1427 #: date). ``None`` when no relative date phrase is detected, when
1428 #: the section has no anchor, or for legacy rows. Surfaced in the
1429 #: answerer context so gpt-4o-mini doesn't have to do date math
1430 #: at query time — the 60-70% LME temporal-reasoning ceiling we
1431 #: kept hitting.
1432 event_date: datetime | None = None
1434 @property
1435 def chunk_id(self) -> str | None:
1436 """Stable composite identity of the source chunk (Hindsight parity).
1438 The Astrocyte equivalent of Hindsight's ``memory_units.chunk_id``
1439 FK. Composed from ``(document_id, line_num)`` — the same
1440 positional anchor that already links facts to their
1441 :class:`PageIndexSection` source. Returns ``None`` for
1442 top-level facts that have no document anchor.
1444 The format ``"<document_id>:<line_num>"`` is stable across
1445 runs as long as the document's section tree is unchanged.
1446 Downstream consumers (notably the bench harness's
1447 ``format_context_structured`` answerer prompt) use this id
1448 to pair each fact with its source-chunk text inline,
1449 mirroring Hindsight's ``_format_context_structured`` output.
1451 See ``docs/_design/`` (M24 architectural fix) for why an
1452 explicit identity surface matters even though the underlying
1453 ``(document_id, line_num)`` pair is unchanged.
1454 """
1455 if self.document_id is None or self.line_num is None:
1456 return None
1457 return f"{self.document_id}:{self.line_num}"
1460@dataclass
1461class MemoryFactHit:
1462 """A :class:`MemoryFact` returned from a fact-grain search strategy.
1464 Section anchor fields (``document_id``, ``line_num``) are nullable
1465 — top-level facts (no document anchor) return ``None`` for both.
1466 """
1468 fact_id: str
1469 text: str
1470 fact_type: str
1471 speaker: str | None
1472 occurred_start: datetime | None
1473 occurred_end: datetime | None
1474 entities: list[str]
1475 score: float
1476 document_id: str | None = None
1477 line_num: int | None = None
1478 #: M27 — per-fact confidence (0.0-1.0) carried through from
1479 #: :attr:`MemoryFact.confidence_score`. ``None`` for legacy rows.
1480 confidence_score: float | None = None
1481 #: M27 — when the fact was mentioned in conversation (session date),
1482 #: distinct from ``occurred_start`` (when the event happened).
1483 mentioned_at: datetime | None = None
1484 #: M31 Fix 4 — resolved absolute date for the fact's most-prominent
1485 #: event, carried through from :attr:`MemoryFact.event_date`. See
1486 #: the MemoryFact docstring for the retain-time resolution
1487 #: contract. ``None`` for legacy rows or facts whose text had no
1488 #: parseable relative phrase.
1489 event_date: datetime | None = None
1491 @property
1492 def chunk_id(self) -> str | None:
1493 """Stable composite identity of the source chunk (Hindsight parity).
1495 Mirrors :attr:`MemoryFact.chunk_id`. The bench harness uses
1496 this to look up the section text and inject it inline with
1497 the fact in the answerer's structured context.
1498 """
1499 if self.document_id is None or self.line_num is None:
1500 return None
1501 return f"{self.document_id}:{self.line_num}"
1504# ─── Backward-compat aliases (M18b post-ship rename, 2026-05-17) ───
1505#
1506# The pre-M18b names ``PageIndexFact`` / ``PageIndexFactHit`` remain
1507# usable indefinitely; new code should prefer ``MemoryFact`` /
1508# ``MemoryFactHit``. The aliases let the 15 existing call sites
1509# migrate gradually rather than in one churning commit. Both names
1510# resolve to the same dataclass — ``isinstance(x, PageIndexFact)``
1511# and ``isinstance(x, MemoryFact)`` are equivalent.
1512PageIndexFact = MemoryFact
1513PageIndexFactHit = MemoryFactHit
1516@dataclass
1517class PageIndexSectionEntity:
1518 """A (section, entity) tuple — Hindsight's unit_entities pattern at section grain."""
1520 document_id: str
1521 line_num: int
1522 entity_name: str
1525@dataclass
1526class PageIndexSectionLink:
1527 """An edge between two sections — Hindsight's memory_links pattern at section grain.
1529 ``link_type`` discriminates: 'semantic_knn' | 'causal' | 'supersedes' | 'elaborates'.
1530 See ADR-006 §4.2 and migration 015.
1531 """
1533 from_doc: str
1534 from_line: int
1535 to_doc: str
1536 to_line: int
1537 link_type: str
1538 weight: float
1541# ---------------------------------------------------------------------------
1542# PII detection (used by policy layer)
1543# ---------------------------------------------------------------------------
1546@dataclass
1547class PiiMatch:
1548 pii_type: str # "email", "phone", "ssn", "credit_card", "ip_address", …
1549 start: int
1550 end: int
1551 matched_text: str
1552 replacement: str | None = None