Coverage for astrocyte/types.py: 98%

742 statements  

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

1"""Astrocyte core types — all DTOs for the framework. 

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@dataclass 

107class MemoryChange: 

108 """One entry in a bank's change feed (team memory sync, ``VectorStore.list_changes``). 

109 

110 The changes feed is every change to a synced row, in ``(changed_at, id)`` 

111 order. ``changed_at`` is the row's last change to any synced field: when 

112 it was stored, rewritten (metadata, and later claim status, trust or 

113 staleness flags) or forgotten. A live row carries its current values, so 

114 a later change shows up as an upsert of the same id. A forgotten row is a 

115 **tombstone**: ``deleted`` is true and only ``id``, ``bank_id`` and 

116 ``changed_at`` are set, so a forgotten memory's text never leaves the 

117 store through the feed. 

118 """ 

119 

120 id: str 

121 bank_id: str 

122 changed_at: datetime 

123 deleted: bool = False 

124 text: str | None = None 

125 occurred_at: datetime | None = None 

126 retained_at: datetime | None = None 

127 tags: list[str] | None = None 

128 fact_type: str | None = None 

129 memory_layer: str | None = None 

130 metadata: Metadata | None = None 

131 

132 

133@dataclass 

134class MemoryChangePage: 

135 """A page of :class:`MemoryChange` entries from ``Astrocyte.list_changes``. 

136 

137 ``next_cursor`` is the opaque position after the last change on this page 

138 (the request's own cursor when the page is empty, ``None`` only when the 

139 bank has no changes at all). Store it and pass it back to resume without 

140 gaps or repeats. ``has_more`` is true when more changes were already 

141 waiting past this page. 

142 """ 

143 

144 changes: list[MemoryChange] 

145 next_cursor: str | None = None 

146 has_more: bool = False 

147 

148 

149@dataclass 

150class SyncPushRecord: 

151 """One memory pushed by a team-memory client (``Astrocyte.push_records``). 

152 

153 Stored as exactly one row with the client's ``id`` (8–64 of 

154 ``[A-Za-z0-9_-]``): no chunking, no extraction, re-embedded by the server. 

155 ``content_hash`` (``"sha256:<hex>"`` of the UTF-8 ``text``), when given, 

156 must match the text as sent. 

157 """ 

158 

159 id: str 

160 text: str 

161 occurred_at: datetime | None = None 

162 tags: list[str] | None = None 

163 fact_type: str | None = None 

164 metadata: Metadata | None = None 

165 content_hash: str | None = None 

166 

167 

168@dataclass 

169class SyncPushResult: 

170 """What became of one :class:`SyncPushRecord`. 

171 

172 ``status`` is ``stored`` (a new row with the pushed id), ``unchanged`` 

173 (that id already holds this text in this bank; idempotent re-push), 

174 ``duplicate`` (a near-duplicate of the bank's memory ``duplicate_of``; 

175 nothing stored) or ``rejected`` (``reason`` says why; nothing stored). 

176 """ 

177 

178 id: str 

179 status: Literal["stored", "unchanged", "duplicate", "rejected"] 

180 duplicate_of: str | None = None 

181 reason: str | None = None 

182 

183 

184# --------------------------------------------------------------------------- 

185# Tier 1: Graph Store 

186# --------------------------------------------------------------------------- 

187 

188 

189@dataclass 

190class Entity: 

191 id: str 

192 name: str 

193 entity_type: str # PERSON, ORG, LOCATION, … 

194 aliases: list[str] | None = None 

195 metadata: Metadata | None = None 

196 #: Per-entity name embedding for the Hindsight-inspired entity-resolution 

197 #: cascade. When present, the resolver uses cosine similarity against 

198 #: candidate embeddings to decide whether two surface forms refer to the 

199 #: same canonical entity, falling back to LLM disambiguation only for 

200 #: genuinely ambiguous pairs. Optional and nullable — a ``None`` value 

201 #: signals "no embedding tier available for this entity" and the cascade 

202 #: degrades gracefully to trigram + LLM. 

203 embedding: list[float] | None = None 

204 #: Hindsight-parity mention count — how many times this entity has been 

205 #: resolved-to during retain. Treated as a soft popularity signal in the 

206 #: composite cascade (cheap tiebreaker, never a primary decider). Adapter 

207 #: stores increment this on every successful canonical resolution; new 

208 #: entities start at 1. 

209 mention_count: int = 1 

210 

211 

212@dataclass 

213class EntityCandidateMatch: 

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

215 

216 The Hindsight-inspired entity-resolution cascade asks the graph store for 

217 candidates pre-scored against four cheap signals — trigram, embedding, 

218 co-occurrence, and temporal proximity — so the resolver can decide 

219 whether to autolink, skip, or escalate to the LLM without paying for 

220 additional database round-trips. 

221 

222 Fields: 

223 entity: The candidate entity. 

224 name_similarity: Trigram similarity of the candidate's name against 

225 the query name in ``[0.0, 1.0]``. Adapters compute this with 

226 ``pg_trgm.similarity()`` (PostgreSQL) or ``difflib.SequenceMatcher`` 

227 (in-memory). 

228 embedding_similarity: Cosine similarity of the candidate's stored 

229 embedding against the supplied query embedding in ``[0.0, 1.0]``, 

230 or ``None`` when either side has no embedding stored. 

231 co_occurring_names: Names of entities that co-occur with this 

232 candidate via ``EntityLink(link_type="co_occurs")``. Lowercased. 

233 Used by the resolver to compute overlap with the new entity's 

234 nearby entities — strong evidence two surface forms refer to 

235 the same canonical entity when their contexts overlap. 

236 last_seen: The candidate's last-activity timestamp (typically 

237 ``updated_at``). Used to compute temporal proximity to the new 

238 entity's event date — recent candidates score higher for the 

239 same name. ``None`` when not stored. 

240 """ 

241 

242 entity: Entity 

243 name_similarity: float 

244 embedding_similarity: float | None = None 

245 co_occurring_names: list[str] = field(default_factory=list) 

246 last_seen: datetime | None = None 

247 #: Hindsight-parity popularity signal — how many memories this entity has 

248 #: been resolved-to. Used by :meth:`EntityResolver._composite_score` as a 

249 #: soft tiebreaker (capped contribution; never overrides name/cooccurrence 

250 #: signals on its own). 

251 mention_count: int = 1 

252 

253 

254@dataclass 

255class EntityLink: 

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

257 

258 M11: fields renamed from ``source_entity_id``/``target_entity_id`` to 

259 ``entity_a``/``entity_b`` to be direction-neutral; ``evidence``, 

260 ``confidence``, and ``created_at`` added for entity resolution provenance. 

261 """ 

262 

263 entity_a: str 

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

265 

266 entity_b: str 

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

268 

269 link_type: str 

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

271 

272 evidence: str = "" 

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

274 

275 confidence: float = 1.0 

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

277 

278 created_at: datetime | None = None 

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

280 

281 metadata: Metadata | None = None 

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

283 

284 

285@dataclass 

286class MemoryEntityAssociation: 

287 memory_id: str 

288 entity_id: str 

289 

290 

291@dataclass 

292class MemoryLink: 

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

294 

295 Distinct from :class:`EntityLink` (which connects entities). Memory 

296 links capture relationships between fact-level units — the granularity 

297 Hindsight's ``link_expansion_retrieval`` walks for causal chains and 

298 semantic-kNN edges. 

299 

300 Three link types are first-class in the link-expansion retrieval 

301 signal: 

302 - ``"caused_by"`` — extracted at retain time from cause-effect text 

303 ("she lost her job, so she couldn't pay rent"). 

304 - ``"semantic"`` — precomputed kNN (each new memory linked to its 

305 top-K most similar prior memories at insert time, similarity ≥ 0.7). 

306 - ``"entity_overlap"`` — query-time signal computed from shared 

307 entities (not persisted; included here for documentation parity). 

308 

309 Direction matters: ``source_memory_id`` is the "from" side. For 

310 ``caused_by`` semantics, the source is the EFFECT and the target is 

311 the CAUSE. (Hindsight's convention; preserved here for parity.) 

312 """ 

