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.
- rememberstack/__init__.py +9 -0
- rememberstack/adapters/__init__.py +42 -0
- rememberstack/adapters/codex_writer.py +221 -0
- rememberstack/adapters/markitdown_converter.py +42 -0
- rememberstack/adapters/openrouter.py +136 -0
- rememberstack/adapters/selfhost/__init__.py +54 -0
- rememberstack/adapters/selfhost/forget.py +66 -0
- rememberstack/adapters/selfhost/git.py +374 -0
- rememberstack/adapters/selfhost/lance.py +328 -0
- rememberstack/adapters/selfhost/minio.py +279 -0
- rememberstack/adapters/selfhost/mounts.py +249 -0
- rememberstack/adapters/selfhost/object_store.py +130 -0
- rememberstack/adapters/selfhost/projection.py +80 -0
- rememberstack/adapters/selfhost/queue.py +137 -0
- rememberstack/adapters/selfhost/telemetry.py +45 -0
- rememberstack/adapters/selfhost/watcher.py +70 -0
- rememberstack/adapters/testing/__init__.py +15 -0
- rememberstack/adapters/testing/cost_meter.py +13 -0
- rememberstack/adapters/testing/model_provider.py +83 -0
- rememberstack/adapters/testing/queue.py +43 -0
- rememberstack/adapters/testing/telemetry.py +22 -0
- rememberstack/client.py +19 -0
- rememberstack/core/__init__.py +127 -0
- rememberstack/core/blockizer.py +189 -0
- rememberstack/core/chunker.py +216 -0
- rememberstack/core/consumption_skill.py +275 -0
- rememberstack/core/conversion.py +76 -0
- rememberstack/core/core_manifest.py +598 -0
- rememberstack/core/extension_packs.py +124 -0
- rememberstack/core/forget.py +17 -0
- rememberstack/core/knowledge_authored.py +276 -0
- rememberstack/core/knowledge_compile.py +215 -0
- rememberstack/core/knowledge_fact_sheet.py +210 -0
- rememberstack/core/knowledge_hashing.py +68 -0
- rememberstack/core/knowledge_planner.py +64 -0
- rememberstack/core/knowledge_writer.py +175 -0
- rememberstack/core/ranking.py +200 -0
- rememberstack/core/recipe_linter.py +149 -0
- rememberstack/core/section_snap.py +209 -0
- rememberstack/core/storage_routing.py +27 -0
- rememberstack/eval/__init__.py +53 -0
- rememberstack/eval/consumption.py +141 -0
- rememberstack/eval/contradiction.py +184 -0
- rememberstack/eval/harness.py +136 -0
- rememberstack/eval/lifecycle.py +400 -0
- rememberstack/eval/operational_scale.py +49 -0
- rememberstack/eval/resolution.py +255 -0
- rememberstack/eval/retrieval_spikes.py +50 -0
- rememberstack/eval/skeleton.py +231 -0
- rememberstack/llm/__init__.py +1 -0
- rememberstack/model/__init__.py +589 -0
- rememberstack/model/adjudication.py +100 -0
- rememberstack/model/auth.py +27 -0
- rememberstack/model/blocks.py +30 -0
- rememberstack/model/chunks.py +190 -0
- rememberstack/model/claims.py +162 -0
- rememberstack/model/client.py +98 -0
- rememberstack/model/clustering.py +54 -0
- rememberstack/model/component_version.py +124 -0
- rememberstack/model/consumption.py +88 -0
- rememberstack/model/conversion.py +31 -0
- rememberstack/model/deployment.py +53 -0
- rememberstack/model/documents.py +168 -0
- rememberstack/model/envelope.py +513 -0
- rememberstack/model/evaluation.py +72 -0
- rememberstack/model/forget.py +143 -0
- rememberstack/model/git.py +13 -0
- rememberstack/model/knowledge.py +840 -0
- rememberstack/model/knowledge_authored.py +325 -0
- rememberstack/model/knowledge_planner.py +431 -0
- rememberstack/model/lifecycle.py +42 -0
- rememberstack/model/model_provider.py +78 -0
- rememberstack/model/mounts.py +24 -0
- rememberstack/model/object_store.py +21 -0
- rememberstack/model/operational_scale.py +59 -0
- rememberstack/model/operations.py +153 -0
- rememberstack/model/processing.py +228 -0
- rememberstack/model/queue.py +73 -0
- rememberstack/model/recipes.py +83 -0
- rememberstack/model/relations.py +79 -0
- rememberstack/model/resolution.py +83 -0
- rememberstack/model/retrieval_spikes.py +62 -0
- rememberstack/model/sections.py +120 -0
- rememberstack/model/telemetry.py +30 -0
- rememberstack/ports/__init__.py +29 -0
- rememberstack/ports/auth.py +16 -0
- rememberstack/ports/connector.py +23 -0
- rememberstack/ports/cost_meter.py +17 -0
- rememberstack/ports/forget.py +20 -0
- rememberstack/ports/git.py +20 -0
- rememberstack/ports/model_provider.py +28 -0
- rememberstack/ports/mounts.py +16 -0
- rememberstack/ports/object_store.py +27 -0
- rememberstack/ports/p1_index.py +92 -0
- rememberstack/ports/purge.py +93 -0
- rememberstack/ports/queue.py +23 -0
- rememberstack/ports/telemetry.py +21 -0
- rememberstack/profiles/__init__.py +22 -0
- rememberstack/profiles/selfhost.py +324 -0
- rememberstack/profiles/selfhost_forget.py +158 -0
- rememberstack/profiles/selfhost_operations.py +95 -0
- rememberstack/py.typed +1 -0
- rememberstack/spine/__init__.py +93 -0
- rememberstack/spine/admission.py +26 -0
- rememberstack/spine/backfill.py +168 -0
- rememberstack/spine/catalog_contract.py +742 -0
- rememberstack/spine/chunk_catalog.py +237 -0
- rememberstack/spine/claim_catalog.py +298 -0
- rememberstack/spine/clustering.py +740 -0
- rememberstack/spine/component_versions.py +208 -0
- rememberstack/spine/consumption.py +81 -0
- rememberstack/spine/deployment_bootstrap.py +445 -0
- rememberstack/spine/document_catalog.py +621 -0
- rememberstack/spine/entity_registry.py +205 -0
- rememberstack/spine/extension_packs.py +220 -0
- rememberstack/spine/fact_catalog.py +571 -0
- rememberstack/spine/forget.py +1753 -0
- rememberstack/spine/knowledge.py +5467 -0
- rememberstack/spine/lifecycle.py +1071 -0
- rememberstack/spine/migrations/__init__.py +1 -0
- rememberstack/spine/migrations/_helpers.py +153 -0
- rememberstack/spine/migrations/env.py +58 -0
- rememberstack/spine/migrations/script.py.mako +27 -0
- rememberstack/spine/migrations/versions/__init__.py +1 -0
- rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
- rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
- rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
- rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
- rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
- rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
- rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
- rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
- rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
- rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
- rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
- rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
- rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
- rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
- rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
- rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
- rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
- rememberstack/spine/observation_adjudication.py +778 -0
- rememberstack/spine/operations.py +298 -0
- rememberstack/spine/projection.py +662 -0
- rememberstack/spine/recipes.py +276 -0
- rememberstack/spine/resolver.py +763 -0
- rememberstack/spine/review.py +650 -0
- rememberstack/spine/settings.py +22 -0
- rememberstack/spine/supersession.py +510 -0
- rememberstack/spine/sync.py +128 -0
- rememberstack/spine/work_ledger.py +816 -0
- rememberstack/surfaces/__init__.py +110 -0
- rememberstack/surfaces/cli.py +447 -0
- rememberstack/surfaces/consumption_skill.py +87 -0
- rememberstack/surfaces/graph_queries.py +698 -0
- rememberstack/surfaces/http_api.py +377 -0
- rememberstack/surfaces/mcp.py +67 -0
- rememberstack/surfaces/query_engine.py +1591 -0
- rememberstack/surfaces/recipe_executor.py +185 -0
- rememberstack/surfaces/recipe_surface.py +219 -0
- rememberstack/surfaces/remote_mcp.py +133 -0
- rememberstack/surfaces/sdk.py +324 -0
- rememberstack/workers/__init__.py +155 -0
- rememberstack/workers/base.py +312 -0
- rememberstack/workers/e0.py +577 -0
- rememberstack/workers/e1.py +425 -0
- rememberstack/workers/e2.py +525 -0
- rememberstack/workers/e3.py +434 -0
- rememberstack/workers/forget.py +299 -0
- rememberstack/workers/knowledge_authored.py +146 -0
- rememberstack/workers/knowledge_driver.py +735 -0
- rememberstack/workers/knowledge_fact_sheet.py +123 -0
- rememberstack/workers/knowledge_planner.py +325 -0
- rememberstack/workers/knowledge_writer.py +393 -0
- rememberstack/workers/operations.py +42 -0
- rememberstack/workers/p1.py +234 -0
- rememberstack/workers/p2.py +513 -0
- rememberstack/workers/p2_analytics.py +276 -0
- rememberstack/workers/p3.py +673 -0
- rememberstack/workers/reconcile.py +485 -0
- rememberstack/workers/sync.py +168 -0
- rememberstack-0.1.0.dist-info/METADATA +213 -0
- rememberstack-0.1.0.dist-info/RECORD +186 -0
- rememberstack-0.1.0.dist-info/WHEEL +4 -0
- rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
- 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))
|