Coverage for astrocyte/types.py: 98%

707 statements  

« 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. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10import json as _json 

11from dataclasses import asdict, dataclass, field 

12from datetime import date, datetime 

13from typing import Literal 

14 

15from astrocyte.mip.schema import ForgetSpec, PipelineSpec 

16 

17# --------------------------------------------------------------------------- 

18# Metadata value type — recursive union replacing Any for FFI safety 

19# --------------------------------------------------------------------------- 

20MetadataValue = str | int | float | bool | None 

21Metadata = dict[str, MetadataValue] 

22 

23# --------------------------------------------------------------------------- 

24# Common 

25# --------------------------------------------------------------------------- 

26 

27 

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 

34 

35 

36# --------------------------------------------------------------------------- 

37# Tier 1: Vector Store 

38# --------------------------------------------------------------------------- 

39 

40 

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 

59 

60 def __post_init__(self) -> None: 

61 if not self.text: 

62 raise ValueError("VectorItem.text must be non-empty") 

63 

64 

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 

82 

83 

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 

100 

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}") 

104 

105 

106# --------------------------------------------------------------------------- 

107# Tier 1: Graph Store 

108# --------------------------------------------------------------------------- 

109 

110 

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 

132 

133 

134@dataclass 

135class EntityCandidateMatch: 

136 """Scored candidate produced by ``GraphStore.find_entity_candidates_scored``. 

137 

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. 

143 

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 """ 

163 

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 

174 

175 

176@dataclass 

177class EntityLink: 

178 """A typed relationship between two entities in the knowledge graph. 

179 

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 """ 

184 

185 entity_a: str 

186 """ID of the first entity in the relationship.""" 

187 

188 entity_b: str 

189 """ID of the second entity in the relationship.""" 

190 

191 link_type: str 

192 """Relationship label — e.g. ``"alias_of"``, ``"co_occurs"``, ``"works_at"``.""" 

193 

194 evidence: str = "" 

195 """Verbatim quote from the source memory that justifies this link.""" 

196 

197 confidence: float = 1.0 

198 """0–1 confidence score. 1.0 = rule-derived; < 1.0 = LLM-confirmed.""" 

199 

200 created_at: datetime | None = None 

201 """UTC wall-clock time this link was created. None for legacy links.""" 

202 

203 metadata: Metadata | None = None 

204 """Optional extra key-value pairs (preserved for backward compatibility).""" 

205 

206 

207@dataclass 

208class MemoryEntityAssociation: 

209 memory_id: str 

210 entity_id: str 

211 

212 

213@dataclass 

214class MemoryLink: 

215 """A typed directional link between two memories (Hindsight parity). 

216 

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. 

221 

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). 

230 

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 """ 

235 

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 

244 

245 

246@dataclass 

247class GraphHit: 

248 memory_id: str 

249 text: str 

250 connected_entities: list[str] 

251 depth: int 

252 score: float 

253 

254 

255# --------------------------------------------------------------------------- 

256# Tier 1: Document Store 

257# --------------------------------------------------------------------------- 

258 

259 

260@dataclass 

261class Document: 

262 id: str 

263 text: str 

264 metadata: Metadata | None = None 

265 tags: list[str] | None = None 

266 

267 

268@dataclass 

269class DocumentFilters: 

270 tags: list[str] | None = None 

271 metadata_filters: Metadata | None = None 

272 

273 

274@dataclass 

275class DocumentHit: 

276 document_id: str 

277 text: str 

278 score: float # BM25 relevance 

279 metadata: Metadata | None = None 

280 

281 

282# --------------------------------------------------------------------------- 

283# Engine Provider — requests / results 

284# --------------------------------------------------------------------------- 

285 

286 

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 

304 

305 

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 

315 

316 

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 

362 

363 

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) 

379 

380 

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) 

393 

394 

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 

408 

409 

410@dataclass 

411class HistoryResult: 