313 

314 source_memory_id: str 

315 target_memory_id: str 

316 link_type: str 

317 evidence: str = "" 

318 confidence: float = 1.0 

319 weight: float = 1.0 

320 created_at: datetime | None = None 

321 metadata: Metadata | None = None 

322 

323 

324@dataclass 

325class GraphHit: 

326 memory_id: str 

327 text: str 

328 connected_entities: list[str] 

329 depth: int 

330 score: float 

331 

332 

333# --------------------------------------------------------------------------- 

334# Tier 1: Document Store 

335# --------------------------------------------------------------------------- 

336 

337 

338@dataclass 

339class Document: 

340 id: str 

341 text: str 

342 metadata: Metadata | None = None 

343 tags: list[str] | None = None 

344 

345 

346@dataclass 

347class DocumentFilters: 

348 tags: list[str] | None = None 

349 metadata_filters: Metadata | None = None 

350 

351 

352@dataclass 

353class DocumentHit: 

354 document_id: str 

355 text: str 

356 score: float # BM25 relevance 

357 metadata: Metadata | None = None 

358 

359 

360# --------------------------------------------------------------------------- 

361# Engine Provider — requests / results 

362# --------------------------------------------------------------------------- 

363 

364 

365@dataclass 

366class RetainRequest: 

367 content: str 

368 bank_id: str 

369 metadata: Metadata | None = None 

370 tags: list[str] | None = None 

371 occurred_at: datetime | None = None 

372 source: str | None = None 

373 content_type: str = "text" # "text", "conversation", "document", "email", ... 

374 extraction_profile: str | None = None # key in astrocyte.yml extraction_profiles (M3) 

375 #: Optional pipeline overrides from a MIP RoutingDecision. When set, fields 

376 #: take precedence over extraction profile and content_type defaults during 

377 #: chunking and dedup. Persisted onto each stored chunk via ``_mip.*`` keys. 

378 mip_pipeline: PipelineSpec | None = None 

379 #: Name of the MIP rule whose action produced ``mip_pipeline``. Persisted on 

380 #: stored chunks as ``_mip.rule`` so recall can warn on rule-version drift. 

381 mip_rule_name: str | None = None 

382 

383 

384@dataclass 

385class RetainResult: 

386 stored: bool 

387 memory_id: str | None = None 

388 deduplicated: bool = False 

389 error: str | None = None 

390 retention_action: str | None = None # "add" | "update" | "merge" | "skip" | "delete" (curated retain) 

391 curated: bool = False # Whether LLM curation was used 

392 memory_layer: str | None = None # Layer assigned during curation 

393 

394 

395@dataclass 

396class RecallRequest: 

397 query: str 

398 bank_id: str 

399 max_results: int = 10 

400 max_tokens: int | None = None 

401 fact_types: list[str] | None = None 

402 tags: list[str] | None = None 

403 time_range: tuple[datetime, datetime] | None = None 

404 include_sources: bool = False 

405 layer_weights: dict[str, float] | None = None # {"fact": 1.0, "observation": 1.5, "model": 2.0} 

406 detail_level: str | None = None # "titles" | "bodies" | "full" | None (default=full) 

407 external_context: list[MemoryHit] | None = None # External RAG/graph results for cross-source fusion 

408 as_of: datetime | None = None # Time-travel: recall as if it were this UTC moment (M9) 

409 #: Reference date for resolving relative temporal phrases in the 

410 #: query (``"yesterday"``, ``"last week"``, ``"3 days ago"``). 

411 #: SEPARATE from ``as_of``: ``as_of`` is a ``retained_at`` time-travel 

412 #: filter (M9 audit/legal-hold use case); ``query_reference_date`` is 

413 #: ONLY consumed by the query analyzer to anchor relative phrases. 

414 #: 

415 #: When unset, the query analyzer uses ``as_of`` if set, else 

416 #: ``datetime.now()`` — preserving prior behaviour. Set this on 

417 #: bench/eval paths whose dataset predates the run wall-clock (e.g. 

418 #: LongMemEval is 2023-vintage; running in 2026 without anchoring 

419 #: makes "yesterday" wrong by ~3 years). Crucially, setting only 

420 #: ``query_reference_date`` (and leaving ``as_of=None``) does NOT 

421 #: filter out memories retained after that date — the entire corpus 

422 #: stays in scope. See the May 2026 LME post-mortem. 

423 query_reference_date: datetime | None = None 

424 #: Per-call disposition override. When set, the orchestrator's 

425 #: abstention decision derives from ``dispositions.skepticism`` 

426 #: (1=trust everything → never abstain, 5=skeptical → aggressive 

427 #: abstention) — see ``_abstention_floor_for_skepticism``. Falls 

428 #: back to deployment defaults when ``None``. Use this to make a 

429 #: single deployment serve both adversarial-resistant agents and 

430 #: trust-the-model assistants without forking the YAML config. 

431 dispositions: "Dispositions | None" = None 

432 #: M31 Fix 2 — opaque session identifier. When the calling 

433 #: application knows which conversation session a query targets, 

434 #: this scopes the underlying SPI calls (``search_facts_*`` / 

435 #: ``search_sections_*``) to facts and sections whose anchoring 

436 #: section has matching ``session_id``. ``None`` (default) keeps 

437 #: cross-session retrieval behaviour. Plumbed from 

438 #: ``RecallParams.session_id`` via ``_make_recall_request``. 

439 session_id: str | None = None 

440 

441 

442@dataclass 

443class MemoryHit: 

444 text: str 

445 score: float # 0.0 – 1.0 relevance 

446 fact_type: str | None = None 

447 metadata: Metadata | None = None 

448 tags: list[str] | None = None 

449 occurred_at: datetime | None = None 

450 source: str | None = None 

451 memory_id: str | None = None 

452 bank_id: str | None = None # set by multi-bank / hybrid recall 

453 memory_layer: str | None = None # "fact", "observation", "model" 

454 utility_score: float | None = None # 0.0 – 1.0 composite utility 

455 retained_at: datetime | None = None # UTC timestamp when item was retained (M9) 

456 chunk_id: str | None = None # M10: source-chunk backreference (when source_store is wired) 

457 

458 

459@dataclass 

460class RecallTrace: 

461 strategies_used: list[str] | None = None 

462 total_candidates: int | None = None 

463 fusion_method: str | None = None 

464 latency_ms: float | None = None 

465 strategy_timings_ms: dict[str, float] | None = None 

466 strategy_candidate_counts: dict[str, int] | None = None 

467 tier_used: int | None = None # Which retrieval tier resolved the query 

468 layer_distribution: dict[str, int] | None = None # {"fact": 5, "observation": 3, "model": 1} 

469 cache_hit: bool | None = None # Whether recall cache was used 

