rememberstack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. rememberstack/__init__.py +9 -0
  2. rememberstack/adapters/__init__.py +42 -0
  3. rememberstack/adapters/codex_writer.py +221 -0
  4. rememberstack/adapters/markitdown_converter.py +42 -0
  5. rememberstack/adapters/openrouter.py +136 -0
  6. rememberstack/adapters/selfhost/__init__.py +54 -0
  7. rememberstack/adapters/selfhost/forget.py +66 -0
  8. rememberstack/adapters/selfhost/git.py +374 -0
  9. rememberstack/adapters/selfhost/lance.py +328 -0
  10. rememberstack/adapters/selfhost/minio.py +279 -0
  11. rememberstack/adapters/selfhost/mounts.py +249 -0
  12. rememberstack/adapters/selfhost/object_store.py +130 -0
  13. rememberstack/adapters/selfhost/projection.py +80 -0
  14. rememberstack/adapters/selfhost/queue.py +137 -0
  15. rememberstack/adapters/selfhost/telemetry.py +45 -0
  16. rememberstack/adapters/selfhost/watcher.py +70 -0
  17. rememberstack/adapters/testing/__init__.py +15 -0
  18. rememberstack/adapters/testing/cost_meter.py +13 -0
  19. rememberstack/adapters/testing/model_provider.py +83 -0
  20. rememberstack/adapters/testing/queue.py +43 -0
  21. rememberstack/adapters/testing/telemetry.py +22 -0
  22. rememberstack/client.py +19 -0
  23. rememberstack/core/__init__.py +127 -0
  24. rememberstack/core/blockizer.py +189 -0
  25. rememberstack/core/chunker.py +216 -0
  26. rememberstack/core/consumption_skill.py +275 -0
  27. rememberstack/core/conversion.py +76 -0
  28. rememberstack/core/core_manifest.py +598 -0
  29. rememberstack/core/extension_packs.py +124 -0
  30. rememberstack/core/forget.py +17 -0
  31. rememberstack/core/knowledge_authored.py +276 -0
  32. rememberstack/core/knowledge_compile.py +215 -0
  33. rememberstack/core/knowledge_fact_sheet.py +210 -0
  34. rememberstack/core/knowledge_hashing.py +68 -0
  35. rememberstack/core/knowledge_planner.py +64 -0
  36. rememberstack/core/knowledge_writer.py +175 -0
  37. rememberstack/core/ranking.py +200 -0
  38. rememberstack/core/recipe_linter.py +149 -0
  39. rememberstack/core/section_snap.py +209 -0
  40. rememberstack/core/storage_routing.py +27 -0
  41. rememberstack/eval/__init__.py +53 -0
  42. rememberstack/eval/consumption.py +141 -0
  43. rememberstack/eval/contradiction.py +184 -0
  44. rememberstack/eval/harness.py +136 -0
  45. rememberstack/eval/lifecycle.py +400 -0
  46. rememberstack/eval/operational_scale.py +49 -0
  47. rememberstack/eval/resolution.py +255 -0
  48. rememberstack/eval/retrieval_spikes.py +50 -0
  49. rememberstack/eval/skeleton.py +231 -0
  50. rememberstack/llm/__init__.py +1 -0
  51. rememberstack/model/__init__.py +589 -0
  52. rememberstack/model/adjudication.py +100 -0
  53. rememberstack/model/auth.py +27 -0
  54. rememberstack/model/blocks.py +30 -0
  55. rememberstack/model/chunks.py +190 -0
  56. rememberstack/model/claims.py +162 -0
  57. rememberstack/model/client.py +98 -0
  58. rememberstack/model/clustering.py +54 -0
  59. rememberstack/model/component_version.py +124 -0
  60. rememberstack/model/consumption.py +88 -0
  61. rememberstack/model/conversion.py +31 -0
  62. rememberstack/model/deployment.py +53 -0
  63. rememberstack/model/documents.py +168 -0
  64. rememberstack/model/envelope.py +513 -0
  65. rememberstack/model/evaluation.py +72 -0
  66. rememberstack/model/forget.py +143 -0
  67. rememberstack/model/git.py +13 -0
  68. rememberstack/model/knowledge.py +840 -0
  69. rememberstack/model/knowledge_authored.py +325 -0
  70. rememberstack/model/knowledge_planner.py +431 -0
  71. rememberstack/model/lifecycle.py +42 -0
  72. rememberstack/model/model_provider.py +78 -0
  73. rememberstack/model/mounts.py +24 -0
  74. rememberstack/model/object_store.py +21 -0
  75. rememberstack/model/operational_scale.py +59 -0
  76. rememberstack/model/operations.py +153 -0
  77. rememberstack/model/processing.py +228 -0
  78. rememberstack/model/queue.py +73 -0
  79. rememberstack/model/recipes.py +83 -0
  80. rememberstack/model/relations.py +79 -0
  81. rememberstack/model/resolution.py +83 -0
  82. rememberstack/model/retrieval_spikes.py +62 -0
  83. rememberstack/model/sections.py +120 -0
  84. rememberstack/model/telemetry.py +30 -0
  85. rememberstack/ports/__init__.py +29 -0
  86. rememberstack/ports/auth.py +16 -0
  87. rememberstack/ports/connector.py +23 -0
  88. rememberstack/ports/cost_meter.py +17 -0
  89. rememberstack/ports/forget.py +20 -0
  90. rememberstack/ports/git.py +20 -0
  91. rememberstack/ports/model_provider.py +28 -0
  92. rememberstack/ports/mounts.py +16 -0
  93. rememberstack/ports/object_store.py +27 -0
  94. rememberstack/ports/p1_index.py +92 -0
  95. rememberstack/ports/purge.py +93 -0
  96. rememberstack/ports/queue.py +23 -0
  97. rememberstack/ports/telemetry.py +21 -0
  98. rememberstack/profiles/__init__.py +22 -0
  99. rememberstack/profiles/selfhost.py +324 -0
  100. rememberstack/profiles/selfhost_forget.py +158 -0
  101. rememberstack/profiles/selfhost_operations.py +95 -0
  102. rememberstack/py.typed +1 -0
  103. rememberstack/spine/__init__.py +93 -0
  104. rememberstack/spine/admission.py +26 -0
  105. rememberstack/spine/backfill.py +168 -0
  106. rememberstack/spine/catalog_contract.py +742 -0
  107. rememberstack/spine/chunk_catalog.py +237 -0
  108. rememberstack/spine/claim_catalog.py +298 -0
  109. rememberstack/spine/clustering.py +740 -0
  110. rememberstack/spine/component_versions.py +208 -0
  111. rememberstack/spine/consumption.py +81 -0
  112. rememberstack/spine/deployment_bootstrap.py +445 -0
  113. rememberstack/spine/document_catalog.py +621 -0
  114. rememberstack/spine/entity_registry.py +205 -0
  115. rememberstack/spine/extension_packs.py +220 -0
  116. rememberstack/spine/fact_catalog.py +571 -0
  117. rememberstack/spine/forget.py +1753 -0
  118. rememberstack/spine/knowledge.py +5467 -0
  119. rememberstack/spine/lifecycle.py +1071 -0
  120. rememberstack/spine/migrations/__init__.py +1 -0
  121. rememberstack/spine/migrations/_helpers.py +153 -0
  122. rememberstack/spine/migrations/env.py +58 -0
  123. rememberstack/spine/migrations/script.py.mako +27 -0
  124. rememberstack/spine/migrations/versions/__init__.py +1 -0
  125. rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
  126. rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
  127. rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
  128. rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
  129. rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
  130. rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
  131. rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
  132. rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
  133. rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
  134. rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
  135. rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
  136. rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
  137. rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
  138. rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
  139. rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
  140. rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
  141. rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
  142. rememberstack/spine/observation_adjudication.py +778 -0
  143. rememberstack/spine/operations.py +298 -0
  144. rememberstack/spine/projection.py +662 -0
  145. rememberstack/spine/recipes.py +276 -0
  146. rememberstack/spine/resolver.py +763 -0
  147. rememberstack/spine/review.py +650 -0
  148. rememberstack/spine/settings.py +22 -0
  149. rememberstack/spine/supersession.py +510 -0
  150. rememberstack/spine/sync.py +128 -0
  151. rememberstack/spine/work_ledger.py +816 -0
  152. rememberstack/surfaces/__init__.py +110 -0
  153. rememberstack/surfaces/cli.py +447 -0
  154. rememberstack/surfaces/consumption_skill.py +87 -0
  155. rememberstack/surfaces/graph_queries.py +698 -0
  156. rememberstack/surfaces/http_api.py +377 -0
  157. rememberstack/surfaces/mcp.py +67 -0
  158. rememberstack/surfaces/query_engine.py +1591 -0
  159. rememberstack/surfaces/recipe_executor.py +185 -0
  160. rememberstack/surfaces/recipe_surface.py +219 -0
  161. rememberstack/surfaces/remote_mcp.py +133 -0
  162. rememberstack/surfaces/sdk.py +324 -0
  163. rememberstack/workers/__init__.py +155 -0
  164. rememberstack/workers/base.py +312 -0
  165. rememberstack/workers/e0.py +577 -0
  166. rememberstack/workers/e1.py +425 -0
  167. rememberstack/workers/e2.py +525 -0
  168. rememberstack/workers/e3.py +434 -0
  169. rememberstack/workers/forget.py +299 -0
  170. rememberstack/workers/knowledge_authored.py +146 -0
  171. rememberstack/workers/knowledge_driver.py +735 -0
  172. rememberstack/workers/knowledge_fact_sheet.py +123 -0
  173. rememberstack/workers/knowledge_planner.py +325 -0
  174. rememberstack/workers/knowledge_writer.py +393 -0
  175. rememberstack/workers/operations.py +42 -0
  176. rememberstack/workers/p1.py +234 -0
  177. rememberstack/workers/p2.py +513 -0
  178. rememberstack/workers/p2_analytics.py +276 -0
  179. rememberstack/workers/p3.py +673 -0
  180. rememberstack/workers/reconcile.py +485 -0
  181. rememberstack/workers/sync.py +168 -0
  182. rememberstack-0.1.0.dist-info/METADATA +213 -0
  183. rememberstack-0.1.0.dist-info/RECORD +186 -0
  184. rememberstack-0.1.0.dist-info/WHEEL +4 -0
  185. rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
  186. rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1591 @@