412 """Result of ``brain.history()`` — what the agent knew at a past point in time (M9). 

413 

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 """ 

417 

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 

424 

425 

426@dataclass 

427class GapItem: 

428 """A single knowledge gap identified by ``brain.audit()`` (M10). 

429 

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 """ 

434 

435 topic: str 

436 """Short label for the missing or under-covered topic (e.g. ``"Alice's current role"``).""" 

437 

438 severity: Literal["high", "medium", "low"] 

439 """How critical the gap is. 

440 

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 """ 

445 

446 reason: str 

447 """One-sentence explanation of why the gap exists.""" 

448 

449 

450@dataclass 

451class AuditResult: 

452 """Result of ``brain.audit()`` — structured gap analysis for a scope (M10). 

453 

454 Summarises what the agent *doesn't* know about a given topic, together 

455 with a 0–1 coverage score and provenance counts. 

456 """ 

457 

458 scope: str 

459 """The scope string passed to ``brain.audit()``.""" 

460 

461 bank_id: str 

462 """The bank that was audited.""" 

463 

464 gaps: list[GapItem] 

465 """Identified knowledge gaps, ordered roughly by severity.""" 

466 

467 coverage_score: float 

468 """0–1 composite score (memory density × recency × topic breadth). 

469 

470 1.0 means the bank covers the scope well; < 0.5 indicates sparse coverage. 

471 """ 

472 

473 memories_scanned: int 

474 """Number of memories retrieved and fed to the audit judge.""" 

475 

476 trace: RecallTrace | None = None 

477 """Diagnostic trace from the recall pass, if available.""" 

478 

479 

480@dataclass 

481class Dispositions: 

482 """Personality modifiers for synthesis.""" 

483 

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) 

487 

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}") 

493 

494 

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 

523 

524 

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 

533 

534 

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 

542 

543 

544@dataclass 

545class ForgetResult: 

546 deleted_count: int 

547 archived_count: int = 0 

548 

549 

550# --------------------------------------------------------------------------- 

551# Engine capabilities 

552# --------------------------------------------------------------------------- 

553 

554 

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 

572 

573 

574# --------------------------------------------------------------------------- 

575# LLM Provider 

576# --------------------------------------------------------------------------- 

577 

578 

579@dataclass 

580class ContentPart: 

581 """Tagged union for multimodal content.""" 

582 

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" 

590 

591 

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 

609 

610 

611@dataclass 

612class TokenUsage: 

613 input_tokens: int 

614 output_tokens: int 

615 

616 

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"] 

621 

622 

623@dataclass 

624class ToolCall: 

625 """A single tool invocation requested by an LLM during native function calling. 

626 

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. 

631 

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 """ 

636 

637 id: str 

638 name: str 

639 arguments: dict[str, JsonValue] 

640 

641 

642@dataclass 

643class ToolDefinition: 

644 """A tool the LLM can call. JSON-Schema-shaped, OpenAI-compatible. 

645 

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 """ 

651 

652 name: str 

653 description: str 

654 parameters: dict[str, JsonValue] 

655 

656 

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 

667 

668 

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 

675 

676 

677# --------------------------------------------------------------------------- 

678# Outbound Transport 

679# --------------------------------------------------------------------------- 

680 

681 

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 

688 

689 

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 

696 

697 

698# --------------------------------------------------------------------------- 

699# Multi-bank orchestration 

700# --------------------------------------------------------------------------- 

701 

702 

703@dataclass 

704class MultiBankStrategy: 

705 """Multi-bank recall behavior. Default ``parallel`` matches legacy ``banks=[...]`` without an explicit strategy.""" 

706 

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 

712 

713 

714# --------------------------------------------------------------------------- 

715# Access control 

716# --------------------------------------------------------------------------- 

717 

718 

719_VALID_PERMISSIONS = {"read", "write", "forget", "admin", "*"} 

720 

721 

722@dataclass 

723class AccessGrant: 

724 bank_id: str # or "*" 