470 wiki_tier_used: bool | None = None # True when wiki tier satisfied the query (M8 W5) 

471 

472 

473@dataclass 

474class RecallResult: 

475 hits: list[MemoryHit] 

476 total_available: int 

477 truncated: bool 

478 trace: RecallTrace | None = None 

479 #: Optional labeled sections + rules for synthesis (M7 structured recall authority). 

480 authority_context: str | None = None 

481 #: Top raw cosine-similarity score from the semantic strategy (0.0 when no semantic 

482 #: results were found). Used by the reflect evidence-strict gate to detect uncertain 

483 #: retrieval and force citation rather than letting the LLM hallucinate from 

484 #: tangential memories. 

485 top_semantic_score: float = 0.0 

486 

487 

488@dataclass 

489class HistoryResult: 

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

491 

492 Wraps a :class:`RecallResult` and carries the ``as_of`` timestamp so 

493 callers can log/display the reconstruction point without parsing the request. 

494 """ 

495 

496 hits: list[MemoryHit] 

497 total_available: int 

498 truncated: bool 

499 as_of: datetime # The UTC timestamp used for the time-travel query 

500 bank_id: str 

501 trace: RecallTrace | None = None 

502 

503 

504@dataclass 

505class GapItem: 

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

507 

508 A gap is a topic or question that the memory bank cannot answer 

509 adequately — either because no memories cover it, or because coverage 

510 is too thin to draw a reliable conclusion. 

511 """ 

512 

513 topic: str 

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

515 

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

517 """How critical the gap is. 

518 

519 - ``"high"`` — likely to cause a wrong or confidently-wrong answer. 

520 - ``"medium"`` — partial coverage; answer may be incomplete. 

521 - ``"low"`` — minor; nuance or context is missing. 

522 """ 

523 

524 reason: str 

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

526 

527 

528@dataclass 

529class AuditResult: 

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

531 

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

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

534 """ 

535 

536 scope: str 

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

538 

539 bank_id: str 

540 """The bank that was audited.""" 

541 

542 gaps: list[GapItem] 

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

544 

545 coverage_score: float 

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

547 

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

549 """ 

550 

551 memories_scanned: int 

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

553 

554 trace: RecallTrace | None = None 

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

556 

557 

558@dataclass 

559class Dispositions: 

560 """Personality modifiers for synthesis.""" 

561 

562 skepticism: int = 3 # 1 (trusting) to 5 (skeptical) 

563 literalism: int = 3 # 1 (flexible) to 5 (rigid) 

564 empathy: int = 3 # 1 (detached) to 5 (empathetic) 

565 

566 def __post_init__(self) -> None: 

567 for field_name in ("skepticism", "literalism", "empathy"): 

568 val = getattr(self, field_name) 

569 if not (1 <= val <= 5): 

570 raise ValueError(f"Dispositions.{field_name} must be 1–5, got {val}") 

571 

572 

573@dataclass 

574class ReflectRequest: 

575 query: str 

576 bank_id: str 

577 max_tokens: int | None = None 

578 include_sources: bool = True 

579 dispositions: Dispositions | None = None 

580 #: Optional tag filter forwarded to the dispatcher's internal recall. 

581 #: When set, reflect's underlying retrieval is scoped to memories 

582 #: carrying every listed tag — closing the leak where single-bank 

583 #: ``Astrocyte.reflect(tags=...)`` previously dropped the filter. 

584 tags: list[str] | None = None 

585 #: M9 time-travel anchor: filters reflect's underlying recall to 

586 #: ``retained_at <= as_of``. Use for legal-hold / audit replay 

587 #: ("show me what the system knew at time X"). NOT for anchoring 

588 #: relative phrases — see ``query_reference_date`` below for that. 

589 as_of: datetime | None = None 

590 #: Reference date for resolving relative temporal phrases in the 

591 #: query (``"yesterday"``, ``"last week"``, ``"3 days ago"``). 

592 #: SEPARATE from ``as_of``: setting ONLY this anchors phrase 

593 #: resolution without filtering out memories retained after that 

594 #: date. Required for benchmarks whose dataset predates the run 

595 #: wall-clock — e.g. LongMemEval (2023-vintage) measured in 2026 

596 #: must set ``query_reference_date=question_date`` so "yesterday" 

597 #: resolves to question_date - 1 day, not run_wall_clock - 1 day. 

598 #: Mirrors ``RecallRequest.query_reference_date``; the orchestrator 

599 #: forwards this into every sub_recall the reflect path builds. 

600 query_reference_date: datetime | None = None 

601 

602 

603@dataclass 

604class ReflectResult: 

605 answer: str 

606 confidence: float | None = None 

607 sources: list[MemoryHit] | None = None 

608 observations: list[str] | None = None 

609 #: Same structured block as :attr:`RecallResult.authority_context` when reflect used recall authority. 

610 authority_context: str | None = None 

611 

612 

613@dataclass 

614class ForgetRequest: 

615 bank_id: str 

616 memory_ids: list[str] | None = None 

617 tags: list[str] | None = None 

618 before_date: datetime | None = None 

619 scope: str | None = None # "all" or None for selective 

620 

621 

622@dataclass 

623class ForgetResult: 

624 deleted_count: int 

625 archived_count: int = 0 

626 

627 

628# --------------------------------------------------------------------------- 

629# Engine capabilities 

630# --------------------------------------------------------------------------- 

631 

632 

633@dataclass(frozen=True) 

634class EngineCapabilities: 

635 supports_reflect: bool = False 

636 supports_forget: bool = False 

637 supports_semantic_search: bool = True 

638 supports_keyword_search: bool = False 

639 supports_graph_search: bool = False 

640 supports_temporal_search: bool = False 

641 supports_dispositions: bool = False 

642 supports_consolidation: bool = False 

643 supports_entities: bool = False 

644 supports_tags: bool = False 

645 supports_metadata: bool = True 

646 supports_compile: bool = False # M8: wiki compile via brain.compile() 

647 max_retain_bytes: int | None = None 

648 max_recall_results: int | None = None 

649 max_embedding_dims: int | None = None 

650 

651 

652# --------------------------------------------------------------------------- 

653# LLM Provider 

654# --------------------------------------------------------------------------- 

655 

656 

657@dataclass 

658class ContentPart: 

659 """Tagged union for multimodal content.""" 

660 

661 type: str # "text", "image_url", "image_base64", "audio_url", "audio_base64" 

662 text: str | None = None 

663 image_url: str | None = None 

664 image_base64: str | None = None 

665 audio_url: str | None = None 

666 audio_base64: str | None = None 

667 media_type: str | None = None # MIME type for base64 content, e.g. "image/png", "image/jpeg" 

668 

669 

670@dataclass 

671class Message: 

672 role: str # "system", "user", "assistant", "tool" 

673 content: str | list[ContentPart] = "" 

674 #: When ``role == "assistant"`` and the model emitted tool calls, the 

675 #: provider adapter places them here so downstream consumers can 

676 #: round-trip them back into a follow-up turn (along with the 

677 #: matching ``role="tool"`` results). 

678 tool_calls: list[ToolCall] | None = None 

679 #: When ``role == "tool"``, the OpenAI / Anthropic spec requires this 

680 #: field to point back at the originating ``ToolCall.id``. The provider 

681 #: adapter uses it to reconstruct the wire-format tool result message. 

682 tool_call_id: str | None = None 

683 #: When ``role == "tool"``, the human-readable tool name. Carried for 

