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,740 @@
1
+ """Clustering & reversibility (D21, registries §6): gather, decide, undo.
2
+
3
+ Pairwise cascade guesses never chain (no transitive closure): the gather
4
+ stage collects a candidate blob through blocking links, and the decide stage
5
+ splits it with hierarchical agglomerative clustering (centroid linkage on
6
+ profile-embedding cosine distance) cut at a threshold — each piece below the
7
+ cut is one entity, a blob is never automatically one entity. New mentions
8
+ re-decide their 1-hop NEIGHBORHOOD jointly, so the grouping is independent of
9
+ arrival order. Every merge is a redirect with a pre-merge snapshot; un-merge
10
+ replays it. Blast radius routes big merges to review instead of auto (D24);
11
+ the black-hole guard tightens the bar on runaway blobs.
12
+ """
13
+
14
+ from uuid import UUID
15
+ from uuid import uuid4
16
+
17
+ from sqlalchemy import bindparam
18
+ from sqlalchemy import JSON
19
+ from sqlalchemy import text
20
+ from sqlalchemy.engine import Connection
21
+ from sqlalchemy.engine import Engine
22
+
23
+ from rememberstack.model import ClusterConfig
24
+ from rememberstack.model import MergeProposal
25
+ from rememberstack.model import NeighborhoodReport
26
+ from rememberstack.model import UnmergeError
27
+ from rememberstack.ports.p1_index import EntityIndexPort
28
+ from rememberstack.spine.entity_registry import normalized_lemma
29
+
30
+
31
+ class EntityClusterer:
32
+ """Neighborhood re-decision, reversible merges, and the guards (D21)."""
33
+
34
+ def __init__(
35
+ self, *, engine: Engine, entity_index: EntityIndexPort, config: ClusterConfig
36
+ ) -> None:
37
+ """Bind the clusterer to the registry, the profile index, and config."""
38
+ self._engine = engine
39
+ self._entity_index = entity_index
40
+ self._config = config
41
+
42
+ def recluster_neighborhood(
43
+ self, *, deployment_id: UUID, surface: str
44
+ ) -> NeighborhoodReport:
45
+ """Jointly re-decide the surface's 1-hop neighborhood (nDR).
46
+
47
+ Gather: active entities whose aliases block-reach the surface's lemma
48
+ (trigram + phonetic — the same reach as resolution blocking). Decide:
49
+ HAC over profile vectors with the distance cut; each multi-entity
50
+ piece becomes a reversible merge (or a review item above the
51
+ blast-radius cap). Joint re-decision makes the outcome independent of
52
+ the order documents arrived in (registries §6).
53
+ """
54
+ lemma = normalized_lemma(surface=surface)
55
+ with self._engine.begin() as connection:
56
+ connection.execute(_LOCK_NEIGHBORHOOD, {"key": f"{deployment_id}:cluster"})
57
+ members = self._gather(
58
+ connection=connection, deployment_id=deployment_id, lemma=lemma
59
+ )
60
+ if len(members) < 2:
61
+ return NeighborhoodReport(members=len(members))
62
+ # re-deciding the pocket JOINTLY may move a previously-merged
63
+ # member to a different group (the R. Klein case): first split
64
+ # every merged member whose piece disagrees with its current
65
+ # root, then apply the piece merges (registries §6).
66
+ cut = self._config.distance_cut
67
+ tightened = False
68
+ if len(members) > self._config.blob_cap:
69
+ # black-hole guard: raise the matching bar and re-split
70
+ # rather than swallow the monster (registries §6)
71
+ cut = cut / 2.0
72
+ tightened = True
73
+ vectors = self._entity_index.entity_vectors(
74
+ deployment_id=str(deployment_id),
75
+ entity_ids=tuple(str(m["entity_id"]) for m in members),
76
+ )
77
+ pieces = _hac_pieces(members=members, vectors=vectors, distance_cut=cut)
78
+ for piece in pieces:
79
+ self._split_disagreeing_members(
80
+ connection=connection, deployment_id=deployment_id, piece=piece
81
+ )
82
+ merged: list[UUID] = []
83
+ queued = 0
84
+ for proposal in self._proposals(
85
+ connection=connection, deployment_id=deployment_id, pieces=pieces
86
+ ):
87
+ if proposal.blast_radius > self._config.blast_radius_cap:
88
+ self._queue_for_review(
89
+ connection=connection,
90
+ deployment_id=deployment_id,
91
+ proposal=proposal,
92
+ trigger_lemma=lemma,
93
+ )
94
+ queued += 1
95
+ continue
96
+ merged.extend(
97
+ self._merge(
98
+ connection=connection,
99
+ deployment_id=deployment_id,
100
+ proposal=proposal,
101
+ trigger_lemma=lemma,
102
+ )
103
+ )
104
+ return NeighborhoodReport(
105
+ members=len(members),
106
+ merged=tuple(merged),
107
+ queued_for_review=queued,
108
+ black_hole_tightened=tightened,
109
+ )
110
+
111
+ def unmerge(self, *, deployment_id: UUID, merge_id: UUID) -> UUID:
112
+ """Reverse one merge by replaying its snapshot (D21).
113
+
114
+ The absorbed entity becomes active again (redirect removed); a
115
+ reversal event is appended and linked from the original — nothing is
116
+ overwritten, the full history survives. Returns the reversal id.
117
+ """
118
+ with self._engine.begin() as connection:
119
+ connection.execute( # exclusive: wait out in-flight adjudications
120
+ _LOCK_IDENTITY_EXCLUSIVE, {"key": f"{deployment_id}:identity-epoch"}
121
+ )
122
+ event = (
123
+ connection.execute(
124
+ _SELECT_MERGE_LOCKED,
125
+ {"deployment_id": deployment_id, "merge_id": merge_id},
126
+ )
127
+ .mappings()
128
+ .one_or_none()
129
+ )
130
+ if event is None:
131
+ raise UnmergeError(f"merge event {merge_id} does not exist")
132
+ if event["reversed_by"] is not None:
133
+ raise UnmergeError(f"merge event {merge_id} is already reversed")
134
+ full_event = {**event, "merge_id": merge_id}
135
+ reversal_id = self._reverse_event(
136
+ connection=connection, deployment_id=deployment_id, event=full_event
137
+ )
138
+ return reversal_id
139
+
140
+ def _reverse_event(
141
+ self, *, connection: Connection, deployment_id: UUID, event: dict[str, object]
142
+ ) -> UUID:
143
+ """Reverse one live merge: restore, replay the snapshot, link.
144
+
145
+ Snapshot replay (Codex review): any mention that belonged to the
146
+ absorbed entity pre-merge but whose live decision now points
147
+ elsewhere gets a superseding decision restoring it — the membership
148
+ picture returns to the snapshot, not just the redirect.
149
+ """
150
+ absorbed_id = event["absorbed_id"]
151
+ connection.execute(
152
+ _RESTORE_ABSORBED,
153
+ {"deployment_id": deployment_id, "entity_id": absorbed_id},
154
+ )
155
+ snapshot = event["pre_merge_membership_snapshot"]
156
+ mentions = (
157
+ snapshot.get("mentions_by_entity", {}).get(str(absorbed_id), [])
158
+ if isinstance(snapshot, dict)
159
+ else []
160
+ )
161
+ for mention_id in mentions:
162
+ self._restore_mention_decision(
163
+ connection=connection,
164
+ deployment_id=deployment_id,
165
+ mention_id=UUID(str(mention_id)),
166
+ entity_id=UUID(str(absorbed_id)),
167
+ )
168
+ reversal_id = uuid4()
169
+ connection.execute(
170
+ _INSERT_MERGE_EVENT,
171
+ {
172
+ "merge_id": reversal_id,
173
+ "deployment_id": deployment_id,
174
+ "survivor_id": absorbed_id,
175
+ "absorbed_id": event["survivor_id"],
176
+ "trigger_lemmas": [],
177
+ "evidence": {"unmerge_of": str(event["merge_id"])},
178
+ "blast_radius": event["blast_radius"],
179
+ "snapshot": snapshot,
180
+ "decided_by": "human",
181
+ },
182
+ )
183
+ marked = connection.execute(
184
+ _MARK_REVERSED, {"merge_id": event["merge_id"], "reversal_id": reversal_id}
185
+ ).rowcount
186
+ if marked != 1:
187
+ raise UnmergeError(
188
+ f"merge event {event['merge_id']} was reversed concurrently"
189
+ )
190
+ self._flag_ripple(
191
+ connection=connection,
192
+ deployment_id=deployment_id,
193
+ survivor_id=UUID(str(event["survivor_id"])),
194
+ absorbed_id=UUID(str(absorbed_id)),
195
+ reversal_id=reversal_id,
196
+ )
197
+ return reversal_id
198
+
199
+ def _flag_ripple(
200
+ self,
201
+ *,
202
+ connection: Connection,
203
+ deployment_id: UUID,
204
+ survivor_id: UUID,
205
+ absorbed_id: UUID,
206
+ reversal_id: UUID,
207
+ ) -> None:
208
+ """The un-merge → supersession ripple (registries §11.3): a validity
209
+ window closed ACROSS the split identities was adjudicated as one
210
+ person's history and may now be wrong — never silently reopened,
211
+ always flagged for review with the pair attached."""
212
+ rows = connection.execute(
213
+ _CROSS_IDENTITY_CLOSURES,
214
+ {
215
+ "deployment_id": deployment_id,
216
+ "left_id": survivor_id,
217
+ "right_id": absorbed_id,
218
+ },
219
+ ).all() # both sides are FULL post-unmerge identity closures
220
+ for relation_id, related_id in rows:
221
+ connection.execute(
222
+ _INSERT_RIPPLE_REVIEW,
223
+ {
224
+ "review_id": uuid4(),
225
+ "deployment_id": deployment_id,
226
+ "candidate": {
227
+ "reason": "unmerge_supersession_ripple",
228
+ "closed_relation_id": str(relation_id),
229
+ "superseding_relation_id": str(related_id),
230
+ "unmerge_event_id": str(reversal_id),
231
+ },
232
+ "blast_radius": 2,
233
+ "confidence": 0.5,
234
+ "expected_impact": 1.0,
235
+ },
236
+ )
237
+
238
+ def _restore_mention_decision(
239
+ self,
240
+ *,
241
+ connection: Connection,
242
+ deployment_id: UUID,
243
+ mention_id: UUID,
244
+ entity_id: UUID,
245
+ ) -> None:
246
+ """Re-point one mention to its pre-merge entity, superseding (D17)."""
247
+ live = (
248
+ connection.execute(
249
+ _LIVE_DECISION_FOR_MENTION,
250
+ {"deployment_id": deployment_id, "mention_id": mention_id},
251
+ )
252
+ .mappings()
253
+ .one_or_none()
254
+ )
255
+ if live is None or live["entity_id"] == entity_id:
256
+ return
257
+ restored_id = uuid4()
258
+ connection.execute(
259
+ _INSERT_RESTORE_DECISION,
260
+ {
261
+ "decision_id": restored_id,
262
+ "deployment_id": deployment_id,
263
+ "mention_id": mention_id,
264
+ "entity_id": entity_id,
265
+ "resolver_version": str(live["resolver_version"]),
266
+ },
267
+ )
268
+ connection.execute(
269
+ _SUPERSEDE_DECISION,
270
+ {"decision_id": live["decision_id"], "superseded_by": restored_id},
271
+ )
272
+
273
+ def _gather(
274
+ self, *, connection: Connection, deployment_id: UUID, lemma: str
275
+ ) -> list[dict[str, object]]:
276
+ """The 1-hop neighborhood, REDIRECTS INCLUDED (Codex review).
277
+
278
+ Absorbed entities stay reachable through their aliases and appear as
279
+ members with their own vectors plus their current survivor root — so
280
+ a later arrival can trigger the joint re-decision that moves them.
281
+ Hub-triggered 2-hop extension is a documented follow-up.
282
+ """
283
+ return [
284
+ dict(row)
285
+ for row in connection.execute(
286
+ _GATHER_NEIGHBORHOOD, {"deployment_id": deployment_id, "lemma": lemma}
287
+ ).mappings()
288
+ ]
289
+
290
+ def _split_disagreeing_members(
291
+ self,
292
+ *,
293
+ connection: Connection,
294
+ deployment_id: UUID,
295
+ piece: tuple[dict[str, object], ...],
296
+ ) -> None:
297
+ """Unmerge every merged member whose piece disagrees with its root.
298
+
299
+ The joint decision is authoritative for the pocket: a member absorbed
300
+ into an entity OUTSIDE its piece (or alone in a singleton piece) is
301
+ split back out by reversing its live merge event — then the piece
302
+ merges (if any) re-attach it where the joint decision says.
303
+ """
304
+ piece_ids = {str(member["entity_id"]) for member in piece}
305
+ for member in piece:
306
+ root = member.get("current_root")
307
+ if root is None or str(root) == str(member["entity_id"]):
308
+ continue # active, or its own root
309
+ if str(root) in piece_ids and len(piece) > 1:
310
+ continue # its survivor is in the same piece: agreement
311
+ event = (
312
+ connection.execute(
313
+ _LIVE_MERGE_OF,
314
+ {
315
+ "deployment_id": deployment_id,
316
+ "absorbed_id": member["entity_id"],
317
+ },
318
+ )
319
+ .mappings()
320
+ .one_or_none()
321
+ )
322
+ if event is not None:
323
+ self._reverse_event(
324
+ connection=connection,
325
+ deployment_id=deployment_id,
326
+ event=dict(event),
327
+ )
328
+
329
+ def _proposals(
330
+ self,
331
+ *,
332
+ connection: Connection,
333
+ deployment_id: UUID,
334
+ pieces: tuple[tuple[dict[str, object], ...], ...],
335
+ ) -> tuple[MergeProposal, ...]:
336
+ """Turn multi-entity pieces into proposals with live blast radii."""
337
+ proposals: list[MergeProposal] = []
338
+ for piece in pieces:
339
+ if len(piece) < 2:
340
+ continue
341
+ ordered = sorted(
342
+ piece, key=lambda m: (m["first_seen"], str(m["entity_id"]))
343
+ )
344
+ ids = [UUID(str(member["entity_id"])) for member in ordered]
345
+ blast = connection.execute(
346
+ _BLAST_RADIUS, {"deployment_id": deployment_id, "entity_ids": ids}
347
+ ).scalar_one()
348
+ proposals.append(
349
+ MergeProposal(
350
+ survivor_id=ids[0],
351
+ absorbed_ids=tuple(ids[1:]),
352
+ blast_radius=blast,
353
+ mean_distance=0.0,
354
+ )
355
+ )
356
+ return tuple(proposals)
357
+
358
+ def _merge(
359
+ self,
360
+ *,
361
+ connection: Connection,
362
+ deployment_id: UUID,
363
+ proposal: MergeProposal,
364
+ trigger_lemma: str,
365
+ ) -> list[UUID]:
366
+ """Redirect each absorbed entity into the survivor, snapshot first."""
367
+ events: list[UUID] = []
368
+ for absorbed_id in proposal.absorbed_ids:
369
+ merge_id = apply_merge(
370
+ connection=connection,
371
+ deployment_id=deployment_id,
372
+ survivor_id=proposal.survivor_id,
373
+ absorbed_id=absorbed_id,
374
+ trigger_lemmas=[trigger_lemma],
375
+ evidence={"mean_distance": proposal.mean_distance},
376
+ blast_radius=proposal.blast_radius,
377
+ decided_by="auto",
378
+ )
379
+ if merge_id is not None:
380
+ events.append(merge_id)
381
+ return events
382
+
383
+ def _queue_for_review(
384
+ self,
385
+ *,
386
+ connection: Connection,
387
+ deployment_id: UUID,
388
+ proposal: MergeProposal,
389
+ trigger_lemma: str,
390
+ ) -> None:
391
+ """Hub merges never auto (registries §6/D24): rank by expected impact."""
392
+ confidence = 0.5 # cluster-level confidence; refined with WP-2.6 cards
393
+ connection.execute(
394
+ _INSERT_REVIEW,
395
+ {
396
+ "review_id": uuid4(),
397
+ "deployment_id": deployment_id,
398
+ "candidate": {
399
+ "survivor_id": str(proposal.survivor_id),
400
+ "absorbed_ids": [str(a) for a in proposal.absorbed_ids],
401
+ "trigger_lemma": trigger_lemma,
402
+ },
403
+ "blast_radius": proposal.blast_radius,
404
+ "confidence": confidence,
405
+ "expected_impact": proposal.blast_radius * (1.0 - confidence),
406
+ },
407
+ )
408
+
409
+
410
+ def apply_merge(
411
+ *,
412
+ connection: Connection,
413
+ deployment_id: UUID,
414
+ survivor_id: UUID,
415
+ absorbed_id: UUID,
416
+ trigger_lemmas: list[str],
417
+ evidence: dict[str, object],
418
+ blast_radius: int,
419
+ decided_by: str,
420
+ ) -> UUID | None:
421
+ """Perform one reversible merge on an open transaction (D21).
422
+
423
+ Snapshot first, redirect (rowcount-guarded: an already-merged entity
424
+ mints no duplicate event), then the append-only event. Shared by the
425
+ auto clusterer and human review verdicts — one mechanism, one audit
426
+ shape. Returns the merge id, or None if nothing was redirected.
427
+ """
428
+ snapshot = _membership_snapshot(
429
+ connection=connection,
430
+ deployment_id=deployment_id,
431
+ entity_ids=(survivor_id, absorbed_id),
432
+ )
433
+ redirected = connection.execute(
434
+ _REDIRECT_ABSORBED,
435
+ {
436
+ "deployment_id": deployment_id,
437
+ "entity_id": absorbed_id,
438
+ "survivor_id": survivor_id,
439
+ },
440
+ ).rowcount
441
+ if redirected != 1:
442
+ return None
443
+ merge_id = uuid4()
444
+ connection.execute(
445
+ _INSERT_MERGE_EVENT,
446
+ {
447
+ "merge_id": merge_id,
448
+ "deployment_id": deployment_id,
449
+ "survivor_id": survivor_id,
450
+ "absorbed_id": absorbed_id,
451
+ "trigger_lemmas": trigger_lemmas,
452
+ "evidence": evidence,
453
+ "blast_radius": blast_radius,
454
+ "snapshot": snapshot,
455
+ "decided_by": decided_by,
456
+ },
457
+ )
458
+ return merge_id
459
+
460
+
461
+ def _hac_pieces(
462
+ *,
463
+ members: list[dict[str, object]],
464
+ vectors: dict[str, tuple[float, ...]],
465
+ distance_cut: float,
466
+ ) -> tuple[tuple[dict[str, object], ...], ...]:
467
+ """Agglomerative clustering, centroid linkage, cut at `distance_cut`.
468
+
469
+ Members without a profile vector stay singletons — a missing profile is
470
+ never merge evidence (the paranoid direction). Deterministic: ties break
471
+ on entity id, so the same member set always yields the same pieces.
472
+ """
473
+ clusters: list[tuple[list[dict[str, object]], tuple[float, ...] | None]] = []
474
+ for member in sorted(members, key=lambda m: str(m["entity_id"])):
475
+ vector = vectors.get(str(member["entity_id"]))
476
+ clusters.append(([member], vector))
477
+ while True:
478
+ best: tuple[int, int, float] | None = None
479
+ for i in range(len(clusters)):
480
+ for j in range(i + 1, len(clusters)):
481
+ left, right = clusters[i][1], clusters[j][1]
482
+ if left is None or right is None:
483
+ continue
484
+ distance = 1.0 - _cosine(left, right)
485
+ if distance <= distance_cut and (best is None or distance < best[2]):
486
+ best = (i, j, distance)
487
+ if best is None:
488
+ break
489
+ i, j, _ = best
490
+ merged_members = clusters[i][0] + clusters[j][0]
491
+ merged_centroid = _centroid(
492
+ [c for c in (clusters[i][1], clusters[j][1]) if c is not None]
493
+ )
494
+ clusters = [cluster for k, cluster in enumerate(clusters) if k not in (i, j)]
495
+ clusters.append((merged_members, merged_centroid))
496
+ return tuple(tuple(cluster[0]) for cluster in clusters)
497
+
498
+
499
+ def _membership_snapshot(
500
+ *, connection: Connection, deployment_id: UUID, entity_ids: tuple[UUID, ...]
501
+ ) -> dict[str, object]:
502
+ """The before picture: which mentions belong to which entity (D21)."""
503
+ rows = connection.execute(
504
+ _SNAPSHOT_MEMBERSHIP,
505
+ {"deployment_id": deployment_id, "entity_ids": list(entity_ids)},
506
+ ).all()
507
+ snapshot: dict[str, list[str]] = {str(e): [] for e in entity_ids}
508
+ for entity_id, mention_id in rows:
509
+ snapshot[str(entity_id)].append(str(mention_id))
510
+ return {"mentions_by_entity": snapshot}
511
+
512
+
513
+ def _cosine(a: tuple[float, ...], b: tuple[float, ...]) -> float:
514
+ """Cosine similarity of two same-dimension vectors."""
515
+ if len(a) != len(b):
516
+ return 0.0
517
+ dot = sum(x * y for x, y in zip(a, b, strict=True))
518
+ norm_a = sum(x * x for x in a) ** 0.5
519
+ norm_b = sum(y * y for y in b) ** 0.5
520
+ if norm_a == 0.0 or norm_b == 0.0:
521
+ return 0.0
522
+ return dot / (norm_a * norm_b)
523
+
524
+
525
+ def _centroid(vectors: list[tuple[float, ...]]) -> tuple[float, ...]:
526
+ """The mean vector (centroid linkage)."""
527
+ return tuple(sum(axis) / len(vectors) for axis in zip(*vectors, strict=True))
528
+
529
+
530
+ _LOCK_NEIGHBORHOOD = text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))")
531
+
532
+ _GATHER_NEIGHBORHOOD = text(
533
+ """
534
+ WITH RECURSIVE reached AS (
535
+ SELECT DISTINCT entities.entity_id, entities.canonical_name,
536
+ entities.created_at, entities.status, entities.merged_into
537
+ FROM aliases
538
+ JOIN entities ON entities.deployment_id = aliases.deployment_id
539
+ AND entities.entity_id = aliases.entity_id
540
+ WHERE aliases.deployment_id = :deployment_id
541
+ AND entities.status IN ('active', 'merged')
542
+ AND (similarity(aliases.normalized_lemma, :lemma) >= 0.3
543
+ OR daitch_mokotoff(aliases.normalized_lemma)
544
+ && daitch_mokotoff(:lemma))
545
+ ),
546
+ rooted AS (
547
+ SELECT entity_id, canonical_name, created_at, status, merged_into,
548
+ entity_id AS current_root
549
+ FROM reached WHERE status = 'active'
550
+ UNION ALL
551
+ SELECT r.entity_id, r.canonical_name, r.created_at, r.status,
552
+ r.merged_into, e.entity_id
553
+ FROM (
554
+ SELECT reached.*, reached.merged_into AS walk FROM reached
555
+ WHERE status = 'merged'
556
+ ) r
557
+ JOIN entities e ON e.entity_id = r.walk AND e.status = 'active'
558
+ )
559
+ SELECT DISTINCT entity_id, canonical_name,
560
+ created_at AS first_seen, current_root
561
+ FROM rooted
562
+ """
563
+ )
564
+
565
+ _BLAST_RADIUS = text(
566
+ """
567
+ SELECT coalesce(sum(mention_count + graph_degree), 0)::int
568
+ FROM entities
569
+ WHERE deployment_id = :deployment_id
570
+ AND entity_id = ANY(:entity_ids)
571
+ """
572
+ )
573
+
574
+ _SNAPSHOT_MEMBERSHIP = text(
575
+ """
576
+ SELECT entity_id, mention_id FROM resolution_decisions
577
+ WHERE deployment_id = :deployment_id
578
+ AND entity_id = ANY(:entity_ids)
579
+ AND superseded_by IS NULL
580
+ ORDER BY decided_at
581
+ """
582
+ )
583
+
584
+ _INSERT_MERGE_EVENT = text(
585
+ """
586
+ INSERT INTO merge_events (
587
+ merge_id, deployment_id, survivor_id, absorbed_id, trigger_lemmas,
588
+ evidence, blast_radius, pre_merge_membership_snapshot, decided_by
589
+ ) VALUES (
590
+ :merge_id, :deployment_id, :survivor_id, :absorbed_id, :trigger_lemmas,
591
+ :evidence, :blast_radius, :snapshot, :decided_by
592
+ )
593
+ """
594
+ ).bindparams(bindparam("evidence", type_=JSON), bindparam("snapshot", type_=JSON))
595
+
596
+ _REDIRECT_ABSORBED = text(
597
+ """
598
+ UPDATE entities
599
+ SET status = 'merged', merged_into = :survivor_id, updated_at = now()
600
+ WHERE deployment_id = :deployment_id AND entity_id = :entity_id
601
+ AND status = 'active'
602
+ """
603
+ )
604
+
605
+ _RESTORE_ABSORBED = text(
606
+ """
607
+ UPDATE entities
608
+ SET status = 'active', merged_into = NULL, updated_at = now()
609
+ WHERE deployment_id = :deployment_id AND entity_id = :entity_id
610
+ AND status = 'merged'
611
+ """
612
+ )
613
+
614
+ _SELECT_MERGE = text(
615
+ """
616
+ SELECT survivor_id, absorbed_id, blast_radius,
617
+ pre_merge_membership_snapshot, reversed_by
618
+ FROM merge_events
619
+ WHERE deployment_id = :deployment_id AND merge_id = :merge_id
620
+ """
621
+ )
622
+
623
+ _MARK_REVERSED = text(
624
+ "UPDATE merge_events SET reversed_by = :reversal_id WHERE merge_id = :merge_id"
625
+ )
626
+
627
+ _INSERT_REVIEW = text(
628
+ """
629
+ INSERT INTO review_queue (
630
+ review_id, deployment_id, item_kind, candidate, blast_radius,
631
+ confidence, expected_impact
632
+ ) VALUES (
633
+ :review_id, :deployment_id, 'merge_cluster', :candidate, :blast_radius,
634
+ :confidence, :expected_impact
635
+ )
636
+ """
637
+ ).bindparams(bindparam("candidate", type_=JSON))
638
+
639
+ _SELECT_MERGE_LOCKED = text(
640
+ """
641
+ SELECT survivor_id, absorbed_id, blast_radius,
642
+ pre_merge_membership_snapshot, reversed_by
643
+ FROM merge_events
644
+ WHERE deployment_id = :deployment_id AND merge_id = :merge_id
645
+ FOR UPDATE
646
+ """
647
+ )
648
+
649
+ _LIVE_MERGE_OF = text(
650
+ """
651
+ SELECT merge_id, survivor_id, absorbed_id, blast_radius,
652
+ pre_merge_membership_snapshot
653
+ FROM merge_events
654
+ WHERE deployment_id = :deployment_id
655
+ AND absorbed_id = :absorbed_id
656
+ AND reversed_by IS NULL
657
+ ORDER BY decided_at DESC
658
+ LIMIT 1
659
+ FOR UPDATE
660
+ """
661
+ )
662
+
663
+ _LIVE_DECISION_FOR_MENTION = text(
664
+ """
665
+ SELECT decision_id, entity_id, resolver_version
666
+ FROM resolution_decisions
667
+ WHERE deployment_id = :deployment_id
668
+ AND mention_id = :mention_id
669
+ AND superseded_by IS NULL
670
+ ORDER BY decided_at DESC
671
+ LIMIT 1
672
+ """
673
+ )
674
+
675
+ _INSERT_RESTORE_DECISION = text(
676
+ """
677
+ INSERT INTO resolution_decisions (
678
+ decision_id, deployment_id, mention_id, entity_id, method,
679
+ confidence, is_new_entity, features, resolver_version, decided_by
680
+ ) VALUES (
681
+ :decision_id, :deployment_id, :mention_id, :entity_id, 'human',
682
+ 1.0, false, '{"unmerge_replay": true}', :resolver_version, 'human'
683
+ )
684
+ """
685
+ )
686
+
687
+ _SUPERSEDE_DECISION = text(
688
+ """
689
+ UPDATE resolution_decisions SET superseded_by = :superseded_by
690
+ WHERE decision_id = :decision_id
691
+ """
692
+ )
693
+
694
+ _CROSS_IDENTITY_CLOSURES = text(
695
+ """
696
+ WITH RECURSIVE left_side AS (
697
+ SELECT CAST(:left_id AS uuid) AS entity_id
698
+ UNION ALL
699
+ SELECT m.entity_id FROM entities m
700
+ JOIN left_side ON m.merged_into = left_side.entity_id
701
+ WHERE m.status = 'merged' AND m.deployment_id = :deployment_id
702
+ ),
703
+ right_side AS (
704
+ SELECT CAST(:right_id AS uuid) AS entity_id
705
+ UNION ALL
706
+ SELECT m.entity_id FROM entities m
707
+ JOIN right_side ON m.merged_into = right_side.entity_id
708
+ WHERE m.status = 'merged' AND m.deployment_id = :deployment_id
709
+ )
710
+ SELECT a.relation_id, a.related_relation_id
711
+ FROM relation_adjudications a
712
+ JOIN relations closed ON closed.relation_id = a.relation_id
713
+ JOIN relations superseding ON superseding.relation_id = a.related_relation_id
714
+ WHERE a.deployment_id = :deployment_id
715
+ AND a.outcome = 'supersede'
716
+ AND a.superseded_by IS NULL
717
+ AND ((closed.subject_entity_id IN (SELECT entity_id FROM left_side)
718
+ AND superseding.subject_entity_id
719
+ IN (SELECT entity_id FROM right_side))
720
+ OR (closed.subject_entity_id IN (SELECT entity_id FROM right_side)
721
+ AND superseding.subject_entity_id
722
+ IN (SELECT entity_id FROM left_side)))
723
+ """
724
+ )
725
+
726
+ _LOCK_IDENTITY_EXCLUSIVE = text(
727
+ "SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))"
728
+ )
729
+
730
+ _INSERT_RIPPLE_REVIEW = text(
731
+ """
732
+ INSERT INTO review_queue (
733
+ review_id, deployment_id, item_kind, candidate, blast_radius,
734
+ confidence, expected_impact
735
+ ) VALUES (
736
+ :review_id, :deployment_id, 'split_cluster', :candidate,
737
+ :blast_radius, :confidence, :expected_impact
738
+ )
739
+ """
740
+ ).bindparams(bindparam("candidate", type_=JSON))