725 principal: str # or "*" 

726 permissions: list[str] # ["read", "write", "forget", "admin"] 

727 

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}") 

732 

733 

734@dataclass 

735class ActorIdentity: 

736 """Structured actor for access control and bank resolution (ADR-002).""" 

737 

738 type: str # "user" | "agent" | "service" 

739 id: str 

740 claims: dict[str, str] | None = None 

741 

742 

743@dataclass 

744class AstrocyteContext: 

745 """Caller identity for access control. 

746 

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 """ 

751 

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 

756 

757 

758# --------------------------------------------------------------------------- 

759# Event hooks 

760# --------------------------------------------------------------------------- 

761 

762 

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 

771 

772 

773# --------------------------------------------------------------------------- 

774# Data governance 

775# --------------------------------------------------------------------------- 

776 

777 

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 

785 

786 

787# --------------------------------------------------------------------------- 

788# Lifecycle / audit 

789# --------------------------------------------------------------------------- 

790 

791 

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" 

799 

800 

801@dataclass 

802class LifecycleAction: 

803 """Result of a lifecycle TTL evaluation on a single memory.""" 

804 

805 memory_id: str 

806 action: str # "archive" | "delete" | "keep" 

807 reason: str # "ttl_unretrieved" | "ttl_archived_expired" | "recent" | "exempt" | "legal_hold" 

808 

809 

810@dataclass 

811class LifecycleRunResult: 

812 archived_count: int 

813 deleted_count: int 

814 skipped_count: int 

815 actions: list[LifecycleAction] 

816 

817 

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 

827 

828 

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 

836 

837 

838# --------------------------------------------------------------------------- 

839# Analytics 

840# --------------------------------------------------------------------------- 

841 

842 

843@dataclass 

844class HealthIssue: 

845 severity: Literal["info", "warning", "critical"] 

846 code: str 

847 message: str 

848 recommendation: str 

849 

850 

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 

859 

860 

861@dataclass 

862class MemoryUsage: 

863 memory_id: str 

864 text: str 

865 recall_count: int 

866 last_recalled_at: datetime 

867 

868 

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 

878 

879 

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 

891 

892 

893@dataclass 

894class QualityTrends: 

895 bank_id: str 

896 data_points: list[QualityDataPoint] 

897 

898 

899# --------------------------------------------------------------------------- 

900# Evaluation 

901# --------------------------------------------------------------------------- 

902 

903 

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. 

923 

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 

937 

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 

951 

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 

959 

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 

968 

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 

988 

989 

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 

999 

1000 

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 

1010 

1011 def to_dict(self) -> dict[str, object]: 

1012 """Serialize to a JSON-safe dict (datetime → ISO 8601 string).""" 

1013 

1014 def _convert(obj: object) -> object: 

1015 if isinstance(obj, (datetime, date)): 

1016 return obj.isoformat() 

1017 return obj 

1018 

1019 raw = asdict(self) 

1020 return _json.loads(_json.dumps(raw, default=_convert)) 

1021 

1022 def to_json(self, *, indent: int = 2) -> str: 

1023 """Serialize to a JSON string.""" 

1024 return _json.dumps(self.to_dict(), indent=indent) 

1025 

1026 

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"] 

1035 

1036 

1037# --------------------------------------------------------------------------- 

1038# MIP routing 

1039# --------------------------------------------------------------------------- 

1040 

1041 

1042@dataclass 

1043class RoutingDecision: 

1044 """Output of MIP routing — tells Astrocyte where/how to store.""" 

1045 

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) 

1056 

1057 

1058# --------------------------------------------------------------------------- 

1059# M8: Wiki Compile 

1060# --------------------------------------------------------------------------- 

1061 

1062WikiPageKind = Literal["entity", "topic", "concept"] 

1063 

1064 

1065@dataclass 

1066class WikiPage: 

1067 """A compiled topic/entity/concept page synthesised from raw memories (M8). 

1068 

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. 

1072 

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 """ 