684 #: providers (and for our own logging) that surface the name on 

685 #: tool result messages. 

686 name: str | None = None 

687 

688 

689@dataclass 

690class TokenUsage: 

691 input_tokens: int 

692 output_tokens: int 

693 

694 

695#: JSON-shaped value type for tool-call arguments and JSON Schema — 

696#: replaces ``Any`` to satisfy the FFI-safety constraint on DTOs 

697#: (see :data:`Metadata` for the same idiom on memory metadata). 

698JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"] 

699 

700 

701@dataclass 

702class ToolCall: 

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

704 

705 Mirrors the OpenAI/Anthropic tool-call shape: each call carries an 

706 opaque ``id`` (for round-tripping a tool result back to the model), 

707 a ``name`` matching one of the tools provided in the request, and 

708 the model's chosen ``arguments`` as a parsed dict. 

709 

710 The agentic reflect loop (Hindsight parity) consumes these instead 

711 of parsing JSON out of the response text — significantly more 

712 reliable than the JSON-in-prose protocol. 

713 """ 

714 

715 id: str 

716 name: str 

717 arguments: dict[str, JsonValue] 

718 

719 

720@dataclass 

721class ToolDefinition: 

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

723 

724 ``parameters`` is a JSON Schema object; the provider adapter is 

725 responsible for translating to the wire format the underlying API 

726 expects (OpenAI: ``tools=[{"type": "function", "function": {...}}]``; 

727 Anthropic: ``tools=[{"name": ..., "input_schema": ...}]``). 

728 """ 

729 

730 name: str 

731 description: str 

732 parameters: dict[str, JsonValue] 

733 

734 

735@dataclass 

736class Completion: 

737 text: str 

738 model: str 

739 usage: TokenUsage | None = None 

740 #: Tool calls the LLM emitted, when ``tools`` were supplied to 

741 #: :meth:`LLMProvider.complete`. ``None`` means the provider did 

742 #: not produce tool calls (or doesn't support them — feature-detect 

743 #: with ``getattr`` rather than assuming). 

744 tool_calls: list[ToolCall] | None = None 

745 

746 

747@dataclass(frozen=True) 

748class LLMCapabilities: 

749 supports_multimodal_completion: bool = False 

750 modalities_supported: tuple[str, ...] | None = None 

751 supports_multimodal_embedding: bool = False 

752 supports_batch_embed: bool = True 

753 

754 

755# --------------------------------------------------------------------------- 

756# Outbound Transport 

757# --------------------------------------------------------------------------- 

758 

759 

760@dataclass 

761class HttpClientContext: 

762 proxy: str | None = None 

763 ca_bundle: str | None = None 

764 headers: dict[str, str] | None = None 

765 timeouts: dict[str, float] | None = None 

766 

767 

768@dataclass(frozen=True) 

769class TransportCapabilities: 

770 supports_proxy: bool = False 

771 supports_custom_ca: bool = False 

772 supports_client_cert: bool = False 

773 supports_headers: bool = False 

774 

775 

776# --------------------------------------------------------------------------- 

777# Multi-bank orchestration 

778# --------------------------------------------------------------------------- 

779 

780 

781@dataclass 

782class MultiBankStrategy: 

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

784 

785 mode: Literal["cascade", "parallel", "first_match"] = "parallel" 

786 min_results_to_stop: int = 3 

787 cascade_order: list[str] | None = None 

788 bank_weights: dict[str, float] | None = None 

789 dedup_across_banks: bool = True 

790 

791 

792# --------------------------------------------------------------------------- 

793# Access control 

794# --------------------------------------------------------------------------- 

795 

796 

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

798 

799 

800@dataclass 

801class AccessGrant: 

802 bank_id: str # or "*" 

803 principal: str # or "*" 

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

805 

806 def __post_init__(self) -> None: 

807 invalid = set(self.permissions) - _VALID_PERMISSIONS 

808 if invalid: 

809 raise ValueError(f"AccessGrant.permissions contains invalid values: {invalid}") 

810 

811 

812@dataclass 

813class ActorIdentity: 

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

815 

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

817 id: str 

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

819 

820 

821@dataclass 

822class AstrocyteContext: 

823 """Caller identity for access control. 

824 

825 ``principal`` remains the backwards-compatible primary string. When ``actor`` 

826 is set, identity resolution uses ``actor`` (and optional ``on_behalf_of`` for OBO); 

827 ``principal`` is still useful for logging and integrations that have not migrated. 

828 

829 ``groups`` are group principals the actor belongs to (e.g. ``team:api``); 

830 grants to a group apply to its members. ``grants`` are extra grants bound to 

831 this caller (e.g. by a gateway token) and are added to the configured ones. 

832 Both are set by the code that authenticated the caller, never by the caller. 

833 """ 

834 

835 principal: str # e.g. "agent:support-bot-1", "user:calvin" 

836 actor: ActorIdentity | None = None 

837 on_behalf_of: ActorIdentity | None = None 

838 tenant_id: str | None = None 

839 groups: list[str] | None = None 

840 grants: list[AccessGrant] | None = None 

841 

842 

843# --------------------------------------------------------------------------- 

844# Event hooks 

845# --------------------------------------------------------------------------- 

846 

847 

848@dataclass 

849class HookEvent: 

850 event_id: str 

851 type: str # e.g. "on_retain", "on_pii_detected" 

852 timestamp: datetime 

853 bank_id: str | None = None 

854 data: Metadata | None = None 

855 trace_id: str | None = None 

856 

857 

858# --------------------------------------------------------------------------- 

859# Data governance 

860# --------------------------------------------------------------------------- 

861 

862 

863@dataclass 

864class DataClassification: 

865 level: int # 0-3 

866 label: str # "public", "internal", "confidential", "restricted" 

867 categories: list[str] | None = None # ["PII", "PHI", …] for restricted 

868 classified_by: str = "rules" # "caller", "rules", "llm" 

869 classified_at: datetime | None = None 

870 

871 

872# --------------------------------------------------------------------------- 

873# Lifecycle / audit 

874# --------------------------------------------------------------------------- 

875 

876 

877@dataclass 

878class LegalHold: 

879 hold_id: str 

880 bank_id: str 

881 reason: str 

882 set_at: datetime 

883 set_by: str # "user:api", "system:compliance" 

884 

885 

886@dataclass 

887class LifecycleAction: 

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

889 

890 memory_id: str 

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

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

893 

894 

895@dataclass 

896class LifecycleRunResult: 

897 archived_count: int 

898 deleted_count: int 

899 skipped_count: int 

900 actions: list[LifecycleAction] 

901 

902 

903@dataclass 

904class AuditEvent: 

905 event_type: str 

906 bank_id: str 

907 actor: str # "system:ttl", "user:api", "compliance:forget", … 

908 timestamp: datetime 

909 memory_ids: list[str] | None = None 

910 reason: str | None = None 

911 metadata: Metadata | None = None 

912 

913 

914@dataclass 

915class ForgetSelector: 

916 bank_ids: list[str] 

917 scope: str | None = None # "all" or None for selective 

918 tags: list[str] | None = None 

919 before_date: datetime | None = None 

920 memory_ids: list[str] | None = None 

921 

922 

923# --------------------------------------------------------------------------- 

924# Analytics 

925# --------------------------------------------------------------------------- 

926 

927 

928@dataclass 

929class HealthIssue: 

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

931 code: str 

932 message: str 

933 recommendation: str 

