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,510 @@
1
+ """The relation supersession cascade (D3/D4): blocking → novelty → ladder.
2
+
3
+ Adjudication operates on RELATIONS, never claims (D3): "Alice left Acme"
4
+ closes the validity window of `(alice, works_for, acme)` — one row update —
5
+ while every claim stays an immutable record of what sources asserted.
6
+ Candidates are found by `(subject, predicate)` blocking over the small
7
+ distinct-fact table, only for change-prone predicates; a novelty gate routes
8
+ clear ADDs past the LLM entirely; the ambiguous residue climbs the
9
+ small→frontier ladder. Every decision lands append-only in
10
+ `relation_adjudications` — the S8 "why do we believe…" audit surface — and
11
+ the adjudicator fails safe to coexist when unsure.
12
+ """
13
+
14
+ from typing import Final
15
+ from uuid import UUID
16
+ from uuid import uuid4
17
+
18
+ from pydantic import Field
19
+ from pydantic_settings import BaseSettings
20
+ from pydantic_settings import SettingsConfigDict
21
+ from sqlalchemy import bindparam
22
+ from sqlalchemy import JSON
23
+ from sqlalchemy import text
24
+ from sqlalchemy.engine import Connection
25
+ from sqlalchemy.engine import Engine
26
+
27
+ from rememberstack.model import ModelRequest
28
+ from rememberstack.model import SupersessionOutcome
29
+ from rememberstack.model import SupersessionVerdict
30
+ from rememberstack.ports.cost_meter import CostMeterPort
31
+ from rememberstack.ports.model_provider import ModelProviderPort
32
+
33
+ ADJUDICATOR_VERSION: Final = "adjudicator-2026.07"
34
+ """The supersession adjudicator generation (D12; replayed on rebuild, D7)."""
35
+
36
+ _ADJUDICATION_PROMPT: Final = """You adjudicate fact supersession for a memory
37
+ system. Two believed facts share a subject and a change-prone predicate:
38
+
39
+ EXISTING: {existing_label}
40
+ evidence: {existing_evidence!r} (asserted {existing_asserted})
41
+ NEW: {new_label}
42
+ evidence: {new_evidence!r} (asserted {new_asserted})
43
+
44
+ Decide:
45
+ - supersede: the world changed — the NEW fact replaces the EXISTING one
46
+ (e.g. a job change); the existing fact's validity window should close.
47
+ - coexist: both hold simultaneously (e.g. dual employment). When unsure,
48
+ prefer coexist — a wrong supersession silently hides a true fact.
49
+ - contradict: the sources describe the SAME period incompatibly; both must
50
+ stand, surfaced together."""
51
+
52
+
53
+ class SupersessionSettings(BaseSettings):
54
+ """The adjudicator ladder bindings (D4/D53; port-default principle)."""
55
+
56
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_ADJUDICATOR_")
57
+
58
+ small_model: str = Field(default="openai/gpt-5.6-luna")
59
+ frontier_model: str = Field(default="openai/gpt-5.6-sol")
60
+ confidence_floor: float = Field(default=0.75, ge=0.0, le=1.0)
61
+
62
+
63
+ class SupersessionAdjudicator:
64
+ """Adjudicate each newly-created relation against its blocked candidates."""
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ engine: Engine,
70
+ model_provider: ModelProviderPort,
71
+ settings: SupersessionSettings,
72
+ ) -> None:
73
+ """Bind the adjudicator to the spine and the ladder models."""
74
+ self._engine = engine
75
+ self._model_provider = model_provider
76
+ self._settings = settings
77
+
78
+ def adjudicate_new_relation(
79
+ self,
80
+ *,
81
+ deployment_id: UUID,
82
+ relation_id: UUID,
83
+ meter: CostMeterPort | None = None,
84
+ call_key: str = "supersession",
85
+ ) -> None:
86
+ """Run the cascade for one new relation (idempotent per generation).
87
+
88
+ A non-change-prone predicate, or an empty blocking set, is a clear
89
+ ADD decided by the novelty gate with no model call. Each blocked
90
+ candidate is adjudicated on the ladder; outcomes are applied and
91
+ recorded atomically.
92
+ """
93
+ with self._engine.begin() as connection:
94
+ subject = (
95
+ connection.execute(
96
+ _LOAD_RELATION,
97
+ {"deployment_id": deployment_id, "relation_id": relation_id},
98
+ )
99
+ .mappings()
100
+ .one_or_none()
101
+ )
102
+ if subject is None or subject["invalidated_at"] is not None:
103
+ return # gone or already retired: nothing to adjudicate
104
+ # serialize the whole (subject, predicate) block (Codex review):
105
+ # concurrent adjudications of one block could otherwise mint
106
+ # disjoint contradiction groups or double-close windows.
107
+ connection.execute(
108
+ _LOCK_BLOCK,
109
+ {
110
+ "key": f"{deployment_id}:adjudicate"
111
+ f":{subject['subject_entity_id']}:{subject['predicate']}"
112
+ },
113
+ )
114
+ # and take the deployment identity lock SHARED: adjudications run
115
+ # concurrently with each other, but an un-merge (which takes it
116
+ # exclusively) waits for every in-flight adjudication — a closure
117
+ # can never land after the ripple scan missed it (Codex review).
118
+ connection.execute(
119
+ _LOCK_IDENTITY_SHARED, {"key": f"{deployment_id}:identity-epoch"}
120
+ )
121
+ if self._already_adjudicated(
122
+ connection=connection, relation_id=relation_id
123
+ ):
124
+ return
125
+ if not subject["is_change_prone"]:
126
+ self._record(
127
+ connection=connection,
128
+ deployment_id=deployment_id,
129
+ relation_id=relation_id,
130
+ related_relation_id=None,
131
+ outcome="add",
132
+ method="novelty_gate",
133
+ confidence=1.0,
134
+ features={"reason": "predicate is not change-prone"},
135
+ )
136
+ return
137
+ candidates = (
138
+ connection.execute(
139
+ _BLOCK_CANDIDATES,
140
+ {
141
+ "deployment_id": deployment_id,
142
+ "relation_id": relation_id,
143
+ "subject_entity_id": subject["subject_entity_id"],
144
+ "predicate": subject["predicate"],
145
+ },
146
+ )
147
+ .mappings()
148
+ .all()
149
+ )
150
+ if not candidates:
151
+ self._record(
152
+ connection=connection,
153
+ deployment_id=deployment_id,
154
+ relation_id=relation_id,
155
+ related_relation_id=None,
156
+ outcome="add",
157
+ method="novelty_gate",
158
+ confidence=1.0,
159
+ features={"reason": "no blocked candidates"},
160
+ )
161
+ return
162
+ for candidate in candidates:
163
+ if self._same_object_after_redirects(
164
+ connection=connection,
165
+ deployment_id=deployment_id,
166
+ left=UUID(str(subject["object_entity_id"])),
167
+ right=UUID(str(candidate["object_entity_id"])),
168
+ ):
169
+ # the EXACT rung for relation-shaped facts: identical
170
+ # objects (redirects followed) mean the same fact — noop,
171
+ # zero LLM. Fuzzy/embedding rungs compare STATEMENTS and
172
+ # bind in the observation adjudicator (WP-2.5, D4).
173
+ self._record(
174
+ connection=connection,
175
+ deployment_id=deployment_id,
176
+ relation_id=relation_id,
177
+ related_relation_id=UUID(str(candidate["relation_id"])),
178
+ outcome="noop",
179
+ method="exact",
180
+ confidence=1.0,
181
+ features={"reason": "same object after redirects"},
182
+ )
183
+ continue
184
+ self._adjudicate_pair(
185
+ connection=connection,
186
+ deployment_id=deployment_id,
187
+ new=dict(subject),
188
+ new_relation_id=relation_id,
189
+ old=dict(candidate),
190
+ meter=meter,
191
+ call_key=f"{call_key}:{candidate['relation_id']}",
192
+ )
193
+
194
+ def _adjudicate_pair(
195
+ self,
196
+ *,
197
+ connection: Connection,
198
+ deployment_id: UUID,
199
+ new: dict[str, object],
200
+ new_relation_id: UUID,
201
+ old: dict[str, object],
202
+ meter: CostMeterPort | None,
203
+ call_key: str,
204
+ ) -> None:
205
+ """Climb the ladder for one blocked pair and apply the outcome."""
206
+ prompt = _ADJUDICATION_PROMPT.format(
207
+ existing_label=old["label"],
208
+ existing_evidence=old["evidence_text"],
209
+ existing_asserted=old["asserted_at"] or "unknown",
210
+ new_label=new["label"],
211
+ new_evidence=new["evidence_text"],
212
+ new_asserted=new["asserted_at"] or "unknown",
213
+ )
214
+ verdict_call = self._model_provider.generate(
215
+ request=ModelRequest(model=self._settings.small_model, prompt=prompt),
216
+ response_type=SupersessionVerdict,
217
+ )
218
+ if meter is not None:
219
+ meter.record(
220
+ call_key=f"{call_key}:small",
221
+ tier="small_model",
222
+ usage=verdict_call.usage,
223
+ )
224
+ verdict = verdict_call.output
225
+ method = "small_model"
226
+ model = self._settings.small_model
227
+ if verdict.confidence < self._settings.confidence_floor:
228
+ verdict_call = self._model_provider.generate(
229
+ request=ModelRequest(
230
+ model=self._settings.frontier_model, prompt=prompt
231
+ ),
232
+ response_type=SupersessionVerdict,
233
+ )
234
+ if meter is not None:
235
+ meter.record(
236
+ call_key=f"{call_key}:frontier",
237
+ tier="frontier_llm",
238
+ usage=verdict_call.usage,
239
+ )
240
+ verdict = verdict_call.output
241
+ method = "frontier_llm"
242
+ model = self._settings.frontier_model
243
+ features: dict[str, object] = {"model": model, "rationale": verdict.rationale}
244
+ old_relation_id = UUID(str(old["relation_id"]))
245
+ if verdict.outcome is SupersessionOutcome.SUPERSEDE:
246
+ # the boundary: the new testimony's assertion time, else now —
247
+ # recorded so the closure is auditable (D3/D41 seeding refines it)
248
+ closed = connection.execute(
249
+ _CLOSE_WINDOW,
250
+ {
251
+ "deployment_id": deployment_id,
252
+ "relation_id": old_relation_id,
253
+ "boundary_asserted": new["asserted_at"],
254
+ },
255
+ ).rowcount
256
+ if closed != 1:
257
+ # the transcript never claims a closure that did not occur
258
+ # (Codex review): the window was already at/inside the
259
+ # boundary — record the verdict as a no-change decision.
260
+ self._record(
261
+ connection=connection,
262
+ deployment_id=deployment_id,
263
+ relation_id=old_relation_id,
264
+ related_relation_id=new_relation_id,
265
+ outcome="noop",
266
+ method=method,
267
+ confidence=verdict.confidence,
268
+ features={**features, "reason": "window already closed"},
269
+ )
270
+ return
271
+ self._record(
272
+ connection=connection,
273
+ deployment_id=deployment_id,
274
+ relation_id=old_relation_id,
275
+ related_relation_id=new_relation_id,
276
+ outcome="supersede",
277
+ method=method,
278
+ confidence=verdict.confidence,
279
+ features={**features, "boundary": str(new["asserted_at"] or "now")},
280
+ )
281
+ elif verdict.outcome is SupersessionOutcome.CONTRADICT:
282
+ group = old["contradiction_group"] or uuid4()
283
+ connection.execute(
284
+ _SET_CONTRADICTION_GROUP,
285
+ {
286
+ "deployment_id": deployment_id,
287
+ "relation_ids": [old_relation_id, new_relation_id],
288
+ "group_id": group,
289
+ },
290
+ )
291
+ self._record(
292
+ connection=connection,
293
+ deployment_id=deployment_id,
294
+ relation_id=new_relation_id,
295
+ related_relation_id=old_relation_id,
296
+ outcome="contradict",
297
+ method=method,
298
+ confidence=verdict.confidence,
299
+ features={**features, "contradiction_group": str(group)},
300
+ )
301
+ else: # coexist — the fail-safe: both stand, nothing changes
302
+ self._record(
303
+ connection=connection,
304
+ deployment_id=deployment_id,
305
+ relation_id=new_relation_id,
306
+ related_relation_id=old_relation_id,
307
+ outcome="noop",
308
+ method=method,
309
+ confidence=verdict.confidence,
310
+ features=features,
311
+ )
312
+
313
+ def _same_object_after_redirects(
314
+ self, *, connection: Connection, deployment_id: UUID, left: UUID, right: UUID
315
+ ) -> bool:
316
+ """Whether two object entities are one after following merge redirects."""
317
+ if left == right:
318
+ return True
319
+ roots = connection.execute(
320
+ _SURVIVOR_ROOTS,
321
+ {"deployment_id": deployment_id, "entity_ids": [left, right]},
322
+ ).all()
323
+ resolved = {row[0]: row[1] for row in roots}
324
+ return resolved.get(left) == resolved.get(right)
325
+
326
+ def _already_adjudicated(
327
+ self, *, connection: Connection, relation_id: UUID
328
+ ) -> bool:
329
+ """Replay check (D7): any decision of this generation is terminal."""
330
+ return (
331
+ connection.execute(
332
+ _COUNT_ADJUDICATIONS,
333
+ {
334
+ "relation_id": relation_id,
335
+ "adjudicator_version": ADJUDICATOR_VERSION,
336
+ },
337
+ ).scalar_one()
338
+ > 0
339
+ )
340
+
341
+ def _record(
342
+ self,
343
+ *,
344
+ connection: Connection,
345
+ deployment_id: UUID,
346
+ relation_id: UUID,
347
+ related_relation_id: UUID | None,
348
+ outcome: str,
349
+ method: str,
350
+ confidence: float,
351
+ features: dict[str, object],
352
+ ) -> None:
353
+ """Append one decision to the transcript (never overwritten)."""
354
+ connection.execute(
355
+ _INSERT_ADJUDICATION,
356
+ {
357
+ "adjudication_id": uuid4(),
358
+ "deployment_id": deployment_id,
359
+ "relation_id": relation_id,
360
+ "related_relation_id": related_relation_id,
361
+ "outcome": outcome,
362
+ "method": method,
363
+ "confidence": confidence,
364
+ "features": features,
365
+ "adjudicator_version": ADJUDICATOR_VERSION,
366
+ },
367
+ )
368
+
369
+
370
+ _LOAD_RELATION = text(
371
+ """
372
+ SELECT r.relation_id, r.subject_entity_id, r.predicate, r.object_entity_id,
373
+ r.invalidated_at, r.contradiction_group,
374
+ coalesce(r.fact_label,
375
+ subject.canonical_name || ' ' || r.predicate || ' '
376
+ || object.canonical_name) AS label,
377
+ p.is_change_prone,
378
+ evidence.claim_text AS evidence_text,
379
+ evidence.asserted_at
380
+ FROM relations r
381
+ JOIN predicates p ON p.deployment_id = r.deployment_id
382
+ AND p.predicate = r.predicate
383
+ JOIN entities subject ON subject.entity_id = r.subject_entity_id
384
+ JOIN entities object ON object.entity_id = r.object_entity_id
385
+ LEFT JOIN LATERAL (
386
+ SELECT c.claim_text, c.asserted_at
387
+ FROM relation_evidence e
388
+ JOIN claims c ON c.claim_id = e.claim_id
389
+ WHERE e.relation_id = r.relation_id AND e.stance = 'supports'
390
+ ORDER BY c.ingested_at DESC
391
+ LIMIT 1
392
+ ) evidence ON true
393
+ WHERE r.deployment_id = :deployment_id AND r.relation_id = :relation_id
394
+ """
395
+ )
396
+
397
+ _BLOCK_CANDIDATES = text(
398
+ """
399
+ SELECT r.relation_id, r.object_entity_id, r.contradiction_group,
400
+ coalesce(r.fact_label,
401
+ subject.canonical_name || ' ' || r.predicate || ' '
402
+ || object.canonical_name) AS label,
403
+ evidence.claim_text AS evidence_text,
404
+ evidence.asserted_at
405
+ FROM relations r
406
+ JOIN entities subject ON subject.entity_id = r.subject_entity_id
407
+ JOIN entities object ON object.entity_id = r.object_entity_id
408
+ LEFT JOIN LATERAL (
409
+ SELECT c.claim_text, c.asserted_at
410
+ FROM relation_evidence e
411
+ JOIN claims c ON c.claim_id = e.claim_id
412
+ WHERE e.relation_id = r.relation_id AND e.stance = 'supports'
413
+ ORDER BY c.ingested_at DESC
414
+ LIMIT 1
415
+ ) evidence ON true
416
+ WHERE r.deployment_id = :deployment_id
417
+ -- IDENTITY-SET blocking (registries §11.3 spike): while entities are
418
+ -- merged they are ONE identity, so the block spans every endpoint that
419
+ -- redirects to the subject's survivor root — an absorbed entity's
420
+ -- employment spell is visible to the survivor's supersession. The
421
+ -- un-merge ripple (a closure across what splits back into two people)
422
+ -- is flagged for review by the clusterer's unmerge.
423
+ AND r.subject_entity_id IN (
424
+ WITH RECURSIVE up AS (
425
+ SELECT entity_id, status, merged_into FROM entities
426
+ WHERE deployment_id = :deployment_id
427
+ AND entity_id = :subject_entity_id
428
+ UNION ALL
429
+ SELECT e.entity_id, e.status, e.merged_into
430
+ FROM up JOIN entities e ON e.entity_id = up.merged_into
431
+ WHERE up.status = 'merged'
432
+ ),
433
+ down AS (
434
+ -- the FULL identity closure: from the active root, every
435
+ -- redirect descendant at any depth (C -> B -> A included)
436
+ SELECT entity_id FROM up WHERE status = 'active'
437
+ UNION ALL
438
+ SELECT m.entity_id FROM entities m
439
+ JOIN down ON m.merged_into = down.entity_id
440
+ WHERE m.status = 'merged'
441
+ )
442
+ SELECT entity_id FROM down
443
+ )
444
+ AND r.predicate = :predicate
445
+ AND r.relation_id <> :relation_id
446
+ AND r.invalidated_at IS NULL
447
+ AND (r.valid_until IS NULL OR r.valid_until > now())
448
+ ORDER BY r.ingested_at
449
+ """
450
+ )
451
+
452
+ _CLOSE_WINDOW = text(
453
+ """
454
+ UPDATE relations
455
+ SET valid_until = coalesce(:boundary_asserted, now()), updated_at = now()
456
+ WHERE deployment_id = :deployment_id AND relation_id = :relation_id
457
+ AND (valid_until IS NULL
458
+ OR valid_until > coalesce(:boundary_asserted, now()))
459
+ """
460
+ )
461
+
462
+ _SET_CONTRADICTION_GROUP = text(
463
+ """
464
+ UPDATE relations SET contradiction_group = :group_id, updated_at = now()
465
+ WHERE deployment_id = :deployment_id AND relation_id = ANY(:relation_ids)
466
+ """
467
+ )
468
+
469
+ _COUNT_ADJUDICATIONS = text(
470
+ """
471
+ SELECT count(*) FROM relation_adjudications
472
+ WHERE (relation_id = :relation_id OR related_relation_id = :relation_id)
473
+ AND adjudicator_version = :adjudicator_version
474
+ AND outcome IN ('add', 'noop', 'supersede', 'contradict')
475
+ """
476
+ )
477
+
478
+ _INSERT_ADJUDICATION = text(
479
+ """
480
+ INSERT INTO relation_adjudications (
481
+ adjudication_id, deployment_id, relation_id, related_relation_id,
482
+ outcome, method, confidence, features, adjudicator_version
483
+ ) VALUES (
484
+ :adjudication_id, :deployment_id, :relation_id, :related_relation_id,
485
+ :outcome, :method, :confidence, :features, :adjudicator_version
486
+ )
487
+ """
488
+ ).bindparams(bindparam("features", type_=JSON))
489
+
490
+ _LOCK_BLOCK = text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))")
491
+
492
+ _SURVIVOR_ROOTS = text(
493
+ """
494
+ WITH RECURSIVE walk AS (
495
+ SELECT entity_id AS start, entity_id, status, merged_into
496
+ FROM entities
497
+ WHERE deployment_id = :deployment_id AND entity_id = ANY(:entity_ids)
498
+ UNION ALL
499
+ SELECT walk.start, e.entity_id, e.status, e.merged_into
500
+ FROM walk
501
+ JOIN entities e ON e.entity_id = walk.merged_into
502
+ WHERE walk.status = 'merged'
503
+ )
504
+ SELECT start, entity_id FROM walk WHERE status = 'active'
505
+ """
506
+ )
507
+
508
+ _LOCK_IDENTITY_SHARED = text(
509
+ "SELECT pg_advisory_xact_lock_shared(hashtextextended(:key, 0))"
510
+ )
@@ -0,0 +1,128 @@
1
+ """Sync-cycle catalog (lifecycle §2/§5, F8): the poll pass as explicit state.
2
+
3
+ One `connector_sync_cycles` row per pass, every ingested version stamped with
4
+ its cycle — the retract-timing barrier reconciliation reads. `completed_at`
5
+ is the poll pass ending; `finalized_at` belongs to the reconciliation
6
+ finalization job.
7
+ """
8
+
9
+ from uuid import UUID
10
+ from uuid import uuid4
11
+
12
+ from sqlalchemy import text
13
+ from sqlalchemy.engine import Engine
14
+
15
+
16
+ class SyncCatalog:
17
+ """Cycle rows, known revisions, and deletion tombstones."""
18
+
19
+ def __init__(self, *, engine: Engine) -> None:
20
+ """Bind the catalog to the spine database."""
21
+ self._engine = engine
22
+
23
+ def open_cycle(self, *, deployment_id: UUID, source_kind: str) -> UUID:
24
+ """Record the start of one poll pass."""
25
+ cycle_id = uuid4()
26
+ with self._engine.begin() as connection:
27
+ connection.execute(
28
+ _OPEN_CYCLE,
29
+ {
30
+ "cycle_id": cycle_id,
31
+ "deployment_id": deployment_id,
32
+ "source_kind": source_kind,
33
+ },
34
+ )
35
+ return cycle_id
36
+
37
+ def complete_cycle(self, *, cycle_id: UUID, observed: int, failed: int) -> None:
38
+ """The poll pass ended (finalization is reconciliation's job).
39
+
40
+ ``failed`` counts items lost to per-item errors: a lossy cycle says
41
+ so on its own row, because reconciliation must never treat its
42
+ observation set as complete.
43
+ """
44
+ with self._engine.begin() as connection:
45
+ connection.execute(
46
+ _COMPLETE_CYCLE,
47
+ {"cycle_id": cycle_id, "observed": observed, "failed": failed},
48
+ )
49
+
50
+ def known_revisions(
51
+ self, *, deployment_id: UUID, source_kind: str
52
+ ) -> dict[str, str]:
53
+ """Each live lineage's last ingested revision (the no-fetch key).
54
+
55
+ The cursor is the LATEST version's source revision, not the current
56
+ pointer's: a just-ingested version whose chain is still running
57
+ already covers its revision — reading the currency pointer instead
58
+ would refetch the in-flight content on every poll.
59
+ """
60
+ with self._engine.connect() as connection:
61
+ rows = connection.execute(
62
+ _KNOWN_REVISIONS,
63
+ {"deployment_id": deployment_id, "source_kind": source_kind},
64
+ ).all()
65
+ return {source_ref: revision or "" for source_ref, revision in rows}
66
+
67
+ def observe_deletion(
68
+ self, *, deployment_id: UUID, source_kind: str, source_ref: str, cycle_id: UUID
69
+ ) -> UUID | None:
70
+ """Tombstone a source-deleted lineage (loud, recorded, idempotent).
71
+
72
+ The tombstone is stamped with the observing cycle so reconciliation
73
+ can place the deletion inside its cycle barrier. The downstream
74
+ cascade (claims currency, fact closure per mode, artifact removal)
75
+ is the delete worker's job.
76
+ """
77
+ with self._engine.begin() as connection:
78
+ return connection.execute(
79
+ _TOMBSTONE_LINEAGE,
80
+ {
81
+ "deployment_id": deployment_id,
82
+ "source_kind": source_kind,
83
+ "source_ref": source_ref,
84
+ "cycle_id": cycle_id,
85
+ },
86
+ ).scalar_one_or_none()
87
+
88
+
89
+ _OPEN_CYCLE = text(
90
+ """
91
+ INSERT INTO connector_sync_cycles (cycle_id, deployment_id, source_kind)
92
+ VALUES (:cycle_id, :deployment_id, :source_kind)
93
+ """
94
+ )
95
+
96
+ _COMPLETE_CYCLE = text(
97
+ """
98
+ UPDATE connector_sync_cycles
99
+ SET completed_at = now(), observed_lineages = :observed,
100
+ failed_items = :failed
101
+ WHERE cycle_id = :cycle_id
102
+ """
103
+ )
104
+
105
+ _KNOWN_REVISIONS = text(
106
+ """
107
+ SELECT DISTINCT ON (d.doc_id) d.source_ref, v.source_version_ref
108
+ FROM documents d
109
+ LEFT JOIN document_versions v ON v.doc_id = d.doc_id
110
+ WHERE d.deployment_id = :deployment_id
111
+ AND d.source_kind = :source_kind
112
+ AND d.deleted_at IS NULL
113
+ AND d.source_ref IS NOT NULL
114
+ ORDER BY d.doc_id, v.version_no DESC NULLS LAST
115
+ """
116
+ )
117
+
118
+ _TOMBSTONE_LINEAGE = text(
119
+ """
120
+ UPDATE documents
121
+ SET deleted_at = now(), deleted_sync_cycle_id = :cycle_id
122
+ WHERE deployment_id = :deployment_id
123
+ AND source_kind = :source_kind
124
+ AND source_ref = :source_ref
125
+ AND deleted_at IS NULL
126
+ RETURNING doc_id
127
+ """
128
+ )