1076 

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 

1089 

1090 

1091@dataclass 

1092class WikiPageHit: 

1093 """A wiki page returned from a semantic search during recall tiering.""" 

1094 

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 

1103 

1104 

1105@dataclass(frozen=True) 

1106class MentalModel: 

1107 """A first-class curated saved-reflect summary. 

1108 

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. 

1113 

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``). 

1119 

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 """ 

1139 

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 

1182 

1183 

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. 

1208 

1209 

1210@dataclass(frozen=True) 

1211class SourceDocument: 

1212 """A top-level source document we ingested into a bank. 

1213 

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. 

1216 

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 """ 

1232 

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 

1241 

1242 

1243@dataclass(frozen=True) 

1244class SourceChunk: 

1245 """A chunk of a :class:`SourceDocument`. 

1246 

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). 

1252 

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 """ 

1265 

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 

1274 

1275 

1276@dataclass 

1277class CompileScope: 

1278 """A resolved compile scope — either from a tag or a DBSCAN cluster label.""" 

1279 

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 

1283 

1284 

1285@dataclass 

1286class CompileRequest: 

1287 bank_id: str 

1288 scope: str | None = None # If None, triggers full scope discovery (§3.2) 

1289 

1290 

1291@dataclass 

1292class CompileResult: 

1293 """Result of a brain.compile() call.""" 

1294 

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 

1303 

1304 

1305# --------------------------------------------------------------------------- 

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

1307# --------------------------------------------------------------------------- 

1308 

1309 

1310@dataclass 

1311class PageIndexDocument: 

1312 """One conversation/document in the PageIndex tier. 

1313 

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 """ 

1319 

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 

1326 

1327 

1328@dataclass 

1329class PageIndexSection: 

1330 """One tree node in the PageIndex tier — the M9 recall primitive. 

1331 

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 """ 

1336 

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 

1363 

1364 

1365@dataclass 

1366class MemoryFact: 

1367 """Astrocyte's fact-grain memory unit — the Memory Engine analogue of 

1368 Hindsight's :class:`MemoryUnit`. 

1369 

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. 

1376 

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. 

1384 

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 

1389 

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 """ 

1394 

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 

1433 

1434 @property 

1435 def chunk_id(self) -> str | None: 

1436 """Stable composite identity of the source chunk (Hindsight parity). 

1437 

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. 

1443 

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. 

1450 

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}" 

1458 

1459 

1460@dataclass 

1461class MemoryFactHit: 

1462 """A :class:`MemoryFact` returned from a fact-grain search strategy. 

1463 

1464 Section anchor fields (``document_id``, ``line_num``) are nullable 

1465 — top-level facts (no document anchor) return ``None`` for both. 

1466 """ 

1467 

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 

1490 

1491 @property 

1492 def chunk_id(self) -> str | None: 

1493 """Stable composite identity of the source chunk (Hindsight parity). 

1494 

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}" 

1502 

1503 

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 

1514 

1515 

1516@dataclass 

1517class PageIndexSectionEntity: 

1518 """A (section, entity) tuple — Hindsight's unit_entities pattern at section grain.""" 

1519 

1520 document_id: str 

1521 line_num: int 

1522 entity_name: str 

1523 

1524 

1525@dataclass 

1526class PageIndexSectionLink: 

1527 """An edge between two sections — Hindsight's memory_links pattern at section grain. 

1528 

1529 ``link_type`` discriminates: 'semantic_knn' | 'causal' | 'supersedes' | 'elaborates'. 

1530 See ADR-006 §4.2 and migration 015. 

1531 """ 

1532 

1533 from_doc: str 

1534 from_line: int 

1535 to_doc: str 

1536 to_line: int 

1537 link_type: str 

1538 weight: float 

1539 

1540 

1541# --------------------------------------------------------------------------- 

1542# PII detection (used by policy layer) 

1543# --------------------------------------------------------------------------- 

1544 

1545 

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