934 

935 

936@dataclass 

937class BankHealth: 

938 bank_id: str 

939 score: float # 0.0 – 1.0 

940 status: Literal["healthy", "warning", "unhealthy"] 

941 issues: list[HealthIssue] 

942 metrics: dict[str, float] 

943 assessed_at: datetime 

944 

945 

946@dataclass 

947class MemoryUsage: 

948 memory_id: str 

949 text: str 

950 recall_count: int 

951 last_recalled_at: datetime 

952 

953 

954@dataclass 

955class QualityDataPoint: 

956 date: date 

957 retain_count: int 

958 recall_count: int 

959 recall_hit_rate: float 

960 avg_recall_score: float 

961 dedup_rate: float 

962 reflect_success_rate: float 

963 

964 

965@dataclass 

966class UtilizationReport: 

967 bank_id: str 

968 total_memories: int 

969 active_memories: int # recalled >= 1x in last 30 days 

970 stale_memories: int # never recalled in 30 days 

971 never_recalled: int 

972 top_recalled: list[MemoryUsage] 

973 fact_type_distribution: dict[str, int] 

974 tag_distribution: dict[str, int] 

975 assessed_at: datetime 

976 

977 

978@dataclass 

979class QualityTrends: 

980 bank_id: str 

981 data_points: list[QualityDataPoint] 

982 

983 

984# --------------------------------------------------------------------------- 

985# Evaluation 

986# --------------------------------------------------------------------------- 

987 

988 

989@dataclass 

990class EvalMetrics: 

991 recall_precision: float 

992 recall_hit_rate: float 

993 recall_mrr: float 

994 recall_ndcg: float 

995 retain_latency_p50_ms: float 

996 retain_latency_p95_ms: float 

997 recall_latency_p50_ms: float 

998 recall_latency_p95_ms: float 

999 total_tokens_used: int 

1000 total_duration_seconds: float 

1001 reflect_accuracy: float | None = None 

1002 reflect_completeness: float | None = None 

1003 reflect_hallucination_rate: float | None = None 

1004 reflect_latency_p50_ms: float | None = None 

1005 reflect_latency_p95_ms: float | None = None 

1006 # Tier-3 metrics: populated by ``BenchmarkMetricsCollector`` when the 

1007 # bench wires it up. All optional so legacy callers stay valid. 

1008 

1009 # ── Quality ────────────────────────────────────────────── 

1010 #: Fraction of questions where at least one retrieved memory had 

1011 #: token-overlap ≥ threshold with the gold answer. Distinguishes 

1012 #: "missed evidence" from "had evidence, synthesised wrong". 

1013 recall_coverage: float | None = None 

1014 #: Cross-tab of (recall_hit, reflect_hit) outcomes. Keys: 

1015 #: ``"both_hit"``, ``"recall_hit_reflect_miss"``, 

1016 #: ``"recall_miss_reflect_hit"``, ``"both_miss"``. 

1017 recall_reflect_gap: dict[str, int] | None = None 

1018 #: For adversarial questions: fraction where reflect correctly abstained 

1019 #: ("Insufficient evidence" / "I don't have …" patterns) instead of 

1020 #: hallucinating an answer. 

1021 abstention_rate_adversarial: float | None = None 

1022 

1023 # ── Cost & efficiency ──────────────────────────────────── 

1024 #: Tokens by pipeline phase. Keys: ``"retain"``, ``"eval"``, 

1025 #: ``"persona_compile"``, ``"observation_consolidation"``, ``"other"``. 

1026 tokens_by_phase: dict[str, int] | None = None 

1027 #: Total HTTP API calls across the run. 

1028 api_calls_total: int | None = None 

1029 #: HTTP API calls by endpoint. Typical keys: ``"chat/completions"``, 

1030 #: ``"embeddings"``. 

1031 api_calls_by_endpoint: dict[str, int] | None = None 

1032 #: Total cost in USD computed from per-model pricing × actual tokens. 

1033 cost_total_usd: float | None = None 

1034 #: Mean cost per question. 

1035 cost_per_question_usd: float | None = None 

1036 

1037 # ── Latency (tail) ─────────────────────────────────────── 

1038 retain_latency_p99_ms: float | None = None 

1039 recall_latency_p99_ms: float | None = None 

1040 reflect_latency_p99_ms: float | None = None 

1041 #: Median end-to-end question time (recall + reflect). 

1042 e2e_per_question_p50_ms: float | None = None 

1043 e2e_per_question_p95_ms: float | None = None 

1044 

1045 # ── Robustness ─────────────────────────────────────────── 

1046 #: Counts of categorised errors. Typical keys: ``"pool_timeout"``, 

1047 #: ``"deadlock"``, ``"openai_429"``, ``"openai_5xx"``, ``"other"``. 

1048 error_count_by_type: dict[str, int] | None = None 

1049 #: Number of OpenAI retries triggered (rate limits, transient errors). 

1050 openai_retry_count: int | None = None 

1051 #: Number of retain calls that returned ``stored=False`` or raised. 

1052 failed_retain_count: int | None = None 

1053 

1054 # ── Memory-architecture (cascade observability) ────────── 

1055 #: Counts of entity-resolution cascade decisions. Typical keys: 

1056 #: ``"trigram_autolink"``, ``"embedding_autolink"``, 

1057 #: ``"composite_autolink"``, ``"llm_disambiguation"``, ``"skipped"``, 

1058 #: ``"created_new"``. 

1059 cascade_decisions: dict[str, int] | None = None 

1060 #: Number of new entities resolved to an existing canonical via Path B 

1061 #: (count of pre-store ID rewrites). 

1062 entities_resolved_count: int | None = None 

1063 #: Number of new entities that became fresh canonicals (no match). 

1064 entities_created_count: int | None = None 

1065 #: Histogram of composite scores keyed by bucket label 

1066 #: (``"0.0-0.1"``, ``"0.1-0.2"``, …, ``"0.9-1.0"``). 

1067 composite_score_distribution: dict[str, int] | None = None 

1068 #: Total wiki-page rows in the bank at end-of-run. 

1069 wiki_pages_total: int | None = None 

1070 #: Mean number of wiki pages per unique persona name. >1 indicates 

1071 #: scoping (per-conversation pages); 1 indicates single canonical. 

1072 wiki_pages_per_persona: float | None = None 

1073 

1074 

1075@dataclass 

1076class QueryResult: 

1077 query: str 

1078 expected: list[str] 

1079 actual: list[MemoryHit] 

1080 relevant_found: int 

1081 precision: float 

1082 reciprocal_rank: float 

1083 latency_ms: float 

1084 

1085 

1086@dataclass 

1087class EvalResult: 

1088 suite: str 

1089 provider: str 

1090 provider_tier: str 

1091 timestamp: datetime 

1092 metrics: EvalMetrics 

1093 per_query_results: list[QueryResult] 

1094 config_snapshot: Metadata | None = None 

1095 

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

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

1098 

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

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

1101 return obj.isoformat() 

1102 return obj 

1103 

1104 raw = asdict(self) 

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

1106 

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

1108 """Serialize to a JSON string.""" 

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

1110 

1111 

1112@dataclass 

1113class RegressionAlert: 

1114 metric: str 

1115 current_value: float 

1116 baseline_value: float 

1117 delta: float 

1118 delta_percent: float 

1119 severity: Literal["warning", "critical"] 

1120 

1121 

1122# --------------------------------------------------------------------------- 