1
+ """The zero-LLM query engine (retrieval §2-§3): resolve, lookup, search, hydrate.
2
+
3
+ The one correctness rule is D48: projections (P1 Lance) may NOMINATE
4
+ candidates, but every returned record has passed by-ID hydration against the
5
+ live Postgres spine — a superseded fact can never be served as current, and
6
+ nominations hydration rejects are counted in `dropped_by_hydration` so ranked
7
+ results are honest about their denominator. No primitive calls an LLM; reads
8
+ never trigger anything.
9
+ """
10
+
11
+ import base64
12
+ import binascii
13
+ from collections.abc import Iterator
14
+ from collections.abc import Sequence
15
+ from datetime import datetime
16
+ from datetime import UTC
17
+ from itertools import batched
18
+ from typing import Final
19
+ from uuid import UUID
20
+
21
+ from sqlalchemy import text
22
+ from sqlalchemy import TextClause
23
+ from sqlalchemy.engine import Engine
24
+ from sqlalchemy.engine import RowMapping
25
+
26
+ from rememberstack.core.ranking import DEFAULT_RRF_K
27
+ from rememberstack.core.ranking import reciprocal_rank_fusion
28
+ from rememberstack.core.ranking import rerank_by_signal
29
+ from rememberstack.core.ranking import rerank_by_weighted_signals
30
+ from rememberstack.model import AggregateBucket
31
+ from rememberstack.model import AggregateReport
32
+ from rememberstack.model import ChangeRecord
33
+ from rememberstack.model import CoMember
34
+ from rememberstack.model import Contradiction
35
+ from rememberstack.model import EmbeddingRequest
36
+ from rememberstack.model import EntityCandidate
37
+ from rememberstack.model import Envelope
38
+ from rememberstack.model import EvidenceResult
39
+ from rememberstack.model import FactResult
40
+ from rememberstack.model import FactSupport
41
+ from rememberstack.model import Freshness
42
+ from rememberstack.model import Grain
43
+ from rememberstack.model import Negative
44
+ from rememberstack.model import NegativeKind
45
+ from rememberstack.model import PageRef
46
+ from rememberstack.model import RankedItem
47
+ from rememberstack.model import ScanRow
48
+ from rememberstack.model import SourceRecord
49
+ from rememberstack.model import TranscriptEntry
50
+ from rememberstack.model import Truncation
51
+ from rememberstack.model import Validity
52
+ from rememberstack.ports.model_provider import ModelProviderPort
53
+ from rememberstack.ports.p1_index import P1SearchPort
54
+ from rememberstack.spine.entity_registry import normalized_lemma
55
+
56
+ DEFAULT_DELTA_LIMIT = 500
57
+ """How many change-feed rows one `delta` page returns before truncating —
58
+ a starting point to measure, not a committed constant (retrieval §13)."""
59
+
60
+ DEFAULT_SCAN_BATCH = 1_000
61
+ """How many rows the batch `scan` cursor fetches per round-trip."""
62
+
63
+ CONTRADICTION_COMEMBER_CAP = 25
64
+ """How many co-members a contradiction block returns inline before it pages
65
+ (S23). Typical groups are 2–3 sides, so the cap is rarely reached — but when
66
+ it is, the block still carries group_id/returned/total/continuation, never a
67
+ one-sided answer. WP-5.6 measured this starting cap below its explicit 16 KiB
68
+ inline-envelope budget; that budget is an operating target, not a protocol
69
+ limit."""
70
+
71
+ RESOLVE_CONTEXT_LIMIT: Final = 8
72
+ """Maximum focal entities in WP-5.6's bounded S51 context tie-break."""
73
+
74
+ INTERACTIVE_HYDRATION_BATCH_SIZE: Final = 256
75
+ """Maximum ids in one WP-5.6-measured Postgres confirmation hop."""
76
+
77
+ _RERANK_SIGNALS = {"graph_distance": True, "evidence_count": False}
78
+ """The inspectable rerank signals and whether each sorts ascending: nearer
79
+ the focal entity wins (ascending), more corroboration wins (descending)."""
80
+
81
+ _BOUNDED_AGGREGATE_FORMS = frozenset(
82
+ {"group_by_predicate", "group_by_object", "delta_top_entities", "typed_absence"}
83
+ )
84
+ """The aggregate forms that take a `limit` and so must disclose truncation.
85
+ `count` and `timeline` are naturally bounded (one row / one row per year)."""
86
+
87
+
88
+ def _encode_feed_cursor(*, at: datetime, item_id: UUID) -> str:
89
+ """Pack a delta feed position into one opaque, resumable token."""
90
+ raw = f"{at.isoformat()}|{item_id}".encode()
91
+ return base64.urlsafe_b64encode(raw).decode()
92
+
93
+
94
+ def _decode_feed_cursor(token: str | None) -> tuple[datetime, UUID] | None:
95
+ """Unpack a feed cursor into (at, id), or None when there is no cursor."""
96
+ if token is None:
97
+ return None
98
+ try:
99
+ raw = base64.urlsafe_b64decode(token.encode()).decode()
100
+ at_text, id_text = raw.rsplit("|", 1)
101
+ return (datetime.fromisoformat(at_text), UUID(id_text))
102
+ except (ValueError, binascii.Error) as error:
103
+ raise ValueError(f"invalid delta continuation: {token!r}") from error
104
+
105
+
106
+ class QueryEngine:
107
+ """The typed read path over one deployment's spine and P1 indexes."""
108
+
109
+ def __init__(
110
+ self,
111
+ *,
112
+ engine: Engine,
113
+ search_index: P1SearchPort,
114
+ model_provider: ModelProviderPort,
115
+ embedding_model: str,
116
+ batch_engine: Engine | None = None,
117
+ ) -> None:
118
+ """Bind the engine to the spine, the P1 indexes, and the embedder.
119
+
120
+ Embedding a query string is not an LLM call (retrieval §3): the
121
+ provider's embed endpoint is the semantic channel's entry.
122
+
123
+ `batch_engine` is the SEPARATE resource pool the batch surface uses
124
+ (retrieval §9): `scan`'s streaming exports run against it so a large
125
+ export can never starve the interactive connection pool. It defaults
126
+ to the interactive engine — correct for a single-pool deployment —
127
+ but a deployment that wants isolation passes a second engine bound
128
+ to its own connection pool.
129
+ """
130
+ self._engine = engine
131
+ self._search_index = search_index
132
+ self._model_provider = model_provider
133
+ self._embedding_model = embedding_model
134
+ self._batch_engine = batch_engine or engine
135
+
136
+ def resolve(
137
+ self,
138
+ *,
139
+ deployment_id: UUID,
140
+ name: str,
141
+ entity_type: str | None = None,
142
+ context_entity_ids: tuple[UUID, ...] = (),
143
+ ) -> Envelope:
144
+ """Resolve a name to ranked current entities (T0 in the skeleton).
145
+
146
+ Nothing resolving is the `unknown_entity` negative (S39) — the agent
147
+ widens resolution or searches; it never gets a silent guess (S51).
148
+ Optional focal entities only reorder exact-name candidates by current
149
+ relation adjacency; every candidate remains visible, so context can
150
+ narrow ambiguity without becoming a silent identity verdict.
151
+ """
152
+ context_entity_ids = tuple(dict.fromkeys(context_entity_ids))
153
+ if len(context_entity_ids) > RESOLVE_CONTEXT_LIMIT:
154
+ raise ValueError(
155
+ f"resolve context accepts at most {RESOLVE_CONTEXT_LIMIT} entities"
156
+ )
157
+ with self._engine.connect() as connection:
158
+ rows = (
159
+ connection.execute(
160
+ _RESOLVE_T0,
161
+ {
162
+ "deployment_id": deployment_id,
163
+ "lemma": normalized_lemma(surface=name),
164
+ "entity_type": entity_type,
165
+ },
166
+ )
167
+ .mappings()
168
+ .all()
169
+ )
170
+ candidate_ids = tuple(row["entity_id"] for row in rows)
171
+ context_hits = (
172
+ {
173
+ row["candidate_id"]: int(row["context_hits"])
174
+ for row in connection.execute(
175
+ _RESOLVE_CONTEXT_HITS,
176
+ {
177
+ "deployment_id": deployment_id,
178
+ "candidate_ids": list(candidate_ids),
179
+ "context_entity_ids": list(context_entity_ids),
180
+ },
181
+ ).mappings()
182
+ }
183
+ if candidate_ids and context_entity_ids
184
+ else {}
185
+ )
186
+ candidates = tuple(
187
+ EntityCandidate(
188
+ entity_id=row["entity_id"],
189
+ canonical_name=row["canonical_name"],
190
+ type=row["type"],
191
+ tier="T0",
192
+ context_hits=context_hits.get(row["entity_id"], 0),
193
+ )
194
+ for row in sorted(
195
+ rows,
196
+ key=lambda row: (
197
+ -context_hits.get(row["entity_id"], 0),
198
+ str(row["canonical_name"]),
199
+ row["entity_id"].bytes,
200
+ ),
201
+ )
202
+ )
203
+ return Envelope(
204
+ grain=Grain.FACT,
205
+ entities=candidates,
206
+ freshness=_freshness(),
207
+ negative=None
208
+ if candidates
209
+ else Negative(
210
+ kind=NegativeKind.UNKNOWN_ENTITY,
211
+ explanation=f"nothing resolves for {name!r}",
212
+ workaround="check spelling, try search over claims or chunks",
213
+ ),
214
+ )
215
+
216
+ def lookup_relations(
217
+ self,
218
+ *,
219
+ deployment_id: UUID,
220
+ subject_entity_id: UUID | None = None,
221
+ predicate: str | None = None,
222
+ object_entity_id: UUID | None = None,
223
+ valid_at: datetime | None = None,
224
+ ) -> Envelope:
225
+ """Relations matching the (s, p, o) pattern — fact grain (S1/S3/S9).
226
+
227
+ Without `valid_at`, current means both clocks: still believed AND the
228
+ valid-time window covers now. With `valid_at`, the window test moves
229
+ to that instant (the S9-class as-of read; belief stays live — the
230
+ believed_at axis arrives with its own parameter). The applied instant
231
+ is echoed in the envelope. An existing entity with no matching facts
232
+ is `known_empty` (S39).
233
+ """
234
+ as_of = valid_at or datetime.now(tz=UTC)
235
+ with self._engine.connect() as connection:
236
+ rows = (
237
+ connection.execute(
238
+ _LOOKUP_RELATIONS,
239
+ {
240
+ "deployment_id": deployment_id,
241
+ "subject_entity_id": subject_entity_id,
242
+ "predicate": predicate,
243
+ "object_entity_id": object_entity_id,
244
+ "as_of": as_of,
245
+ },
246
+ )
247
+ .mappings()
248
+ .all()
249
+ )
250
+ facts = self._enrich_facts(
251
+ deployment_id=deployment_id,
252
+ facts=tuple(_fact_result(row=row, kind="relation") for row in rows),
253
+ kind="relation",
254
+ )
255
+ return Envelope(
256
+ grain=Grain.FACT,
257
+ as_of_valid_at=valid_at,
258
+ facts=facts,
259
+ freshness=_freshness(),
260
+ negative=None
261
+ if facts
262
+ else Negative(
263
+ kind=NegativeKind.KNOWN_EMPTY,
264
+ explanation="no live relations match the pattern",
265
+ workaround=None,
266
+ ),
267
+ )
268
+
269
+ def lookup_observations(
270
+ self,
271
+ *,
272
+ deployment_id: UUID,
273
+ entity_id: UUID,
274
+ property_query: str | None = None,
275
+ k: int = 10,
276
+ valid_at: datetime | None = None,
277
+ ) -> Envelope:
278
+ """Observations on one entity — current, or as-of on the valid-time
279
+ axis (S2/S9, D43): "headcount mid-2024" is the capped slice whose
280
+ window covers that instant.
281
+
282
+ With a property query, the facts channel NOMINATES by label similarity
283
+ and the spine confirms live rows (D48); without one, the entity block
284
+ is read directly.
285
+ """
286
+ dropped = 0
287
+ as_of = valid_at or datetime.now(tz=UTC)
288
+ if property_query is None:
289
+ with self._engine.connect() as connection:
290
+ rows = (
291
+ connection.execute(
292
+ _LOOKUP_OBSERVATIONS,
293
+ {
294
+ "deployment_id": deployment_id,
295
+ "entity_id": entity_id,
296
+ "as_of": as_of,
297
+ },
298
+ )
299
+ .mappings()
300
+ .all()
301
+ )
302
+ else:
303
+ nominated = self._search_index.search_facts(
304
+ deployment_id=str(deployment_id),
305
+ vector=self._embed(query=property_query),
306
+ k=k,
307
+ kind="observation",
308
+ )
309
+ rows, dropped = self._confirm_observations(
310
+ deployment_id=deployment_id,
311
+ entity_id=entity_id,
312
+ observation_ids=tuple(UUID(item) for item in nominated),
313
+ as_of=as_of,
314
+ )
315
+ facts = self._enrich_facts(
316
+ deployment_id=deployment_id,
317
+ facts=tuple(_fact_result(row=row, kind="observation") for row in rows),
318
+ kind="observation",
319
+ )
320
+ return Envelope(
321
+ grain=Grain.FACT,
322
+ as_of_valid_at=valid_at,
323
+ facts=facts,
324
+ freshness=_freshness(),
325
+ dropped_by_hydration=dropped,
326
+ negative=None
327
+ if facts
328
+ else Negative(
329
+ kind=NegativeKind.KNOWN_EMPTY,
330
+ explanation="no live observations match on this entity",
331
+ workaround=None,
332
+ ),
333
+ )
334
+
335
+ def search_claims(
336
+ self, *, deployment_id: UUID, query: str, k: int = 10
337
+ ) -> Envelope:
338
+ """Semantic claim search — EVIDENCE grain, never a current-fact answer.
339
+
340
+ The claims channel nominates (current-testimony-only by default);
341
+ hydration re-reads each claim from the spine and drops what no longer
342
+ confirms, counting the drops (D48 nominate-then-drop honesty).
343
+ """
344
+ nominated = self._search_index.search_claims(
345
+ deployment_id=str(deployment_id),
346
+ vector=self._embed(query=query),
347
+ k=k,
348
+ current_only=True,
349
+ )
350
+ evidence, dropped = self._confirm_claims(
351
+ deployment_id=deployment_id,
352
+ claim_ids=tuple(UUID(item) for item in nominated),
353
+ )
354
+ return Envelope(
355
+ grain=Grain.EVIDENCE,
356
+ evidence=evidence,
357
+ freshness=_freshness(),
358
+ dropped_by_hydration=dropped,
359
+ negative=None
360
+ if evidence
361
+ else Negative(
362
+ kind=NegativeKind.KNOWN_EMPTY,
363
+ explanation="no current-testimony claims match the query",
364
+ workaround="broaden the query or inspect the source artifacts",
365
+ ),
366
+ )
367
+
368
+ def hydrate_relation(self, *, deployment_id: UUID, relation_id: UUID) -> Envelope:
369
+ """The S5 chain: relation → evidence claims → source documents.
370
+
371
+ Composite grain: the fact, its supporting evidence-grain claims
372
+ (verbatim spans and offsets against the representation they were cut
373
+ from), and the ID-addressed document handles. Hydrate-by-ID is the
374
+ AUDIT deepening hop: an invalidated relation is returned with its
375
+ invalidation disclosed in `validity` (D48 re-reads and discloses —
376
+ it does not refuse audit access); current-fact questions route
377
+ through lookup, which filters both clocks.
378
+ """
379
+ with self._engine.connect() as connection:
380
+ relation = (
381
+ connection.execute(
382
+ _HYDRATE_RELATION,
383
+ {"deployment_id": deployment_id, "relation_id": relation_id},
384
+ )
385
+ .mappings()
386
+ .one_or_none()
387
+ )
388
+ if relation is None:
389
+ return Envelope(
390
+ grain=Grain.COMPOSITE,
391
+ freshness=_freshness(),
392
+ negative=Negative(
393
+ kind=NegativeKind.UNKNOWN_ENTITY,
394
+ explanation=f"relation {relation_id} does not exist",
395
+ workaround=None,
396
+ ),
397
+ )
398
+ claims = (
399
+ connection.execute(
400
+ _HYDRATE_EVIDENCE_CLAIMS, {"relation_id": relation_id}
401
+ )
402
+ .mappings()
403
+ .all()
404
+ )
405
+ sources = (
406
+ connection.execute(_HYDRATE_SOURCES, {"relation_id": relation_id})
407
+ .mappings()
408
+ .all()
409
+ )
410
+ # the audit hop discloses the same S23 contradiction and D54 support
411
+ # as a lookup — a contradicted relation is never hydrated one-sided
412
+ facts = self._enrich_facts(
413
+ deployment_id=deployment_id,
414
+ facts=(_fact_result(row=relation, kind="relation"),),
415
+ kind="relation",
416
+ )
417
+ return Envelope(
418
+ grain=Grain.COMPOSITE,
419
+ facts=facts,
420
+ evidence=tuple(EvidenceResult.model_validate(dict(row)) for row in claims),
421
+ sources=tuple(SourceRecord.model_validate(dict(row)) for row in sources),
422
+ freshness=_freshness(),
423
+ )
424
+
425
+ def transcript(
426
+ self, *, deployment_id: UUID, subject_kind: str, subject_id: UUID
427
+ ) -> Envelope:
428
+ """The S8/S32/S35 audit query: any subject's decision history.
429
+
430
+ "Why do we believe this?" as a first-class read, uniform across the
431
+ four subjects a decision is about: a supersession-adjudicated
432
+ `relation` or `observation`, a resolved/merged `entity` (its
433
+ resolution decisions braided with its merges), or a compiled
434
+ `k_page` (its compile provenance). Returned newest-last; reads never
435
+ trigger anything. An empty history is `known_empty`, not a guess; an
436
+ unknown kind is a `boundary` naming the four that exist.
437
+ """
438
+ statement = _TRANSCRIPT_BY_KIND.get(subject_kind)
439
+ if statement is None:
440
+ return Envelope(
441
+ grain=Grain.COMPOSITE,
442
+ freshness=_freshness(),
443
+ negative=Negative(
444
+ kind=NegativeKind.BOUNDARY,
445
+ explanation=(f"no transcript for subject kind {subject_kind!r}"),
446
+ workaround="use one of: relation, observation, entity, k_page",
447
+ ),
448
+ )
449
+ with self._engine.connect() as connection:
450
+ rows = (
451
+ connection.execute(
452
+ statement,
453
+ {"deployment_id": deployment_id, "subject_id": subject_id},
454
+ )
455
+ .mappings()
456
+ .all()
457
+ )
458
+ return Envelope(
459
+ grain=Grain.COMPOSITE,
460
+ transcript=tuple(TranscriptEntry.model_validate(dict(row)) for row in rows),
461
+ freshness=_freshness(),
462
+ negative=None
463
+ if rows
464
+ else Negative(
465
+ kind=NegativeKind.KNOWN_EMPTY,
466
+ explanation=f"no decision history for this {subject_kind}",
467
+ workaround=None,
468
+ ),
469
+ )
470
+
471
+ def transcript_relation(
472
+ self, *, deployment_id: UUID, relation_id: UUID
473
+ ) -> Envelope:
474
+ """A relation's decision history — the `transcript` primitive, relation
475
+ arm (kept as the named surface the HTTP API and recipes bind to)."""
476
+ return self.transcript(
477
+ deployment_id=deployment_id, subject_kind="relation", subject_id=relation_id
478
+ )
479
+
480
+ def fuse(
481
+ self, *, rankings: Sequence[Sequence[UUID]], k: int = DEFAULT_RRF_K
482
+ ) -> Envelope:
483
+ """RRF-merge parallel channel rankings into one order (D9/S46).
484
+
485
+ An operator, not a spine read: the same reciprocal-rank fusion a
486
+ recipe applies, exposed so an agent's ad-hoc channel set fuses
487
+ identically. The grain is EVIDENCE — a fused order is over
488
+ nominations still to be confirmed by id-hydration (D48), never
489
+ current-fact truth on its own.
490
+ """
491
+ fused = reciprocal_rank_fusion(rankings=rankings, k=k)
492
+ return Envelope(
493
+ grain=Grain.EVIDENCE,
494
+ ranking=fused,
495
+ freshness=_freshness(),
496
+ negative=None
497
+ if fused
498
+ else Negative(
499
+ kind=NegativeKind.KNOWN_EMPTY,
500
+ explanation="no channel supplied any candidate to fuse",
501
+ workaround=None,
502
+ ),
503
+ )
504
+
505
+ def rerank(self, *, items: Sequence[RankedItem], signal: str) -> Envelope:
506
+ """Reorder candidates by one inspectable signal (D9/S46/S48).
507
+
508
+ `graph_distance` and `evidence_count` are the direct signals;
509
+ `weighted_relevance` applies WP-5.6's measured normalized blend while
510
+ preserving every contribution on the item. `cross_encoder` needs a
511
+ configured reranker port and is off by default — asking for it, or
512
+ for any unknown signal, is a typed `boundary`, never a silent
513
+ identity sort.
514
+ """
515
+ if signal == "cross_encoder":
516
+ return self._rerank_boundary(
517
+ explanation=(
518
+ "cross-encoder reranking needs a configured reranker port"
519
+ " and is off by default"
520
+ ),
521
+ workaround=(
522
+ "use graph_distance, evidence_count, or weighted_relevance"
523
+ ),
524
+ )
525
+ if signal == "weighted_relevance":
526
+ ranked = rerank_by_weighted_signals(items=items)
527
+ return Envelope(
528
+ grain=Grain.EVIDENCE, ranking=ranked, freshness=_freshness()
529
+ )
530
+ ascending = _RERANK_SIGNALS.get(signal)
531
+ if ascending is None:
532
+ return self._rerank_boundary(
533
+ explanation=f"no rerank signal {signal!r}",
534
+ workaround=(
535
+ "use graph_distance, evidence_count, or weighted_relevance"
536
+ ),
537
+ )
538
+ ranked = rerank_by_signal(items=items, signal=signal, ascending=ascending)
539
+ return Envelope(grain=Grain.EVIDENCE, ranking=ranked, freshness=_freshness())
540
+
541
+ def delta(
542
+ self,
543
+ *,
544
+ deployment_id: UUID,
545
+ since: datetime,
546
+ kinds: tuple[str, ...] | None = None,
547
+ limit: int = DEFAULT_DELTA_LIMIT,
548
+ continuation: str | None = None,
549
+ ) -> Envelope:
550
+ """The change feed as a query: what changed since `since` (S13/S14/S30).
551
+
552
+ Four timestamped change types across the evidence kinds and K pages:
553
+ `new` (ingested after `since`), `invalidated` (retracted after it —
554
+ source-removal retractions land here too, since they set
555
+ `invalidated_at`), `capped` (a relation or observation whose validity
556
+ window a supersede closed — dated by the adjudication), and
557
+ `recompiled` (a K page rebuilt after it). `kinds` filters to a subset
558
+ of {relation, observation, claim, page}.
559
+
560
+ Ordered newest-first over the FULL `(at, id)` key and bounded: hitting
561
+ `limit` sets a truncation marker carrying an opaque `continuation`.
562
+ Paginating means passing that token back (keeping the same `since`) —
563
+ it resumes strictly before the last row seen, so a page boundary that
564
+ splits rows sharing one timestamp never drops the tied remainder.
565
+ """
566
+ if limit < 1:
567
+ raise ValueError("limit must be at least 1")
568
+ cursor = _decode_feed_cursor(continuation)
569
+ with self._engine.connect() as connection:
570
+ rows = (
571
+ connection.execute(
572
+ _DELTA_FEED,
573
+ {
574
+ "deployment_id": deployment_id,
575
+ "since": since,
576
+ "kinds": list(kinds) if kinds else None,
577
+ "cursor_at": cursor[0] if cursor else None,
578
+ "cursor_id": str(cursor[1]) if cursor else None,
579
+ "fetch": limit + 1,
580
+ },
581
+ )
582
+ .mappings()
583
+ .all()
584
+ )
585
+ truncated = len(rows) > limit
586
+ kept = rows[:limit]
587
+ changes = tuple(
588
+ ChangeRecord(
589
+ kind=row["kind"],
590
+ change=row["change"],
591
+ id=row["id"],
592
+ label=row["label"],
593
+ at=row["at"],
594
+ )
595
+ for row in kept
596
+ )
597
+ next_cursor = (
598
+ _encode_feed_cursor(at=kept[-1]["at"], item_id=kept[-1]["id"])
599
+ if truncated and kept
600
+ else None
601
+ )
602
+ return Envelope(
603
+ grain=Grain.COMPOSITE,
604
+ as_of_believed_at=since,
605
+ changes=changes,
606
+ freshness=_freshness(),
607
+ truncation=Truncation(
608
+ truncated=truncated,
609
+ returned=len(changes),
610
+ estimated_total=len(changes),
611
+ total_is_exact=not truncated,
612
+ continuation=next_cursor,
613
+ ),
614
+ negative=None
615
+ if changes
616
+ else Negative(
617
+ kind=NegativeKind.KNOWN_EMPTY,
618
+ explanation="nothing changed in the requested window",
619
+ workaround=None,
620
+ ),
621
+ )
622
+
623
+ def pages_about(
624
+ self,
625
+ *,
626
+ deployment_id: UUID,
627
+ entity_id: UUID | None = None,
628
+ key_kind: str | None = None,
629
+ key_value: str | None = None,
630
+ ) -> Envelope:
631
+ """Which K pages exist about a subject (S31/S45): the routing index,
632
+ read backwards.
633
+
634
+ The rule-key inverted index built to ROUTE writes doubles as the
635
+ reader's discovery index — mechanically, no LLM. Pass an `entity_id`
636
+ (shorthand for the `entity` key) or an explicit `key_kind`/`key_value`
637
+ (`predicate`, `community`, `doc_source`). Each page reports its
638
+ compile state and a `stale` flag — inputs changed but not yet
639
+ recompiled — so discovery never presents an out-of-date page as
640
+ fresh. COMPILED grain: these are pre-paid syntheses, not raw facts.
641
+ """
642
+ if entity_id is not None:
643
+ key_kind, key_value = "entity", str(entity_id)
644
+ if key_kind is None or key_value is None:
645
+ raise ValueError("pages_about needs an entity_id or a key_kind+key_value")
646
+ with self._engine.connect() as connection:
647
+ rows = (
648
+ connection.execute(
649
+ _PAGES_ABOUT,
650
+ {
651
+ "deployment_id": deployment_id,
652
+ "key_kind": key_kind,
653
+ "key_value": key_value,
654
+ },
655
+ )
656
+ .mappings()
657
+ .all()
658
+ )
659
+ pages = tuple(
660
+ PageRef(
661
+ artifact_id=row["artifact_id"],
662
+ page_kind=row["page_kind"],
663
+ git_path=row["git_path"],
664
+ page_summary=row["page_summary"],
665
+ last_compiled_at=row["last_compiled_at"],
666
+ status=row["status"],
667
+ stale=row["stale"],
668
+ open_review_flags=row["open_review_flags"],
669
+ redaction_required=row["redaction_required"],
670
+ )
671
+ for row in rows
672
+ )
673
+ return Envelope(
674
+ grain=Grain.COMPILED,
675
+ pages=pages,
676
+ freshness=_freshness(),
677
+ negative=None
678
+ if pages
679
+ else Negative(
680
+ kind=NegativeKind.KNOWN_EMPTY,
681
+ explanation=f"no K pages route on {key_kind}={key_value!r}",
682
+ workaround="query the primitives directly; K synthesis is optional",
683
+ ),
684
+ )
685
+
686
+ def aggregate(
687
+ self,
688
+ *,
689
+ deployment_id: UUID,
690
+ form: str,
691
+ subject_entity_id: UUID | None = None,
692
+ predicate: str | None = None,
693
+ entity_type: str | None = None,
694
+ since: datetime | None = None,
695
+ limit: int = 50,
696
+ ) -> Envelope:
697
+ """An enumerated aggregate — never a general GROUP BY (retrieval §9).
698
+
699
+ Each `form` is a bounded SQL shape with a predictable cost, because
700
+ an unbounded ad-hoc aggregation over 10⁸ rows is a denial of service
701
+ against the spine (the escape hatch is `scan`). The forms: `count`,
702
+ `group_by_predicate`, `group_by_object`, `timeline` (an entity's
703
+ facts by year), `delta_top_entities` (facts gained since T, bounded
704
+ by the delta window — S30), and `typed_absence` (entities of a type
705
+ with no relation of a predicate — S40, answerable because the
706
+ ontology types entities). A `limit`-bounded form that hits its cap
707
+ sets an explicit truncation marker — the bucket total is then a
708
+ floor, never a silent "this is all there is". An unknown form is a
709
+ typed `boundary`.
710
+ """
711
+ if limit < 1:
712
+ raise ValueError("limit must be at least 1")
713
+ builder = _AGGREGATE_FORMS.get(form)
714
+ if builder is None:
715
+ return Envelope(
716
+ grain=Grain.FACT,
717
+ freshness=_freshness(),
718
+ negative=Negative(
719
+ kind=NegativeKind.BOUNDARY,
720
+ explanation=f"no enumerated aggregate {form!r}",
721
+ workaround=f"use one of: {', '.join(sorted(_AGGREGATE_FORMS))}",
722
+ ),
723
+ )
724
+ statement, needs = builder
725
+ parameters = {
726
+ "deployment_id": deployment_id,
727
+ "subject_entity_id": subject_entity_id,
728
+ "predicate": predicate,
729
+ "entity_type": entity_type,
730
+ "since": since,
731
+ "fetch": limit + 1, # one extra row reveals a truncation honestly
732
+ }
733
+ for required, value in (
734
+ ("subject_entity_id", subject_entity_id),
735
+ ("predicate", predicate),
736
+ ("entity_type", entity_type),
737
+ ("since", since),
738
+ ):
739
+ if required in needs and value is None:
740
+ raise ValueError(f"aggregate {form!r} requires {required}")
741
+ with self._engine.connect() as connection:
742
+ rows = connection.execute(statement, parameters).mappings().all()
743
+ bounded = form in _BOUNDED_AGGREGATE_FORMS
744
+ truncated = bounded and len(rows) > limit
745
+ buckets = tuple(
746
+ AggregateBucket(
747
+ key=None if row["key"] is None else str(row["key"]),
748
+ count=row["count"],
749
+ entity_id=row.get("entity_id"),
750
+ )
751
+ for row in (rows[:limit] if bounded else rows)
752
+ )
753
+ total = sum(bucket.count for bucket in buckets)
754
+ return Envelope(
755
+ grain=Grain.FACT,
756
+ as_of_believed_at=since,
757
+ aggregate=AggregateReport(
758
+ form=form,
759
+ buckets=buckets,
760
+ total=total,
761
+ bounded_by="delta window" if form == "delta_top_entities" else None,
762
+ ),
763
+ freshness=_freshness(),
764
+ truncation=Truncation(
765
+ truncated=truncated,
766
+ returned=len(buckets),
767
+ estimated_total=len(buckets),
768
+ total_is_exact=not truncated,
769
+ )
770
+ if bounded
771
+ else None,
772
+ )
773
+
774
+ def scan(
775
+ self, *, deployment_id: UUID, kind: str, batch_size: int = DEFAULT_SCAN_BATCH
776
+ ) -> Iterator[ScanRow]:
777
+ """The batch surface (S53): stream a filtered export, row by row.
778
+
779
+ A generator over the SEPARATE batch pool (`batch_engine`), using a
780
+ server-side cursor so a full export streams in bounded memory and
781
+ never buffers 10⁸ rows or starves the interactive pool. Same
782
+ zero-LLM read, same grain labels; no interactive-latency promise.
783
+ `kind` selects the export: `relation`, `observation`, or `claim`. An
784
+ unknown kind raises rather than streaming a silent empty export.
785
+ """
786
+ if batch_size < 1:
787
+ raise ValueError("batch_size must be at least 1")
788
+ statement = _SCAN_EXPORTS.get(kind)
789
+ if statement is None:
790
+ raise ValueError(
791
+ f"no scan export {kind!r}; use relation, observation, or claim"
792
+ )
793
+ connection = self._batch_engine.connect().execution_options(stream_results=True)
794
+ try:
795
+ result = connection.execute(statement, {"deployment_id": deployment_id})
796
+ for partition in result.mappings().partitions(batch_size):
797
+ for row in partition:
798
+ yield ScanRow(
799
+ kind=kind, id=row["id"], label=row["label"], at=row["at"]
800
+ )
801
+ finally:
802
+ connection.close()
803
+
804
+ def _rerank_boundary(self, *, explanation: str, workaround: str) -> Envelope:
805
+ """A rerank request the engine cannot honor, as a typed boundary."""
806
+ return Envelope(
807
+ grain=Grain.EVIDENCE,
808
+ freshness=_freshness(),
809
+ negative=Negative(
810
+ kind=NegativeKind.BOUNDARY,
811
+ explanation=explanation,
812
+ workaround=workaround,
813
+ ),
814
+ )
815
+
816
+ def _enrich_facts(
817
+ self, *, deployment_id: UUID, facts: tuple[FactResult, ...], kind: str
818
+ ) -> tuple[FactResult, ...]:
819
+ """Attach the S23 contradiction block and the D54 support marker.
820
+
821
+ For every returned fact in a live contradiction group, the OTHER
822
+ live sides come back inline (bounded by the cap, with
823
+ group_id/returned/total/continuation) — one-sided is never a valid
824
+ answer. A fact under an open `support_withdrawn` review flag is
825
+ marked `withdrawn` (flagged, not vanished). Two bounded batch reads,
826
+ never one-per-fact.
827
+ """
828
+ if not facts:
829
+ return facts
830
+ groups = [
831
+ fact.contradiction_group
832
+ for fact in facts
833
+ if fact.contradiction_group is not None
834
+ ]
835
+ members_by_group: dict[UUID, list[dict[str, object]]] = {}
836
+ withdrawn: set[UUID] = set()
837
+ with self._engine.connect() as connection:
838
+ if groups:
839
+ for row in (
840
+ connection.execute(
841
+ _CONTRADICTION_MEMBERS[kind],
842
+ {"deployment_id": deployment_id, "groups": groups},
843
+ )
844
+ .mappings()
845
+ .all()
846
+ ):
847
+ members_by_group.setdefault(row["contradiction_group"], []).append(
848
+ dict(row)
849
+ )
850
+ withdrawn = {
851
+ row["fact_id"]
852
+ for row in connection.execute(
853
+ _OPEN_SUPPORT_FLAGS,
854
+ {
855
+ "deployment_id": deployment_id,
856
+ "fact_ids": [str(fact.fact_id) for fact in facts],
857
+ },
858
+ )
859
+ .mappings()
860
+ .all()
861
+ }
862
+ return tuple(
863
+ self._enrich_one(
864
+ fact=fact, members_by_group=members_by_group, withdrawn=withdrawn
865
+ )
866
+ for fact in facts
867
+ )
868
+
869
+ def _enrich_one(
870
+ self,
871
+ *,
872
+ fact: FactResult,
873
+ members_by_group: dict[UUID, list[dict[str, object]]],
874
+ withdrawn: set[UUID],
875
+ ) -> FactResult:
876
+ """One fact, with its contradiction block and support marker resolved."""
877
+ update: dict[str, object] = {}
878
+ if fact.fact_id in withdrawn:
879
+ update["support"] = FactSupport.WITHDRAWN
880
+ if fact.contradiction_group is not None:
881
+ others = [
882
+ member
883
+ for member in members_by_group.get(fact.contradiction_group, [])
884
+ if member["fact_id"] != fact.fact_id
885
+ ]
886
+ returned = others[:CONTRADICTION_COMEMBER_CAP]
887
+ update["contradiction"] = Contradiction(
888
+ group_id=fact.contradiction_group,
889
+ co_members=tuple(_co_member(member) for member in returned),
890
+ returned=len(returned),
891
+ total=len(others),
892
+ continuation=(
893
+ str(returned[-1]["fact_id"])
894
+ if len(returned) < len(others)
895
+ else None
896
+ ),
897
+ )
898
+ return fact.model_copy(update=update) if update else fact
899
+
900
+ def _confirm_claims(
901
+ self, *, deployment_id: UUID, claim_ids: tuple[UUID, ...]
902
+ ) -> tuple[tuple[EvidenceResult, ...], int]:
903
+ """The D48 confirmation hop for claim nominations, order-preserving."""
904
+ if not claim_ids:
905
+ return (), 0
906
+ rows: list[RowMapping] = []
907
+ # Multiple chunks are one answer, so they must observe one database
908
+ # snapshot rather than mixing currency states across round trips.
909
+ with self._engine.connect().execution_options(
910
+ isolation_level="REPEATABLE READ"
911
+ ) as connection:
912
+ for batch in batched(claim_ids, INTERACTIVE_HYDRATION_BATCH_SIZE):
913
+ rows.extend(
914
+ connection.execute(
915
+ _CONFIRM_CLAIMS,
916
+ {"deployment_id": deployment_id, "claim_ids": list(batch)},
917
+ )
918
+ .mappings()
919
+ .all()
920
+ )
921
+ confirmed = {row["claim_id"]: row for row in rows}
922
+ results = tuple(
923
+ EvidenceResult.model_validate(dict(confirmed[claim_id]))
924
+ for claim_id in claim_ids
925
+ if claim_id in confirmed
926
+ )
927
+ return results, len(claim_ids) - len(results)
928
+
929
+ def _confirm_observations(
930
+ self,
931
+ *,
932
+ deployment_id: UUID,
933
+ entity_id: UUID,
934
+ observation_ids: tuple[UUID, ...],
935
+ as_of: datetime,
936
+ ) -> tuple[tuple[dict[str, object], ...], int]:
937
+ """The D48 confirmation hop for observation nominations."""
938
+ if not observation_ids:
939
+ return (), 0
940
+ rows: list[RowMapping] = []
941
+ with self._engine.connect().execution_options(
942
+ isolation_level="REPEATABLE READ"
943
+ ) as connection:
944
+ for batch in batched(observation_ids, INTERACTIVE_HYDRATION_BATCH_SIZE):
945
+ rows.extend(
946
+ connection.execute(
947
+ _CONFIRM_OBSERVATIONS,
948
+ {
949
+ "deployment_id": deployment_id,
950
+ "entity_id": entity_id,
951
+ "observation_ids": list(batch),
952
+ "as_of": as_of,
953
+ },
954
+ )
955
+ .mappings()
956
+ .all()
957
+ )
958
+ confirmed = {row["fact_id"]: dict(row) for row in rows}
959
+ results = tuple(
960
+ confirmed[observation_id]
961
+ for observation_id in observation_ids
962
+ if observation_id in confirmed
963
+ )
964
+ return results, len(observation_ids) - len(results)
965
+
966
+ def _embed(self, *, query: str) -> tuple[float, ...]:
967
+ """One query-string embedding through the configured port (D63)."""
968
+ response = self._model_provider.embed(
969
+ request=EmbeddingRequest(model=self._embedding_model, texts=(query,))
970
+ )
971
+ return response.vectors[0]
972
+
973
+
974
+ def _freshness() -> Freshness:
975
+ """The skeleton's freshness stamps: PG is live; P1 is written inline.
976
+
977
+ The `believed_at` horizons are null (unbounded): Postgres holds full
978
+ belief history, and under D69 the hot P2 view keeps every relation whose
979
+ endpoints stay emitted. A channel that grows a real finite horizon fills
980
+ these in, and `believed_at_boundary` turns a query before it into a typed
981
+ boundary.
982
+ """
983
+ return Freshness(pg_live_ts=datetime.now(tz=UTC))
984
+
985
+
986
+ def believed_at_boundary(
987
+ *, believed_at: datetime | None, horizon: datetime | None
988
+ ) -> Negative | None:
989
+ """A typed boundary when a `believed_at` query predates a channel horizon.
990
+
991
+ Belief history is not infinite on every channel: if a channel reports a
992
+ finite `believed_at` horizon and the caller asks for an instant before
993
+ it, that is a stated capability limit (retrieval §3) — a `boundary` that
994
+ names the fallback, never a silently truncated answer. Null horizon
995
+ (unbounded) never triggers it.
996
+ """
997
+ if believed_at is None or horizon is None or believed_at >= horizon:
998
+ return None
999
+ return Negative(
1000
+ kind=NegativeKind.BOUNDARY,
1001
+ explanation=(
1002
+ f"believed_at {believed_at.isoformat()} is before this channel's"
1003
+ f" retention horizon {horizon.isoformat()}"
1004
+ ),
1005
+ workaround="query a later instant, or read Postgres belief history",
1006
+ )
1007
+
1008
+
1009
+ def _fact_result(*, row, kind: str) -> FactResult: # noqa: ANN001
1010
+ """Build one fact-grain record from a hydrated spine row."""
1011
+ mapping = dict(row)
1012
+ return FactResult(
1013
+ fact_id=row["fact_id"],
1014
+ kind=kind,
1015
+ label=row["label"],
1016
+ evidence_count=row["evidence_count"],
1017
+ contradiction_group=mapping.get("contradiction_group"),
1018
+ validity=Validity(
1019
+ valid_from=row["valid_from"],
1020
+ valid_until=row["valid_until"],
1021
+ ingested_at=row["ingested_at"],
1022
+ invalidated_at=row["invalidated_at"],
1023
+ ),
1024
+ )
1025
+
1026
+
1027
+ def _co_member(row: dict[str, object]) -> CoMember:
1028
+ """Build one contradiction co-member record from a live spine row."""
1029
+ return CoMember(
1030
+ fact_id=row["fact_id"], # type: ignore[arg-type]
1031
+ label=row["label"], # type: ignore[arg-type]
1032
+ evidence_count=row["evidence_count"], # type: ignore[arg-type]
1033
+ validity=Validity(
1034
+ valid_from=row["valid_from"], # type: ignore[arg-type]
1035
+ valid_until=row["valid_until"], # type: ignore[arg-type]
1036
+ ingested_at=row["ingested_at"], # type: ignore[arg-type]
1037
+ invalidated_at=row["invalidated_at"], # type: ignore[arg-type]
1038
+ ),
1039
+ )
1040
+
1041
+
1042
+ _RESOLVE_T0 = text(
1043
+ """
1044
+ WITH RECURSIVE matched AS (
1045
+ SELECT entities.entity_id, entities.canonical_name, entities.type,
1046
+ entities.status, entities.merged_into
1047
+ FROM aliases
1048
+ JOIN entities ON entities.deployment_id = aliases.deployment_id
1049
+ AND entities.entity_id = aliases.entity_id
1050
+ WHERE aliases.deployment_id = :deployment_id
1051
+ AND aliases.normalized_lemma = :lemma
1052
+ UNION
1053
+ -- follow merge redirects to the survivor (S60: resolve returns
1054
+ -- CURRENT identities; the redirect chain is walked, never dead-ended)
1055
+ SELECT survivor.entity_id, survivor.canonical_name, survivor.type,
1056
+ survivor.status, survivor.merged_into
1057
+ FROM matched
1058
+ JOIN entities survivor ON survivor.deployment_id = :deployment_id
1059
+ AND survivor.entity_id = matched.merged_into
1060
+ WHERE matched.status = 'merged'
1061
+ )
1062
+ SELECT DISTINCT entity_id, canonical_name, type
1063
+ FROM matched
1064
+ WHERE status = 'active'
1065
+ AND (CAST(:entity_type AS text) IS NULL OR type = :entity_type)
1066
+ """
1067
+ )
1068
+
1069
+ _RESOLVE_CONTEXT_HITS = text(
1070
+ """
1071
+ SELECT candidate_id, count(DISTINCT context_entity_id) AS context_hits
1072
+ FROM (
1073
+ SELECT subject_entity_id AS candidate_id,
1074
+ object_entity_id AS context_entity_id
1075
+ FROM relations
1076
+ WHERE deployment_id = :deployment_id
1077
+ AND subject_entity_id = ANY(:candidate_ids)
1078
+ AND object_entity_id = ANY(:context_entity_ids)
1079
+ AND invalidated_at IS NULL
1080
+ AND (valid_from IS NULL OR valid_from <= now())
1081
+ AND (valid_until IS NULL OR valid_until > now())
1082
+ UNION ALL
1083
+ SELECT object_entity_id AS candidate_id,
1084
+ subject_entity_id AS context_entity_id
1085
+ FROM relations
1086
+ WHERE deployment_id = :deployment_id
1087
+ AND object_entity_id = ANY(:candidate_ids)
1088
+ AND subject_entity_id = ANY(:context_entity_ids)
1089
+ AND invalidated_at IS NULL
1090
+ AND (valid_from IS NULL OR valid_from <= now())
1091
+ AND (valid_until IS NULL OR valid_until > now())
1092
+ ) adjacent
1093
+ GROUP BY candidate_id
1094
+ """
1095
+ )
1096
+
1097
+ _LOOKUP_RELATIONS = text(
1098
+ """
1099
+ SELECT relation_id AS fact_id,
1100
+ coalesce(fact_label, predicate) AS label,
1101
+ evidence_count, valid_from, valid_until, ingested_at, invalidated_at,
1102
+ contradiction_group
1103
+ FROM relations
1104
+ WHERE deployment_id = :deployment_id
1105
+ AND invalidated_at IS NULL
1106
+ AND (valid_from IS NULL OR valid_from <= :as_of)
1107
+ AND (valid_until IS NULL OR valid_until > :as_of)
1108
+ AND (CAST(:subject_entity_id AS uuid) IS NULL
1109
+ OR subject_entity_id = :subject_entity_id)
1110
+ AND (CAST(:predicate AS text) IS NULL OR predicate = :predicate)
1111
+ AND (CAST(:object_entity_id AS uuid) IS NULL
1112
+ OR object_entity_id = :object_entity_id)
1113
+ ORDER BY evidence_count DESC, ingested_at
1114
+ """
1115
+ )
1116
+
1117
+ _LOOKUP_OBSERVATIONS = text(
1118
+ """
1119
+ SELECT observation_id AS fact_id, statement AS label,
1120
+ evidence_count, valid_from, valid_until, ingested_at, invalidated_at,
1121
+ contradiction_group
1122
+ FROM observations
1123
+ WHERE deployment_id = :deployment_id
1124
+ AND subject_entity_id = :entity_id
1125
+ AND invalidated_at IS NULL
1126
+ AND (valid_from IS NULL OR valid_from <= :as_of)
1127
+ AND (valid_until IS NULL OR valid_until > :as_of)
1128
+ ORDER BY evidence_count DESC, ingested_at
1129
+ """
1130
+ )
1131
+
1132
+ _CONFIRM_OBSERVATIONS = text(
1133
+ """
1134
+ SELECT observation_id AS fact_id, statement AS label,
1135
+ evidence_count, valid_from, valid_until, ingested_at, invalidated_at,
1136
+ contradiction_group
1137
+ FROM observations
1138
+ WHERE deployment_id = :deployment_id
1139
+ AND subject_entity_id = :entity_id
1140
+ AND observation_id = ANY(:observation_ids)
1141
+ AND invalidated_at IS NULL
1142
+ AND (valid_from IS NULL OR valid_from <= :as_of)
1143
+ AND (valid_until IS NULL OR valid_until > :as_of)
1144
+ """
1145
+ )
1146
+
1147
+ _CONFIRM_CLAIMS = text(
1148
+ """
1149
+ SELECT claim_id, doc_id, chunk_id, claim_text, source_span,
1150
+ char_start, char_end, is_attributed, is_current_testimony
1151
+ FROM claims
1152
+ WHERE deployment_id = :deployment_id
1153
+ AND claim_id = ANY(:claim_ids)
1154
+ AND is_current_testimony
1155
+ """
1156
+ )
1157
+
1158
+ _HYDRATE_RELATION = text(
1159
+ """
1160
+ SELECT relation_id AS fact_id,
1161
+ coalesce(fact_label, predicate) AS label,
1162
+ evidence_count, valid_from, valid_until, ingested_at, invalidated_at,
1163
+ contradiction_group
1164
+ FROM relations
1165
+ WHERE deployment_id = :deployment_id AND relation_id = :relation_id
1166
+ """
1167
+ )
1168
+
1169
+ _HYDRATE_EVIDENCE_CLAIMS = text(
1170
+ """
1171
+ SELECT c.claim_id, c.doc_id, c.chunk_id, c.claim_text, c.source_span,
1172
+ c.char_start, c.char_end, c.is_attributed, c.is_current_testimony
1173
+ FROM relation_evidence e
1174
+ JOIN claims c ON c.claim_id = e.claim_id
1175
+ WHERE e.relation_id = :relation_id AND e.stance = 'supports'
1176
+ ORDER BY c.ingested_at, c.claim_id
1177
+ """
1178
+ )
1179
+
1180
+ _HYDRATE_SOURCES = text(
1181
+ """
1182
+ SELECT DISTINCT d.doc_id, d.title, d.source_kind, r.markdown_uri
1183
+ FROM relation_evidence e
1184
+ JOIN claims c ON c.claim_id = e.claim_id
1185
+ JOIN chunks ch ON ch.chunk_id = c.chunk_id
1186
+ JOIN documents d ON d.doc_id = e.doc_id
1187
+ LEFT JOIN document_representations r
1188
+ ON r.representation_id = ch.representation_id
1189
+ WHERE e.relation_id = :relation_id
1190
+ AND e.stance = 'supports'
1191
+ """
1192
+ )
1193
+
1194
+ _RELATION_TRANSCRIPT = text(
1195
+ """
1196
+ -- related_id is always the OTHER relation in the pair, whichever side of
1197
+ -- the adjudication the subject sits on (never the subject itself)
1198
+ SELECT 'relation' AS subject_kind,
1199
+ outcome::text AS outcome, method::text AS method, confidence,
1200
+ CASE WHEN relation_id = :subject_id THEN related_relation_id
1201
+ ELSE relation_id END AS related_id,
1202
+ decided_by::text AS decided_by, decided_at, features
1203
+ FROM relation_adjudications
1204
+ WHERE deployment_id = :deployment_id
1205
+ AND (relation_id = :subject_id OR related_relation_id = :subject_id)
1206
+ ORDER BY decided_at, adjudication_id
1207
+ """
1208
+ )
1209
+
1210
+ _OBSERVATION_TRANSCRIPT = text(
1211
+ """
1212
+ SELECT 'observation' AS subject_kind,
1213
+ outcome::text AS outcome, method::text AS method, confidence,
1214
+ CASE WHEN observation_id = :subject_id THEN related_observation_id
1215
+ ELSE observation_id END AS related_id,
1216
+ decided_by::text AS decided_by, decided_at, features
1217
+ FROM observation_adjudications
1218
+ WHERE deployment_id = :deployment_id
1219
+ AND (observation_id = :subject_id OR related_observation_id = :subject_id)
1220
+ ORDER BY decided_at, adjudication_id
1221
+ """
1222
+ )
1223
+
1224
+ _ENTITY_TRANSCRIPT = text(
1225
+ """
1226
+ -- an entity's decision history braids two append-only logs: how each of
1227
+ -- its mentions resolved (resolution_decisions) and every merge it took
1228
+ -- part in (merge_events), newest-last across both. related_id is the
1229
+ -- COUNTERPART entity of a merge (never the subject); a reversed merge is
1230
+ -- an unmerge.
1231
+ SELECT 'entity' AS subject_kind,
1232
+ CASE WHEN is_new_entity THEN 'new_entity' ELSE 'linked' END AS outcome,
1233
+ method::text AS method, confidence,
1234
+ mention_id AS related_id, decided_by::text AS decided_by,
1235
+ decided_at, features
1236
+ FROM resolution_decisions
1237
+ WHERE deployment_id = :deployment_id AND entity_id = :subject_id
1238
+ UNION ALL
1239
+ SELECT 'entity' AS subject_kind,
1240
+ CASE WHEN reversed_by IS NOT NULL THEN 'unmerge' ELSE 'merge' END
1241
+ AS outcome,
1242
+ 'merge_event' AS method, NULL::real AS confidence,
1243
+ CASE WHEN survivor_id = :subject_id THEN absorbed_id
1244
+ ELSE survivor_id END AS related_id,
1245
+ decided_by::text AS decided_by, decided_at, evidence AS features
1246
+ FROM merge_events
1247
+ WHERE deployment_id = :deployment_id
1248
+ AND (survivor_id = :subject_id OR absorbed_id = :subject_id)
1249
+ ORDER BY decided_at
1250
+ """
1251
+ )
1252
+
1253
+ _KPAGE_TRANSCRIPT = text(
1254
+ """
1255
+ -- a K page's provenance is its compile history: each recompilation, what
1256
+ -- it cited, and the writer that produced it (S35)
1257
+ SELECT 'k_page' AS subject_kind,
1258
+ 'compiled' AS outcome, writer_version AS method,
1259
+ NULL::real AS confidence, artifact_id AS related_id,
1260
+ 'writer'::text AS decided_by, compiled_at AS decided_at,
1261
+ jsonb_build_object('cited', cited_count, 'uncited', uncited_count,
1262
+ 'evidence_added', evidence_added,
1263
+ 'evidence_removed', evidence_removed) AS features
1264
+ FROM knowledge_compilations
1265
+ WHERE deployment_id = :deployment_id AND artifact_id = :subject_id
1266
+ ORDER BY compiled_at, compilation_id
1267
+ """
1268
+ )
1269
+
1270
+ _TRANSCRIPT_BY_KIND = {
1271
+ "relation": _RELATION_TRANSCRIPT,
1272
+ "observation": _OBSERVATION_TRANSCRIPT,
1273
+ "entity": _ENTITY_TRANSCRIPT,
1274
+ "k_page": _KPAGE_TRANSCRIPT,
1275
+ }
1276
+
1277
+
1278
+ _DELTA_FEED = text(
1279
+ """
1280
+ -- the change feed: one timestamped row per change, unioned across the
1281
+ -- evidence kinds and K pages, filtered by :since and an optional :kinds
1282
+ -- subset. Every branch dates its change on a real column, so a follow-up
1283
+ -- delta resumes deterministically from the oldest `at` returned.
1284
+ WITH feed AS (
1285
+ SELECT 'relation' AS kind, 'new' AS change, relation_id AS id,
1286
+ coalesce(fact_label, predicate) AS label, ingested_at AS at
1287
+ FROM relations
1288
+ WHERE deployment_id = :deployment_id AND ingested_at > :since
1289
+ UNION ALL
1290
+ SELECT 'relation', 'invalidated', relation_id,
1291
+ coalesce(fact_label, predicate), invalidated_at
1292
+ FROM relations
1293
+ WHERE deployment_id = :deployment_id AND invalidated_at > :since
1294
+ UNION ALL
1295
+ -- a supersede caps the OLD relation's window (ra.relation_id), dated
1296
+ -- by the adjudication that closed it
1297
+ SELECT 'relation', 'capped', r.relation_id,
1298
+ coalesce(r.fact_label, r.predicate), ra.decided_at
1299
+ FROM relation_adjudications ra
1300
+ JOIN relations r ON r.deployment_id = ra.deployment_id
1301
+ AND r.relation_id = ra.relation_id
1302
+ WHERE ra.deployment_id = :deployment_id
1303
+ AND ra.outcome = 'supersede' AND ra.decided_at > :since
1304
+ UNION ALL
1305
+ SELECT 'observation', 'new', observation_id, statement, ingested_at
1306
+ FROM observations
1307
+ WHERE deployment_id = :deployment_id AND ingested_at > :since
1308
+ UNION ALL
1309
+ SELECT 'observation', 'invalidated', observation_id, statement,
1310
+ invalidated_at
1311
+ FROM observations
1312
+ WHERE deployment_id = :deployment_id AND invalidated_at > :since
1313
+ UNION ALL
1314
+ -- an observation supersede caps the OLD observation's window, dated
1315
+ -- by the adjudication (symmetric with the relation cap above)
1316
+ SELECT 'observation', 'capped', o.observation_id, o.statement,
1317
+ oa.decided_at
1318
+ FROM observation_adjudications oa
1319
+ JOIN observations o ON o.deployment_id = oa.deployment_id
1320
+ AND o.observation_id = oa.observation_id
1321
+ WHERE oa.deployment_id = :deployment_id
1322
+ AND oa.outcome = 'supersede' AND oa.decided_at > :since
1323
+ UNION ALL
1324
+ SELECT 'claim', 'new', claim_id, left(claim_text, 80), ingested_at
1325
+ FROM claims
1326
+ WHERE deployment_id = :deployment_id AND ingested_at > :since
1327
+ UNION ALL
1328
+ SELECT 'page', 'recompiled', artifact_id, NULL, compiled_at
1329
+ FROM knowledge_compilations
1330
+ WHERE deployment_id = :deployment_id AND compiled_at > :since
1331
+ )
1332
+ SELECT kind, change, id, label, at
1333
+ FROM feed
1334
+ WHERE (CAST(:kinds AS text[]) IS NULL OR kind = ANY(:kinds))
1335
+ -- resume strictly before the cursor over the FULL (at, id) order, so a
1336
+ -- page boundary that splits rows sharing a timestamp never drops the
1337
+ -- tied remainder
1338
+ AND (
1339
+ CAST(:cursor_at AS timestamptz) IS NULL
1340
+ OR at < :cursor_at
1341
+ OR (at = :cursor_at AND id < CAST(:cursor_id AS uuid))
1342
+ )
1343
+ ORDER BY at DESC, id DESC
1344
+ LIMIT :fetch
1345
+ """
1346
+ )
1347
+
1348
+ _PAGES_ABOUT = text(
1349
+ """
1350
+ -- the rule-key inverted index read backwards: which artifacts route on
1351
+ -- (:key_kind, :key_value). One row per artifact (a page may hold several
1352
+ -- matching rules), each carrying its compile state and a stale flag —
1353
+ -- a page whose refresh is still queued has not caught up to its inputs.
1354
+ SELECT * FROM (
1355
+ SELECT DISTINCT ON (a.artifact_id)
1356
+ a.artifact_id, a.page_kind::text AS page_kind, a.git_path,
1357
+ a.page_summary, a.last_compiled_at, a.status::text AS status,
1358
+ (a.page_kind = 'compiled' AND (
1359
+ a.status::text = 'stale' OR EXISTS (
1360
+ SELECT 1 FROM knowledge_refresh_queue q
1361
+ WHERE q.deployment_id = a.deployment_id
1362
+ AND q.artifact_id = a.artifact_id
1363
+ AND q.processed_at IS NULL
1364
+ ))) AS stale,
1365
+ CASE WHEN a.page_kind = 'authored' THEN (
1366
+ SELECT count(*) FROM knowledge_refresh_queue q
1367
+ WHERE q.deployment_id = a.deployment_id
1368
+ AND q.artifact_id = a.artifact_id
1369
+ AND q.trigger = 'authored_review'
1370
+ AND q.processed_at IS NULL
1371
+ ) ELSE 0 END AS open_review_flags,
1372
+ CASE WHEN a.page_kind = 'authored' THEN COALESCE((
1373
+ SELECT bool_or(
1374
+ COALESCE((q.payload ->> 'redaction_required')::boolean, false)
1375
+ )
1376
+ FROM knowledge_refresh_queue q
1377
+ WHERE q.deployment_id = a.deployment_id
1378
+ AND q.artifact_id = a.artifact_id
1379
+ AND q.trigger = 'authored_review'
1380
+ AND q.processed_at IS NULL
1381
+ ), false) ELSE false END AS redaction_required
1382
+ FROM knowledge_rule_keys rk
1383
+ JOIN knowledge_page_rules pr ON pr.deployment_id = rk.deployment_id
1384
+ AND pr.rule_id = rk.rule_id
1385
+ JOIN knowledge_artifacts a ON a.deployment_id = pr.deployment_id
1386
+ AND a.artifact_id = pr.artifact_id
1387
+ WHERE rk.deployment_id = :deployment_id
1388
+ AND rk.key_kind = CAST(:key_kind AS rule_key_kind)
1389
+ AND rk.key_value = :key_value
1390
+ AND pr.status = 'active' -- a deprecated rule no longer routes
1391
+ AND a.status::text <> 'tombstoned'
1392
+ ORDER BY a.artifact_id
1393
+ ) page
1394
+ ORDER BY page.last_compiled_at DESC NULLS LAST, page.artifact_id
1395
+ """
1396
+ )
1397
+
1398
+ _AGG_COUNT = text(
1399
+ """
1400
+ SELECT NULL::text AS key, count(*) AS count, NULL::uuid AS entity_id
1401
+ FROM relations
1402
+ WHERE deployment_id = :deployment_id AND invalidated_at IS NULL
1403
+ AND (CAST(:subject_entity_id AS uuid) IS NULL
1404
+ OR subject_entity_id = :subject_entity_id)
1405
+ AND (CAST(:predicate AS text) IS NULL OR predicate = :predicate)
1406
+ """
1407
+ )
1408
+
1409
+ _AGG_GROUP_BY_PREDICATE = text(
1410
+ """
1411
+ SELECT predicate AS key, count(*) AS count, NULL::uuid AS entity_id
1412
+ FROM relations
1413
+ WHERE deployment_id = :deployment_id AND invalidated_at IS NULL
1414
+ AND subject_entity_id = :subject_entity_id
1415
+ GROUP BY predicate
1416
+ ORDER BY count DESC, predicate
1417
+ LIMIT :fetch
1418
+ """
1419
+ )
1420
+
1421
+ _AGG_GROUP_BY_OBJECT = text(
1422
+ """
1423
+ SELECT e.canonical_name AS key, count(*) AS count,
1424
+ r.object_entity_id AS entity_id
1425
+ FROM relations r
1426
+ JOIN entities e ON e.deployment_id = r.deployment_id
1427
+ AND e.entity_id = r.object_entity_id
1428
+ WHERE r.deployment_id = :deployment_id AND r.invalidated_at IS NULL
1429
+ AND r.subject_entity_id = :subject_entity_id
1430
+ AND (CAST(:predicate AS text) IS NULL OR r.predicate = :predicate)
1431
+ GROUP BY e.canonical_name, r.object_entity_id
1432
+ ORDER BY count DESC, e.canonical_name
1433
+ LIMIT :fetch
1434
+ """
1435
+ )
1436
+
1437
+ _AGG_TIMELINE = text(
1438
+ """
1439
+ -- an entity's facts by year — relations it is either end of AND the
1440
+ -- observations about it, so the timeline is the whole fact evolution,
1441
+ -- not just relations
1442
+ SELECT to_char(date_trunc('year', ts), 'YYYY') AS key,
1443
+ count(*) AS count, NULL::uuid AS entity_id
1444
+ FROM (
1445
+ SELECT coalesce(valid_from, ingested_at) AS ts
1446
+ FROM relations
1447
+ WHERE deployment_id = :deployment_id AND invalidated_at IS NULL
1448
+ AND (subject_entity_id = :subject_entity_id
1449
+ OR object_entity_id = :subject_entity_id)
1450
+ UNION ALL
1451
+ SELECT coalesce(valid_from, ingested_at) AS ts
1452
+ FROM observations
1453
+ WHERE deployment_id = :deployment_id AND invalidated_at IS NULL
1454
+ AND subject_entity_id = :subject_entity_id
1455
+ ) facts
1456
+ GROUP BY 1
1457
+ ORDER BY 1
1458
+ """
1459
+ )
1460
+
1461
+ _AGG_DELTA_TOP_ENTITIES = text(
1462
+ """
1463
+ -- facts gained since T, grouped by the subject entity, bounded by the
1464
+ -- delta window (S30): a leaderboard of what moved, over relations AND
1465
+ -- observations, not a full-history scan
1466
+ SELECT e.canonical_name AS key, sum(gained.cnt) AS count,
1467
+ gained.entity_id AS entity_id
1468
+ FROM (
1469
+ SELECT subject_entity_id AS entity_id, count(*) AS cnt
1470
+ FROM relations
1471
+ WHERE deployment_id = :deployment_id AND ingested_at > :since
1472
+ GROUP BY subject_entity_id
1473
+ UNION ALL
1474
+ SELECT subject_entity_id AS entity_id, count(*) AS cnt
1475
+ FROM observations
1476
+ WHERE deployment_id = :deployment_id AND ingested_at > :since
1477
+ GROUP BY subject_entity_id
1478
+ ) gained
1479
+ JOIN entities e ON e.deployment_id = :deployment_id
1480
+ AND e.entity_id = gained.entity_id
1481
+ GROUP BY e.canonical_name, gained.entity_id
1482
+ ORDER BY count DESC, e.canonical_name
1483
+ LIMIT :fetch
1484
+ """
1485
+ )
1486
+
1487
+ _AGG_TYPED_ABSENCE = text(
1488
+ """
1489
+ -- entities of a type with NO live relation of a predicate (S40): an
1490
+ -- anti-join, answerable because the ontology types entities. Each bucket
1491
+ -- IS one absent entity (count 1), so the total is how many lack it.
1492
+ SELECT e.canonical_name AS key, 1 AS count, e.entity_id AS entity_id
1493
+ FROM entities e
1494
+ WHERE e.deployment_id = :deployment_id AND e.status = 'active'
1495
+ AND e.type = :entity_type
1496
+ AND NOT EXISTS (
1497
+ SELECT 1 FROM relations r
1498
+ WHERE r.deployment_id = e.deployment_id
1499
+ AND r.subject_entity_id = e.entity_id
1500
+ AND r.predicate = :predicate
1501
+ AND r.invalidated_at IS NULL
1502
+ )
1503
+ ORDER BY e.canonical_name
1504
+ LIMIT :fetch
1505
+ """
1506
+ )
1507
+
1508
+ _AGGREGATE_FORMS: dict[str, tuple[TextClause, frozenset[str]]] = {
1509
+ "count": (_AGG_COUNT, frozenset()),
1510
+ "group_by_predicate": (_AGG_GROUP_BY_PREDICATE, frozenset({"subject_entity_id"})),
1511
+ "group_by_object": (_AGG_GROUP_BY_OBJECT, frozenset({"subject_entity_id"})),
1512
+ "timeline": (_AGG_TIMELINE, frozenset({"subject_entity_id"})),
1513
+ "delta_top_entities": (_AGG_DELTA_TOP_ENTITIES, frozenset({"since"})),
1514
+ "typed_absence": (_AGG_TYPED_ABSENCE, frozenset({"entity_type", "predicate"})),
1515
+ }
1516
+
1517
+ _SCAN_EXPORTS = {
1518
+ "relation": text(
1519
+ """
1520
+ SELECT relation_id AS id, coalesce(fact_label, predicate) AS label,
1521
+ ingested_at AS at
1522
+ FROM relations
1523
+ WHERE deployment_id = :deployment_id
1524
+ ORDER BY ingested_at, relation_id
1525
+ """
1526
+ ),
1527
+ "observation": text(
1528
+ """
1529
+ SELECT observation_id AS id, statement AS label, ingested_at AS at
1530
+ FROM observations
1531
+ WHERE deployment_id = :deployment_id
1532
+ ORDER BY ingested_at, observation_id
1533
+ """
1534
+ ),
1535
+ "claim": text(
1536
+ """
1537
+ SELECT claim_id AS id, left(claim_text, 120) AS label,
1538
+ ingested_at AS at
1539
+ FROM claims
1540
+ WHERE deployment_id = :deployment_id
1541
+ ORDER BY ingested_at, claim_id
1542
+ """
1543
+ ),
1544
+ }
1545
+
1546
+
1547
+ _CONTRADICTION_MEMBERS_RELATIONS = text(
1548
+ """
1549
+ SELECT contradiction_group, relation_id AS fact_id,
1550
+ coalesce(fact_label, predicate) AS label, evidence_count,
1551
+ valid_from, valid_until, ingested_at, invalidated_at
1552
+ FROM relations
1553
+ WHERE deployment_id = :deployment_id
1554
+ AND contradiction_group = ANY(:groups)
1555
+ AND invalidated_at IS NULL
1556
+ ORDER BY contradiction_group, ingested_at, relation_id
1557
+ """
1558
+ )
1559
+
1560
+ _CONTRADICTION_MEMBERS_OBSERVATIONS = text(
1561
+ """
1562
+ SELECT contradiction_group, observation_id AS fact_id,
1563
+ statement AS label, evidence_count,
1564
+ valid_from, valid_until, ingested_at, invalidated_at
1565
+ FROM observations
1566
+ WHERE deployment_id = :deployment_id
1567
+ AND contradiction_group = ANY(:groups)
1568
+ AND invalidated_at IS NULL
1569
+ ORDER BY contradiction_group, ingested_at, observation_id
1570
+ """
1571
+ )
1572
+
1573
+ _CONTRADICTION_MEMBERS = {
1574
+ "relation": _CONTRADICTION_MEMBERS_RELATIONS,
1575
+ "observation": _CONTRADICTION_MEMBERS_OBSERVATIONS,
1576
+ }
1577
+
1578
+ _OPEN_SUPPORT_FLAGS = text(
1579
+ """
1580
+ -- a fact under an OPEN support_withdrawn review carries support=withdrawn
1581
+ -- in the envelope (D54: flagged, not vanished). "Open" is pending OR
1582
+ -- deferred — an 'uncertain' verdict defers but leaves the flag standing,
1583
+ -- matching review._SELECT_OPEN_FLAG and the lifecycle reconciler.
1584
+ SELECT (candidate ->> 'fact_id')::uuid AS fact_id
1585
+ FROM review_queue
1586
+ WHERE deployment_id = :deployment_id
1587
+ AND item_kind = 'support_withdrawn'
1588
+ AND status IN ('pending', 'deferred')
1589
+ AND (candidate ->> 'fact_id') = ANY(:fact_ids)
1590
+ """
1591
+ )