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,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
|
+
)
|