1123# MIP routing 

1124# --------------------------------------------------------------------------- 

1125 

1126 

1127@dataclass 

1128class RoutingDecision: 

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

1130 

1131 bank_id: str | None = None 

1132 tags: list[str] | None = None 

1133 retain_policy: str | None = None # "default" | "redact_before_store" | "encrypt" | "reject" 

1134 resolved_by: str = "passthrough" # "mechanical" | "intent" | "passthrough" 

1135 rule_name: str | None = None 

1136 confidence: float = 1.0 

1137 reasoning: str | None = None # LLM justification if intent layer used 

1138 pipeline: PipelineSpec | None = None # Optional pipeline-shaping overrides from rule 

1139 forget: ForgetSpec | None = None # Optional forget-policy overrides from rule (Phase 4) 

1140 observability_tags: list[str] | None = None # Per-rule operator labels (Phase 5) 

1141 

1142 

1143# --------------------------------------------------------------------------- 

1144# M8: Wiki Compile 

1145# --------------------------------------------------------------------------- 

1146 

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

1148 

1149 

1150@dataclass 

1151class WikiPage: 

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

1153 

1154 WikiPages are additive artefacts — raw memories are never removed when a 

1155 page is compiled. Each page carries ``source_ids`` back to every raw memory 

1156 that contributed, enabling provenance tracing and recompile-on-forget. 

1157 

1158 Pages are mutable: each compile pass produces a new revision. Past revisions 

1159 are kept in the WikiStore audit log (not indexed for recall). 

1160 """ 

1161 

1162 page_id: str # Stable ID, e.g. "topic:incident-response", "entity:alice" 

1163 bank_id: str 

1164 kind: WikiPageKind # "entity" | "topic" | "concept" 

1165 title: str 

1166 content: str # LLM-maintained markdown 

1167 scope: str # Scope string used for this compile (tag name or cluster label) 

1168 source_ids: list[str] # Raw memory IDs that contributed (provenance) 

1169 cross_links: list[str] # Other page_ids referenced in this page 

1170 revision: int # Monotonically increasing, starts at 1 

1171 revised_at: datetime 

1172 tags: list[str] | None = None # Inherited from contributing memories 

1173 metadata: Metadata | None = None 

1174 

1175 

1176@dataclass 

1177class WikiPageHit: 

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

1179 

1180 page_id: str 

1181 title: str 

1182 content: str 

1183 scope: str 

1184 kind: str 

1185 score: float # 0.0 – 1.0 similarity 

1186 source_ids: list[str] 

1187 bank_id: str 

1188 

1189 

1190@dataclass(frozen=True) 

1191class MentalModel: 

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

1193 

1194 Mental models are durable, refreshable artifacts — the "Caroline 

1195 prefers async updates" / "Project X status: blocked on review" rows 

1196 that outlive any single recall and serve as authoritative summaries 

1197 when the recall pipeline elects to use the compiled layer. 

1198 

1199 Stored in the dedicated :class:`~astrocyte.provider.MentalModelStore` 

1200 SPI (formerly piggybacked on :class:`WikiStore` with 

1201 ``kind="concept"`` + ``metadata["_mental_model"] = True``; that 

1202 discriminator pattern was an architecture smell that we cut to a 

1203 proper table in v1.x — see ``docs/_plugins/benchmark-presets.md``). 

1204 

1205 Attributes: 

1206 model_id: Stable identifier within the bank (e.g. 

1207 ``"model:alice-prefs"``). 

1208 bank_id: Tenant-scoped bank identifier. 

1209 title: Human-readable display title. 

1210 content: The summary body, typically markdown. 

1211 scope: Scope key — ``"bank"`` for bank-wide models, or a 

1212 specific tag like ``"person:alice"`` to scope to a topic. 

1213 source_ids: Raw memory IDs that contributed to this summary 

1214 (provenance — enables refresh-on-forget). 

1215 revision: Monotonically increasing version number, starts at 1. 

1216 Bumped by ``MentalModelStore.upsert`` on each refresh. 

1217 refreshed_at: Timestamp of the most recent refresh. 

1218 kind: Sub-type discriminator (M14.6). Defaults to ``"general"`` 

1219 for backwards compatibility with M11.2-era rows. Other 

1220 values: ``"preference"`` for consolidated user-preference 

1221 rows produced by ``astrocyte.pipeline.preference_compile``. 

1222 Mirrors Hindsight's ``mental_models.subtype`` column. 

1223 """ 

1224 

1225 model_id: str 

1226 bank_id: str 

1227 title: str 

1228 content: str 

1229 scope: str 

1230 source_ids: list[str] 

1231 revision: int 

1232 refreshed_at: datetime 

1233 kind: str = "general" 

1234 #: M21 — structured representation of the document, stored as 

1235 #: JSON-shaped dict (the :meth:`pydantic.BaseModel.model_dump` 

1236 #: of :class:`astrocyte.pipeline.structured_doc.StructuredDocument`). 

1237 #: When ``None`` (legacy rows), callers may lazy-migrate by parsing 

1238 #: ``content`` via 

1239 #: :func:`astrocyte.pipeline.structured_doc.parse_markdown`. New 

1240 #: writes through :class:`~astrocyte.provider.MentalModelStore` 

1241 #: populate this field; the ``content`` column stays as the 

1242 #: rendered markdown for backwards-compatible reads. Stored on 

1243 #: Postgres as ``structured_doc JSONB`` (see migration 032). 

1244 #: 

1245 #: Type is plain ``dict | None`` for FFI safety (per the file-level 

1246 #: rule: no ``Any``, no callables, no generators); use 

1247 #: ``StructuredDocument.model_validate(model.structured_doc)`` to 

1248 #: get the typed object in-process. 

1249 structured_doc: dict | None = None 

1250 #: M40 — Per-source-id evidence timestamps, parallel to 

1251 #: :attr:`source_ids`. Captures *when* each contributing memory was 

1252 #: observed (in the conversation's reference timeline, not wall- 

1253 #: clock-now), so that :func:`astrocyte.pipeline.trend.compute_trend` 

1254 #: can classify the model as STABLE / STRENGTHENING / WEAKENING / 

1255 #: NEW / STALE relative to a query's reference date. 

1256 #: 

1257 #: Mirrors the ``_obs_source_timestamps`` pattern on observations 

1258 #: (see :mod:`astrocyte.pipeline.observation`). When ``None`` (legacy 

1259 #: rows pre-M40), trend computation falls back to ``refreshed_at`` as 

1260 #: a single-point timestamp — coarse but never garbage. New writes 

1261 #: populate this field at consolidation time. 

1262 #: 

1263 #: Stored on Postgres as ``source_timestamps TIMESTAMPTZ[]`` (see 

1264 #: migration 037). Length must equal ``len(source_ids)`` when set — 

1265 #: the lists are positionally aligned. 

1266 source_timestamps: list[datetime] | None = None 

1267 

1268 

1269# --------------------------------------------------------------------------- 

1270# Source documents and chunks (M10 — provenance + dedup) 

1271# --------------------------------------------------------------------------- 

1272# 

1273# Three-layer hierarchy: SourceDocument → SourceChunk → VectorItem. 

1274# 

1275# Pre-M10, retained memories were anonymous flat rows in 

1276# ``astrocyte_vectors`` with no record of where they came from. 

1277# M10 introduces the optional :class:`~astrocyte.provider.SourceStore` 

1278# SPI which lets callers preserve the source ↔ chunk ↔ memory chain so 

