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,763 @@
1
+ """The full ER cascade (D17/D20): T0 exact → T1/T2 blocking → T3 → T4 → mint.
2
+
3
+ Block-loose / decide-tight: T1 (trigram) and T2 (Daitch-Mokotoff phonetic)
4
+ only GENERATE candidates; decisions are T0 (exact), T3 (embedding band), and
5
+ T4 (LLM adjudication, small → frontier escalation). A near-miss is escalated,
6
+ never auto-rejected. Every verdict lands append-only in
7
+ `resolution_decisions` with its tier, scores, and the resolver version whose
8
+ thresholds were in force. Registry-self-contained: no external authority
9
+ tier (D20).
10
+ """
11
+
12
+ from typing import Final
13
+ from uuid import UUID
14
+ from uuid import uuid4
15
+
16
+ from sqlalchemy import bindparam
17
+ from sqlalchemy import JSON
18
+ from sqlalchemy import text
19
+ from sqlalchemy.engine import Connection
20
+ from sqlalchemy.engine import Engine
21
+
22
+ from rememberstack.model import AdjudicationVerdict
23
+ from rememberstack.model import ClaimForNormalization
24
+ from rememberstack.model import EmbeddingRequest
25
+ from rememberstack.model import EntityRef
26
+ from rememberstack.model import ModelRequest
27
+ from rememberstack.model import P1EntityRow
28
+ from rememberstack.model import ResolutionCandidate
29
+ from rememberstack.model import ResolvedEntity
30
+ from rememberstack.model import ResolverConfig
31
+ from rememberstack.ports.cost_meter import CostMeterPort
32
+ from rememberstack.ports.model_provider import ModelProviderPort
33
+ from rememberstack.ports.p1_index import EntityIndexPort
34
+ from rememberstack.spine.entity_registry import normalized_lemma
35
+
36
+
37
+ class ResolverVersionConflictError(Exception):
38
+ """A resolver version re-registered with a different definition (D22)."""
39
+
40
+
41
+ RESOLVER_VERSION: Final = "resolver-2026.07a"
42
+ """The cascade generation whose thresholds stamp every decision (D17/D22)."""
43
+
44
+ _T4_PROMPT: Final = """You adjudicate entity identity for a memory system.
45
+ Are these the same real-world entity? Answer strictly from the evidence given.
46
+
47
+ MENTION: {mention!r} (emitted type {mention_type})
48
+ CLAIM CONTEXT: {context}
49
+
50
+ CANDIDATE: {candidate!r} (registry type {candidate_type})
51
+
52
+ Same entity?"""
53
+
54
+
55
+ class CascadeResolver:
56
+ """T0-T4 resolution over one deployment's registry, minting on no-match."""
57
+
58
+ def __init__(
59
+ self,
60
+ *,
61
+ engine: Engine,
62
+ entity_index: EntityIndexPort,
63
+ model_provider: ModelProviderPort,
64
+ config: ResolverConfig,
65
+ embedding_model: str,
66
+ small_model: str,
67
+ frontier_model: str,
68
+ ) -> None:
69
+ """Bind the cascade to the registry, the T3 index, and the T4 models.
70
+
71
+ Model seats follow the port-default principle (D70's pattern): the
72
+ adjudicator ladder is deployment configuration, measured per phase.
73
+ """
74
+ self._engine = engine
75
+ self._entity_index = entity_index
76
+ self._model_provider = model_provider
77
+ self._config = config
78
+ self._embedding_model = embedding_model
79
+ self._small_model = small_model
80
+ self._frontier_model = frontier_model
81
+ self._registered = False
82
+ self._last_rejection: tuple[str, float, dict[str, object]] | None = None
83
+
84
+ def resolve(
85
+ self,
86
+ *,
87
+ deployment_id: UUID,
88
+ reference: EntityRef,
89
+ claim: ClaimForNormalization,
90
+ meter: CostMeterPort | None = None,
91
+ call_key: str = "resolve",
92
+ ) -> ResolvedEntity:
93
+ """Run the cascade for one reference; mint when nothing matches.
94
+
95
+ Stops at the first confident decision. The mention and the
96
+ append-only verdict (tier, scores, config version) are written in the
97
+ same transaction as any mint. Cross-SURFACE duplicate mints (two
98
+ distinct variants racing on an empty registry) are deliberately not
99
+ serialized here — that is the clustering/merge machinery's job
100
+ (registries §6, WP-2.2); the lemma lock prevents the same-lemma race.
101
+ """
102
+ self._ensure_registered(deployment_id=deployment_id)
103
+ lemma = normalized_lemma(surface=reference.name)
104
+ with self._engine.begin() as connection:
105
+ connection.execute(_LOCK_LEMMA, {"key": f"{deployment_id}:lemma:{lemma}"})
106
+ exact = (
107
+ connection.execute(
108
+ _T0_EXACT, {"deployment_id": deployment_id, "lemma": lemma}
109
+ )
110
+ .mappings()
111
+ .one_or_none()
112
+ )
113
+ if exact is not None:
114
+ return self._record(
115
+ connection=connection,
116
+ deployment_id=deployment_id,
117
+ reference=reference,
118
+ claim=claim,
119
+ lemma=lemma,
120
+ entity_id=exact["entity_id"],
121
+ entity_type=exact["type"],
122
+ method="T0",
123
+ confidence=1.0,
124
+ features={"lemma": lemma},
125
+ created=False,
126
+ )
127
+ candidates = self._blocked_candidates(
128
+ connection=connection, deployment_id=deployment_id, lemma=lemma
129
+ )
130
+ decision = self._decide(
131
+ deployment_id=deployment_id,
132
+ reference=reference,
133
+ claim=claim,
134
+ candidates=candidates,
135
+ meter=meter,
136
+ call_key=call_key,
137
+ )
138
+ if decision is not None:
139
+ candidate, method, confidence, features = decision
140
+ return self._record(
141
+ connection=connection,
142
+ deployment_id=deployment_id,
143
+ reference=reference,
144
+ claim=claim,
145
+ lemma=lemma,
146
+ entity_id=candidate.entity_id,
147
+ entity_type=candidate.type,
148
+ method=method,
149
+ confidence=confidence,
150
+ features=features,
151
+ created=False,
152
+ )
153
+ return self._mint(
154
+ connection=connection,
155
+ deployment_id=deployment_id,
156
+ reference=reference,
157
+ claim=claim,
158
+ lemma=lemma,
159
+ considered=candidates,
160
+ meter=meter,
161
+ call_key=call_key,
162
+ )
163
+
164
+ def _ensure_registered(self, *, deployment_id: UUID) -> None:
165
+ """Verify the in-force config IS the registered resolver version.
166
+
167
+ Registers on first use; a version whose stored definition differs
168
+ from this config is a hard error — thresholds are immutable per
169
+ version (D22): change the numbers, mint a new version string.
170
+ """
171
+ if self._registered:
172
+ return
173
+ seed_resolver_version(
174
+ engine=self._engine, deployment_id=deployment_id, config=self._config
175
+ )
176
+ self._registered = True
177
+
178
+ def judge_pair(
179
+ self,
180
+ *,
181
+ surface_a: str,
182
+ surface_b: str,
183
+ entity_type: str,
184
+ context_a: str | None,
185
+ context_b: str | None,
186
+ ) -> tuple[bool, str]:
187
+ """The cascade's decision function over one golden pair (D22).
188
+
189
+ Registry-free: measures whether the tiers would identify the two
190
+ surfaces — lemma equality (T0), blocking reachability (T1/T2; a pair
191
+ blocking cannot reach is a no_match by the recall ceiling), the T3
192
+ band over the two surface embeddings, then T4 with both contexts.
193
+ Returns (match, deciding_tier).
194
+ """
195
+ lemma_a = normalized_lemma(surface=surface_a)
196
+ lemma_b = normalized_lemma(surface=surface_b)
197
+ if lemma_a == lemma_b:
198
+ return True, "T0"
199
+ with self._engine.connect() as connection:
200
+ reachable = connection.execute(
201
+ _PAIR_REACHABLE,
202
+ {"a": lemma_a, "b": lemma_b, "floor": self._config.trigram_floor},
203
+ ).scalar_one()
204
+ if not reachable:
205
+ return False, "blocking"
206
+ thresholds = self._config.thresholds_for(entity_type=entity_type)
207
+ vectors = self._model_provider.embed(
208
+ request=EmbeddingRequest(
209
+ model=self._embedding_model, texts=(surface_a, surface_b)
210
+ )
211
+ ).vectors
212
+ score = _cosine(vectors[0], vectors[1])
213
+ if score >= thresholds.t3_accept:
214
+ return True, "T3"
215
+ if score <= thresholds.t3_reject:
216
+ return False, "T3"
217
+ prompt = _T4_PROMPT.format(
218
+ mention=surface_b,
219
+ mention_type=entity_type,
220
+ context=context_b or "(none)",
221
+ candidate=surface_a,
222
+ candidate_type=entity_type,
223
+ )
224
+ if context_a:
225
+ prompt += f"\nCANDIDATE CONTEXT: {context_a}"
226
+ verdict = self._model_provider.generate(
227
+ request=ModelRequest(model=self._small_model, prompt=prompt),
228
+ response_type=AdjudicationVerdict,
229
+ )
230
+ if verdict.output.confidence >= thresholds.t4_small_confidence_floor:
231
+ return verdict.output.match, "T4_small"
232
+ frontier = self._model_provider.generate(
233
+ request=ModelRequest(model=self._frontier_model, prompt=prompt),
234
+ response_type=AdjudicationVerdict,
235
+ )
236
+ return frontier.output.match, "T4_frontier"
237
+
238
+ def _blocked_candidates(
239
+ self, *, connection: Connection, deployment_id: UUID, lemma: str
240
+ ) -> tuple[ResolutionCandidate, ...]:
241
+ """T1 trigram + T2 phonetic candidate generation (never a decision)."""
242
+ rows = (
243
+ connection.execute(
244
+ _T1_T2_BLOCK,
245
+ {
246
+ "deployment_id": deployment_id,
247
+ "lemma": lemma,
248
+ "floor": self._config.trigram_floor,
249
+ "limit": self._config.blocking_limit,
250
+ },
251
+ )
252
+ .mappings()
253
+ .all()
254
+ )
255
+ return tuple(
256
+ ResolutionCandidate(
257
+ entity_id=row["entity_id"],
258
+ canonical_name=row["canonical_name"],
259
+ type=row["type"],
260
+ blocking_tier=row["blocking_tier"],
261
+ trigram_score=row["trigram_score"],
262
+ )
263
+ for row in rows
264
+ )
265
+
266
+ def _decide(
267
+ self,
268
+ *,
269
+ deployment_id: UUID,
270
+ reference: EntityRef,
271
+ claim: ClaimForNormalization,
272
+ candidates: tuple[ResolutionCandidate, ...],
273
+ meter: CostMeterPort | None,
274
+ call_key: str,
275
+ ) -> tuple[ResolutionCandidate, str, float, dict[str, object]] | None:
276
+ """T3 embedding bands, then T4 adjudication for the ambiguous band."""
277
+ if not candidates:
278
+ return None
279
+ thresholds = self._config.thresholds_for(entity_type=reference.type)
280
+ scored = self._t3_scores(
281
+ deployment_id=deployment_id,
282
+ reference=reference,
283
+ candidates=candidates,
284
+ meter=meter,
285
+ call_key=f"{call_key}:t3",
286
+ )
287
+ ordered = sorted(
288
+ scored,
289
+ key=lambda item: item[1] if item[1] is not None else 0.0,
290
+ reverse=True,
291
+ )
292
+ adjudicated = 0
293
+ for candidate, score in ordered:
294
+ if score is not None and score >= thresholds.t3_accept:
295
+ return (
296
+ candidate,
297
+ "T3",
298
+ score,
299
+ {
300
+ "blocking_tier": candidate.blocking_tier,
301
+ "embedding_score": score,
302
+ },
303
+ )
304
+ if score is not None and score <= thresholds.t3_reject:
305
+ self._last_rejection = ("T3", score, {"embedding_score": score})
306
+ continue # confidently not THIS candidate; others get a look
307
+ # ambiguous band — or no stored profile vector, which must
308
+ # ESCALATE, never count as a confident non-match (Codex review):
309
+ if adjudicated >= self._config.t4_max_candidates:
310
+ break
311
+ adjudicated += 1
312
+ verdict, seat, model = self._t4(
313
+ reference=reference,
314
+ claim=claim,
315
+ candidate=candidate,
316
+ meter=meter,
317
+ call_key=f"{call_key}:t4:{candidate.entity_id}",
318
+ )
319
+ if verdict.match:
320
+ return (
321
+ candidate,
322
+ seat,
323
+ verdict.confidence,
324
+ {
325
+ "blocking_tier": candidate.blocking_tier,
326
+ "embedding_score": score,
327
+ "model": model,
328
+ "rationale": verdict.rationale,
329
+ },
330
+ )
331
+ self._last_rejection = (
332
+ seat,
333
+ verdict.confidence,
334
+ {"model": model, "rationale": verdict.rationale},
335
+ )
336
+ return None
337
+
338
+ def _t3_scores(
339
+ self,
340
+ *,
341
+ deployment_id: UUID,
342
+ reference: EntityRef,
343
+ candidates: tuple[ResolutionCandidate, ...],
344
+ meter: CostMeterPort | None,
345
+ call_key: str,
346
+ ) -> tuple[tuple[ResolutionCandidate, float | None], ...]:
347
+ """Cosine similarity against candidate profiles; None = no profile.
348
+
349
+ A missing/stale profile vector is AMBIGUITY (route to T4), never a
350
+ confident non-match (Codex review).
351
+ """
352
+ query_vector = self._embed(
353
+ surface=reference.name, meter=meter, call_key=call_key
354
+ )
355
+ by_id = self._entity_index.entity_vectors(
356
+ deployment_id=str(deployment_id),
357
+ entity_ids=tuple(str(candidate.entity_id) for candidate in candidates),
358
+ )
359
+ return tuple(
360
+ (
361
+ candidate,
362
+ None
363
+ if by_id.get(str(candidate.entity_id)) is None
364
+ else _cosine(query_vector, by_id[str(candidate.entity_id)]),
365
+ )
366
+ for candidate in candidates
367
+ )
368
+
369
+ def _t4(
370
+ self,
371
+ *,
372
+ reference: EntityRef,
373
+ claim: ClaimForNormalization,
374
+ candidate: ResolutionCandidate,
375
+ meter: CostMeterPort | None,
376
+ call_key: str,
377
+ ) -> tuple[AdjudicationVerdict, str, str]:
378
+ """T4 small-model adjudication, escalating to frontier below the floor."""
379
+ prompt = _T4_PROMPT.format(
380
+ mention=reference.name,
381
+ mention_type=reference.type,
382
+ context=claim.claim_text,
383
+ candidate=candidate.canonical_name,
384
+ candidate_type=candidate.type,
385
+ )
386
+ verdict_call = self._model_provider.generate(
387
+ request=ModelRequest(model=self._small_model, prompt=prompt),
388
+ response_type=AdjudicationVerdict,
389
+ )
390
+ if meter is not None:
391
+ meter.record(
392
+ call_key=f"{call_key}:small", tier="T4_small", usage=verdict_call.usage
393
+ )
394
+ verdict = verdict_call.output
395
+ thresholds = self._config.thresholds_for(entity_type=reference.type)
396
+ if verdict.confidence >= thresholds.t4_small_confidence_floor:
397
+ return verdict, "T4_small", self._small_model
398
+ frontier_call = self._model_provider.generate(
399
+ request=ModelRequest(model=self._frontier_model, prompt=prompt),
400
+ response_type=AdjudicationVerdict,
401
+ )
402
+ if meter is not None:
403
+ meter.record(
404
+ call_key=f"{call_key}:frontier",
405
+ tier="T4_frontier",
406
+ usage=frontier_call.usage,
407
+ )
408
+ return frontier_call.output, "T4_frontier", self._frontier_model
409
+
410
+ def _mint(
411
+ self,
412
+ *,
413
+ connection: Connection,
414
+ deployment_id: UUID,
415
+ reference: EntityRef,
416
+ claim: ClaimForNormalization,
417
+ lemma: str,
418
+ considered: tuple[ResolutionCandidate, ...],
419
+ meter: CostMeterPort | None,
420
+ call_key: str,
421
+ ) -> ResolvedEntity:
422
+ """Create the canonical entity + alias and index its T3 profile."""
423
+ entity_id = uuid4()
424
+ # the mint verdict records the tier that DECIDED novelty: T0 when
425
+ # nothing blocked, else the rejecting tier's method and confidence
426
+ # (Codex review — the audit trail keeps the actual path):
427
+ rejection = self._last_rejection if considered else None
428
+ self._last_rejection = None
429
+ method, confidence, extra = rejection or ("T0", 1.0, {})
430
+ connection.execute(
431
+ _INSERT_ENTITY,
432
+ {
433
+ "entity_id": entity_id,
434
+ "deployment_id": deployment_id,
435
+ "type": reference.type,
436
+ "canonical_name": reference.name,
437
+ "normalized_name": lemma,
438
+ },
439
+ )
440
+ connection.execute(
441
+ _INSERT_ALIAS,
442
+ {
443
+ "alias_id": uuid4(),
444
+ "deployment_id": deployment_id,
445
+ "entity_id": entity_id,
446
+ "alias_text": reference.name,
447
+ "lemma": lemma,
448
+ },
449
+ )
450
+ self._entity_index.upsert_entities(
451
+ rows=(
452
+ P1EntityRow(
453
+ entity_id=entity_id,
454
+ deployment_id=deployment_id,
455
+ type=reference.type,
456
+ canonical_name=reference.name,
457
+ vector=self._embed(
458
+ surface=reference.name, meter=meter, call_key=f"{call_key}:mint"
459
+ ),
460
+ ),
461
+ )
462
+ )
463
+ connection.execute(
464
+ _STAMP_PROFILE_REF, {"entity_id": entity_id, "ref": str(entity_id)}
465
+ )
466
+ return self._record(
467
+ connection=connection,
468
+ deployment_id=deployment_id,
469
+ reference=reference,
470
+ claim=claim,
471
+ lemma=lemma,
472
+ entity_id=entity_id,
473
+ entity_type=reference.type,
474
+ method=method,
475
+ confidence=confidence,
476
+ features={
477
+ "lemma": lemma,
478
+ "novelty": True,
479
+ "considered": [str(c.entity_id) for c in considered],
480
+ **extra,
481
+ },
482
+ created=True,
483
+ )
484
+
485
+ def _record(
486
+ self,
487
+ *,
488
+ connection: Connection,
489
+ deployment_id: UUID,
490
+ reference: EntityRef,
491
+ claim: ClaimForNormalization,
492
+ lemma: str,
493
+ entity_id: UUID,
494
+ entity_type: str,
495
+ method: str,
496
+ confidence: float,
497
+ features: dict[str, object],
498
+ created: bool,
499
+ ) -> ResolvedEntity:
500
+ """Write the mention + append-only verdict; return the resolution."""
501
+ mention_id = uuid4()
502
+ connection.execute(
503
+ _INSERT_MENTION,
504
+ {
505
+ "mention_id": mention_id,
506
+ "deployment_id": deployment_id,
507
+ "surface_form": reference.name,
508
+ "lemma": lemma,
509
+ "canonical_name_form": reference.name,
510
+ "emitted_type": reference.type,
511
+ "claim_id": claim.claim_id,
512
+ "chunk_id": claim.chunk_id,
513
+ "doc_id": claim.doc_id,
514
+ },
515
+ )
516
+ connection.execute(
517
+ _INSERT_DECISION,
518
+ {
519
+ "decision_id": uuid4(),
520
+ "deployment_id": deployment_id,
521
+ "mention_id": mention_id,
522
+ "entity_id": entity_id,
523
+ "method": method,
524
+ "confidence": confidence,
525
+ "is_new_entity": created,
526
+ "features": features,
527
+ "resolver_version": self._config.resolver_version,
528
+ },
529
+ )
530
+ connection.execute( # keep the blast-radius input warm (registries §6)
531
+ _BUMP_MENTION_COUNT,
532
+ {"deployment_id": deployment_id, "entity_id": entity_id},
533
+ )
534
+ return ResolvedEntity(
535
+ entity_id=entity_id, created=created, entity_type=entity_type
536
+ )
537
+
538
+ def _embed(
539
+ self, *, surface: str, meter: CostMeterPort | None, call_key: str
540
+ ) -> tuple[float, ...]:
541
+ """One profile/query embedding through the configured port (D63)."""
542
+ response = self._model_provider.embed(
543
+ request=EmbeddingRequest(model=self._embedding_model, texts=(surface,))
544
+ )
545
+ if meter is not None:
546
+ meter.record(call_key=call_key, tier="T3", usage=response.usage)
547
+ return response.vectors[0]
548
+
549
+
550
+ def seed_resolver_version(
551
+ *, engine: Engine, deployment_id: UUID, config: ResolverConfig
552
+ ) -> None:
553
+ """Register the cascade configuration once (immutable per version, D22).
554
+
555
+ Re-seeding an identical definition is a no-op; a DIFFERENT definition
556
+ under the same version string is a hard error — change the numbers, mint
557
+ a new version. Thresholds are starting points until curves exist.
558
+ """
559
+ definition = _config_definition(config=config)
560
+ stored = _stored_config(
561
+ engine=engine,
562
+ deployment_id=deployment_id,
563
+ resolver_version=config.resolver_version,
564
+ )
565
+ if stored is not None:
566
+ if stored != definition:
567
+ raise ResolverVersionConflictError(
568
+ f"resolver version {config.resolver_version!r} already registered "
569
+ "with a different definition; mint a new version string"
570
+ )
571
+ return
572
+ with engine.begin() as connection:
573
+ connection.execute(
574
+ _SEED_RESOLVER_VERSION,
575
+ {
576
+ "deployment_id": deployment_id,
577
+ "resolver_version": config.resolver_version,
578
+ **definition,
579
+ },
580
+ )
581
+
582
+
583
+ def _stored_config(
584
+ *, engine: Engine, deployment_id: UUID, resolver_version: str
585
+ ) -> dict[str, object] | None:
586
+ """The registered definition for a version, or None if unregistered."""
587
+ with engine.connect() as connection:
588
+ row = (
589
+ connection.execute(
590
+ _SELECT_RESOLVER_VERSION,
591
+ {"deployment_id": deployment_id, "resolver_version": resolver_version},
592
+ )
593
+ .mappings()
594
+ .one_or_none()
595
+ )
596
+ if row is None:
597
+ return None
598
+ return {
599
+ "tier_config": row["tier_config"],
600
+ "thresholds_by_type": row["thresholds_by_type"],
601
+ }
602
+
603
+
604
+ def _config_definition(*, config: ResolverConfig) -> dict[str, object]:
605
+ """The comparable stored form of one in-memory config."""
606
+ return {
607
+ "tier_config": {
608
+ "order": ["T0", "T1", "T2", "T3", "T4_small", "T4_frontier"],
609
+ "trigram_floor": config.trigram_floor,
610
+ "blocking_limit": config.blocking_limit,
611
+ "t4_max_candidates": config.t4_max_candidates,
612
+ },
613
+ "thresholds_by_type": {
614
+ "default": config.default_thresholds.model_dump(),
615
+ **{
616
+ entity_type: thresholds.model_dump()
617
+ for entity_type, thresholds in config.thresholds_by_type.items()
618
+ },
619
+ },
620
+ }
621
+
622
+
623
+ def _cosine(a: tuple[float, ...], b: tuple[float, ...] | None) -> float:
624
+ """Cosine similarity; a candidate without a profile vector scores 0."""
625
+ if b is None or len(a) != len(b):
626
+ return 0.0
627
+ dot = sum(x * y for x, y in zip(a, b, strict=True))
628
+ norm_a = sum(x * x for x in a) ** 0.5
629
+ norm_b = sum(y * y for y in b) ** 0.5
630
+ if norm_a == 0.0 or norm_b == 0.0:
631
+ return 0.0
632
+ return dot / (norm_a * norm_b)
633
+
634
+
635
+ _LOCK_LEMMA = text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))")
636
+
637
+ _PAIR_REACHABLE = text(
638
+ """
639
+ SELECT similarity(:a, :b) >= :floor
640
+ OR daitch_mokotoff(:a) && daitch_mokotoff(:b)
641
+ """
642
+ )
643
+
644
+ _T0_EXACT = text(
645
+ """
646
+ SELECT aliases.entity_id, entities.type FROM aliases
647
+ JOIN entities ON entities.deployment_id = aliases.deployment_id
648
+ AND entities.entity_id = aliases.entity_id
649
+ WHERE aliases.deployment_id = :deployment_id
650
+ AND aliases.normalized_lemma = :lemma
651
+ AND entities.status = 'active'
652
+ ORDER BY aliases.first_seen
653
+ LIMIT 1
654
+ """
655
+ )
656
+
657
+ _T1_T2_BLOCK = text(
658
+ """
659
+ WITH t1 AS (
660
+ SELECT DISTINCT ON (aliases.entity_id)
661
+ aliases.entity_id, similarity(aliases.normalized_lemma, :lemma) AS score
662
+ FROM aliases
663
+ WHERE aliases.deployment_id = :deployment_id
664
+ AND similarity(aliases.normalized_lemma, :lemma) >= :floor
665
+ ORDER BY aliases.entity_id, score DESC
666
+ ),
667
+ t2 AS (
668
+ SELECT DISTINCT aliases.entity_id
669
+ FROM aliases
670
+ WHERE aliases.deployment_id = :deployment_id
671
+ AND daitch_mokotoff(aliases.normalized_lemma)
672
+ && daitch_mokotoff(:lemma)
673
+ )
674
+ SELECT entities.entity_id, entities.canonical_name, entities.type,
675
+ coalesce(t1.score, 0.0) AS trigram_score,
676
+ CASE WHEN t1.entity_id IS NOT NULL THEN 'T1' ELSE 'T2' END
677
+ AS blocking_tier
678
+ FROM entities
679
+ LEFT JOIN t1 ON t1.entity_id = entities.entity_id
680
+ LEFT JOIN t2 ON t2.entity_id = entities.entity_id
681
+ WHERE entities.deployment_id = :deployment_id
682
+ AND entities.status = 'active'
683
+ AND (t1.entity_id IS NOT NULL OR t2.entity_id IS NOT NULL)
684
+ ORDER BY coalesce(t1.score, 0.0) DESC
685
+ LIMIT :limit
686
+ """
687
+ )
688
+
689
+ _INSERT_ENTITY = text(
690
+ """
691
+ INSERT INTO entities (
692
+ entity_id, deployment_id, type, canonical_name, normalized_name
693
+ ) VALUES (
694
+ :entity_id, :deployment_id, :type, :canonical_name, :normalized_name
695
+ )
696
+ """
697
+ )
698
+
699
+ _INSERT_ALIAS = text(
700
+ """
701
+ INSERT INTO aliases (
702
+ alias_id, deployment_id, entity_id, alias_text, normalized_lemma, provenance
703
+ ) VALUES (
704
+ :alias_id, :deployment_id, :entity_id, :alias_text, :lemma, 'llm_canonical'
705
+ )
706
+ """
707
+ )
708
+
709
+ _STAMP_PROFILE_REF = text(
710
+ "UPDATE entities SET profile_embedding_ref = :ref WHERE entity_id = :entity_id"
711
+ )
712
+
713
+ _INSERT_MENTION = text(
714
+ """
715
+ INSERT INTO mentions (
716
+ mention_id, deployment_id, surface_form, normalized_lemma,
717
+ canonical_name_form, emitted_type, claim_id, chunk_id, doc_id
718
+ ) VALUES (
719
+ :mention_id, :deployment_id, :surface_form, :lemma,
720
+ :canonical_name_form, :emitted_type, :claim_id, :chunk_id, :doc_id
721
+ )
722
+ """
723
+ )
724
+
725
+ _INSERT_DECISION = text(
726
+ """
727
+ INSERT INTO resolution_decisions (
728
+ decision_id, deployment_id, mention_id, entity_id, method,
729
+ confidence, is_new_entity, features, resolver_version
730
+ ) VALUES (
731
+ :decision_id, :deployment_id, :mention_id, :entity_id, :method,
732
+ :confidence, :is_new_entity, :features, :resolver_version
733
+ )
734
+ """
735
+ ).bindparams(bindparam("features", type_=JSON))
736
+
737
+ _SEED_RESOLVER_VERSION = text(
738
+ """
739
+ INSERT INTO resolver_versions (
740
+ deployment_id, resolver_version, tier_config, thresholds_by_type
741
+ ) VALUES (
742
+ :deployment_id, :resolver_version, :tier_config, :thresholds_by_type
743
+ )
744
+ ON CONFLICT (deployment_id, resolver_version) DO NOTHING
745
+ """
746
+ ).bindparams(
747
+ bindparam("tier_config", type_=JSON), bindparam("thresholds_by_type", type_=JSON)
748
+ )
749
+
750
+ _SELECT_RESOLVER_VERSION = text(
751
+ """
752
+ SELECT tier_config, thresholds_by_type FROM resolver_versions
753
+ WHERE deployment_id = :deployment_id
754
+ AND resolver_version = :resolver_version
755
+ """
756
+ )
757
+
758
+ _BUMP_MENTION_COUNT = text(
759
+ """
760
+ UPDATE entities SET mention_count = mention_count + 1, updated_at = now()
761
+ WHERE deployment_id = :deployment_id AND entity_id = :entity_id
762
+ """
763
+ )