1279# that: 

1280# 

1281# 1. **Provenance** — every memory traces back through its chunk to its 

1282# originating document 

1283# 2. **Dedup** — content_hash on documents and chunks prevents storing 

1284# duplicate sources twice 

1285# 3. **Re-extraction** — chunks can be re-processed without losing the 

1286# original document text 

1287# 4. **Source attribution** — recall results can answer "this answer 

1288# came from these chunks of these documents" 

1289# 

1290# Backward compat: existing memories without a chunk parent (chunk_id IS 

1291# NULL on astrocyte_vectors) continue to work unchanged. Adoption is 

1292# entirely opt-in at the call site. 

1293 

1294 

1295@dataclass(frozen=True) 

1296class SourceDocument: 

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

1298 

1299 The "source" of one or more :class:`SourceChunk` rows, which in turn 

1300 are the source of one or more memory rows in the vector store. 

1301 

1302 Attributes: 

1303 id: Stable identifier (caller-supplied, must be unique per bank). 

1304 bank_id: Tenant-scoped bank identifier. 

1305 title: Optional human-readable title (e.g. file name, page title). 

1306 source_uri: Optional pointer back to where this document originated 

1307 (URL, file path, message-bus offset, etc.). Free-form. 

1308 content_hash: Optional SHA-256 (hex) of the ORIGINAL document 

1309 content — used by :meth:`SourceStore.find_document_by_hash` 

1310 for dedup before re-ingest. 

1311 content_type: Optional MIME type (``text/plain``, 

1312 ``application/pdf``, etc.). 

1313 metadata: Free-form JSON-serialisable metadata. 

1314 created_at: When the document was first stored (assigned by the 

1315 store; placeholder values from callers are overwritten). 

1316 """ 

1317 

1318 id: str 

1319 bank_id: str 

1320 title: str | None = None 

1321 source_uri: str | None = None 

1322 content_hash: str | None = None 

1323 content_type: str | None = None 

1324 metadata: Metadata | None = None 

1325 created_at: datetime | None = None 

1326 

1327 

1328@dataclass(frozen=True) 

1329class SourceChunk: 

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

1331 

1332 Sized + ordered by the chunking strategy used at retain time 

1333 (paragraph / sentence / fixed-token / etc.). One chunk typically 

1334 produces one memory in the vector store, but the relationship is 

1335 1:N — a chunk can produce zero memories (e.g. policy filtered) or 

1336 multiple (e.g. structured-fact extraction yielded N facts). 

1337 

1338 Attributes: 

1339 id: Stable identifier (caller-supplied OR generated by chunker). 

1340 bank_id: Tenant-scoped bank identifier. 

1341 document_id: ``SourceDocument.id`` this chunk belongs to. 

1342 chunk_index: Ordering within the document, starts at 0. 

1343 text: The chunk's text content. 

1344 content_hash: SHA-256 (hex) of ``text`` — used for dedup so the 

1345 same exact chunk ingested twice doesn't double up. 

1346 metadata: Free-form metadata (e.g. character offsets, page 

1347 numbers, speaker tags from chat transcripts). 

1348 created_at: When the chunk was first stored. 

1349 """ 

1350 

1351 id: str 

1352 bank_id: str 

1353 document_id: str 

1354 chunk_index: int 

1355 text: str 

1356 content_hash: str | None = None 

1357 metadata: Metadata | None = None 

1358 created_at: datetime | None = None 

1359 

1360 

1361@dataclass 

1362class CompileScope: 

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

1364 

1365 scope: str # Scope string (tag name or cluster label) 

1366 source: Literal["tag", "cluster", "explicit"] # How it was discovered 

1367 memory_ids: list[str] # Memory IDs belonging to this scope 

1368 

1369 

1370@dataclass 

1371class CompileRequest: 

1372 bank_id: str 

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

1374 

1375 

1376@dataclass 

1377class CompileResult: 

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

1379 

1380 bank_id: str 

1381 scopes_compiled: list[str] # Scope strings that produced wiki pages 

1382 pages_created: int 

1383 pages_updated: int 

1384 noise_memories: int # Untagged memories DBSCAN could not cluster (held for next cycle) 

1385 tokens_used: int 

1386 elapsed_ms: int 

1387 error: str | None = None 

1388 

1389 

1390# --------------------------------------------------------------------------- 

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

1392# --------------------------------------------------------------------------- 

1393 

1394 

1395@dataclass 

1396class PageIndexDocument: 

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

1398 

1399 Holds the canonical markdown so the picker can slice section excerpts 

1400 by line_num without round-tripping to a separate document store, plus 

1401 the reference_date used by the synthesiser to resolve relative phrases 

1402 like "two months ago". 

1403 """ 

1404 

1405 id: str # UUID-stringly typed for storage-adapter portability 

1406 bank_id: str 

1407 source_id: str # e.g. "conv-26", "lme-user-42" 

1408 md_text: str 

1409 reference_date: datetime | None = None 

1410 built_at: datetime | None = None 

1411 

1412 

1413@dataclass 

1414class PageIndexSection: 

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

1416 

1417 The picker fetches a list of these (without ``summary_embedding``, 

1418 which is only needed by the semantic-strategy SQL) as the "skeleton" 

1419 it navigates over. 

1420 """ 

1421 

1422 document_id: str 

1423 line_num: int 

1424 node_id: str # PageIndex's "0001"-style id 

1425 title: str 

1426 summary: str | None = None 

1427 summary_embedding: list[float] | None = None 

1428 speaker: str | None = None # 'user' | 'assistant' | None — LME assistant-recall 

1429 session_date: datetime | None = None 

1430 parent_node: str | None = None 

1431 depth: int = 0 

1432 # M11.1: structured event time the section's most-prominent 

1433 # discussed event happened. Different from ``session_date`` — 

1434 # "Yesterday I went to the doctor" in a session dated May 8 

1435 # has ``session_date=May 8`` but ``occurred_start=May 7``. 

1436 # Populated by per-section LLM extraction at retain time; 

1437 # ``None`` when the LLM didn't surface a specific event. 

1438 occurred_start: datetime | None = None 

1439 occurred_end: datetime | None = None 

1440 # M31 Fix 2 — opaque session identifier propagated from the source 

1441 # conversation. Real systems pass session_id when the application 

1442 # knows which session a query is about; the recall path filters to 

1443 # facts/sections whose session_id matches. ``None`` for document- 

1444 # ingest paths (markdown docs have no session concept). Stored as 

1445 # TEXT column ``session_id`` on ``astrocyte_pi_sections`` via 

1446 # migration 035. 

1447 session_id: str | None = None 

1448 

1449 

1450@dataclass 

1451class MemoryFact: 

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

1453 Hindsight's :class:`MemoryUnit`. 

1454 

1455 Originally introduced as ``PageIndexFact`` in M12.1 when fact-grain 

1456 was first added bolted to PageIndex sections (one section = one chat 

1457 session). Renamed in the M18b post-ship cleanup (2026-05-17) to 

1458 reflect its actual role: this is the Memory Engine's primary fact 

1459 type, not a Document-Engine concept. ``PageIndexFact`` remains as 

1460 a deprecated alias at the bottom of this file. 

1461 

1462 **Section anchoring is now OPTIONAL** (was NOT NULL pre-M18b 

1463 cleanup, migration 029). Facts ingested through a 

1464 Document/Conversation Engine path get a ``(document_id, line_num)`` 

1465 section anchor for cheap context-expansion and cascade-delete 

1466 semantics. Facts written directly via a future top-level retain 

1467 API (Hindsight-parity) can leave both fields ``None`` — Hindsight's 

1468 ``MemoryUnit.document_id`` is similarly nullable. 

1469 

1470 Anchored facts (typical today): 

1471 - ``document_id``: parent tree document UUID 

1472 - ``line_num``: section position within the tree 

1473 - FK to ``astrocyte_pi_sections``; ON DELETE CASCADE applies 

1474 

1475 Top-level facts (Hindsight-parity, future use): 

1476 - ``document_id = None``, ``line_num = None`` 

1477 - Live directly under the bank; no cascade source 

1478 """ 

1479 

1480 id: str 

1481 bank_id: str 

1482 text: str 

1483 fact_type: str # 'experience' | 'preference' | 'world' | 'plan' | 'opinion' (M25: assistant_statement deprecated → experience+speaker='assistant') 

1484 document_id: str | None = None 

1485 line_num: int | None = None 

1486 speaker: str | None = None 

1487 occurred_start: datetime | None = None 

1488 occurred_end: datetime | None = None 

1489 entities: list[str] | None = None 

1490 embedding: list[float] | None = None 

1491 #: M27 — per-fact confidence (0.0-1.0) emitted by the extraction LLM. 

1492 #: ``None`` for legacy rows + facts where the LLM did not emit one. 

1493 #: Surfaced in the answerer context so the model can hedge / abstain 

1494 #: on low-confidence facts and trust high-confidence ones. Mirrors 

1495 #: Hindsight's ``memory_units.confidence_score`` (CHECK 0..1). 

1496 confidence_score: float | None = None 

1497 #: M27 — when the fact was MENTIONED in conversation, distinct from 

1498 #: ``occurred_start`` (when the event happened). For section-anchored 

1499 #: facts this is the section's ``session_date``. ``None`` for 

1500 #: top-level facts or legacy rows. Hindsight calls this 

1501 #: ``mentioned_at`` on memory_units; the prompt explicitly tells 

1502 #: the answerer to distinguish "when it happened" from "when it 

1503 #: was discussed". 

1504 mentioned_at: datetime | None = None 

1505 #: M31 Fix 4 — single resolved absolute date for the fact's most- 

1506 #: prominent event. Populated at retain time by 

1507 #: :func:`astrocyte.pipeline.temporal_resolution.resolve_event_date` 

1508 #: when the fact's text contains a relative phrase ("last Tuesday", 

1509 #: "3 days ago"), parsed against the section's ``session_date``. 

1510 #: Distinct from ``occurred_start`` / ``occurred_end`` (LLM-emitted 

1511 #: explicit event-time range) and ``mentioned_at`` (the discussion 

1512 #: date). ``None`` when no relative date phrase is detected, when 

1513 #: the section has no anchor, or for legacy rows. Surfaced in the 

1514 #: answerer context so gpt-4o-mini doesn't have to do date math 

1515 #: at query time — the 60-70% LME temporal-reasoning ceiling we 

1516 #: kept hitting. 

1517 event_date: datetime | None = None 

1518 

1519 @property 

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

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

1522 

1523 The Astrocyte equivalent of Hindsight's ``memory_units.chunk_id`` 

1524 FK. Composed from ``(document_id, line_num)`` — the same 

1525 positional anchor that already links facts to their 

1526 :class:`PageIndexSection` source. Returns ``None`` for 

1527 top-level facts that have no document anchor. 

1528 

1529 The format ``"<document_id>:<line_num>"`` is stable across 

1530 runs as long as the document's section tree is unchanged. 

1531 Downstream consumers (notably the bench harness's 

1532 ``format_context_structured`` answerer prompt) use this id 

1533 to pair each fact with its source-chunk text inline, 

1534 mirroring Hindsight's ``_format_context_structured`` output. 

1535 

1536 See ``docs/_design/`` (M24 architectural fix) for why an 

1537 explicit identity surface matters even though the underlying 

1538 ``(document_id, line_num)`` pair is unchanged. 

1539 """ 

1540 if self.document_id is None or self.line_num is None: 

1541 return None 

1542 return f"{self.document_id}:{self.line_num}" 

1543 

1544 

1545@dataclass 

1546class MemoryFactHit: 

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

1548 

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

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

1551 """ 

1552 

1553 fact_id: str 

1554 text: str 

1555 fact_type: str 

1556 speaker: str | None 

1557 occurred_start: datetime | None 

1558 occurred_end: datetime | None 

1559 entities: list[str] 

1560 score: float 

1561 document_id: str | None = None 

1562 line_num: int | None = None 

1563 #: M27 — per-fact confidence (0.0-1.0) carried through from 

1564 #: :attr:`MemoryFact.confidence_score`. ``None`` for legacy rows. 

1565 confidence_score: float | None = None 

1566 #: M27 — when the fact was mentioned in conversation (session date), 

1567 #: distinct from ``occurred_start`` (when the event happened). 

1568 mentioned_at: datetime | None = None 

1569 #: M31 Fix 4 — resolved absolute date for the fact's most-prominent 

1570 #: event, carried through from :attr:`MemoryFact.event_date`. See 

1571 #: the MemoryFact docstring for the retain-time resolution 

1572 #: contract. ``None`` for legacy rows or facts whose text had no 

1573 #: parseable relative phrase. 

1574 event_date: datetime | None = None 

1575 

1576 @property 

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

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

1579 

1580 Mirrors :attr:`MemoryFact.chunk_id`. The bench harness uses 

1581 this to look up the section text and inject it inline with 

1582 the fact in the answerer's structured context. 

1583 """ 

1584 if self.document_id is None or self.line_num is None: 

1585 return None 

1586 return f"{self.document_id}:{self.line_num}" 

1587 

1588 

1589# ─── Backward-compat aliases (M18b post-ship rename, 2026-05-17) ─── 

1590# 

1591# The pre-M18b names ``PageIndexFact`` / ``PageIndexFactHit`` remain 

1592# usable indefinitely; new code should prefer ``MemoryFact`` / 

1593# ``MemoryFactHit``. The aliases let the 15 existing call sites 

1594# migrate gradually rather than in one churning commit. Both names 

1595# resolve to the same dataclass — ``isinstance(x, PageIndexFact)`` 

1596# and ``isinstance(x, MemoryFact)`` are equivalent. 

1597PageIndexFact = MemoryFact 

1598PageIndexFactHit = MemoryFactHit 

1599 

1600 

1601@dataclass 

1602class PageIndexSectionEntity: 

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

1604 

1605 document_id: str 

1606 line_num: int 

1607 entity_name: str 

1608 

1609 

1610@dataclass 

1611class PageIndexSectionLink: 

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

1613 

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

1615 See ADR-006 §4.2 and migration 015. 

1616 """ 

1617 

1618 from_doc: str 

1619 from_line: int 

1620 to_doc: str 

1621 to_line: int 

1622 link_type: str 

1623 weight: float 

1624 

1625 

1626# --------------------------------------------------------------------------- 

1627# PII detection (used by policy layer) 

1628# --------------------------------------------------------------------------- 

1629 

1630 

1631@dataclass 

1632class PiiMatch: 

1633 pii_type: str # "email", "phone", "ssn", "credit_card", "ip_address", … 

1634 start: int 

1635 end: int 

1636 matched_text: str 

1637 replacement: str | None = None