pbuffer 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.
@@ -0,0 +1,1271 @@
1
+ """The identity registry (whitepaper §11): anchors, aliases,
2
+ SAME_AS / MAYBE_SAME_AS, merges as logged events.
3
+
4
+ The registry is derived: edges are ordinary assertions; the closure is
5
+ computed (union-find over visible same_as edges). A merge appends — late
6
+ binding leaves every earlier row intact and reachable through the merged
7
+ identity. A bad merge is repaired forward by retracting the edge.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+
15
+ from patternbuffer.buffer import PatternBuffer
16
+ from patternbuffer.classify import CONSTITUTIVE, DISPOSITIONAL
17
+ from patternbuffer.model import (
18
+ ATTR_PREFIX,
19
+ CANON,
20
+ CONTAINMENT_FAMILY,
21
+ META_ATTRIBUTES,
22
+ Assertion,
23
+ )
24
+ from patternbuffer.roles import WriterRole
25
+
26
+ # Identity edges — never a "relating edge" for the distinctness signal.
27
+ _IDENTITY_ATTRS = frozenset({"same_as", "maybe_same_as", "distinct_from", "aka"})
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Content-token normalization for the alias-specificity test (IDENTITY-RECALL-V1
32
+ # §3): a discriminative alias has >=2 *informative* tokens after dropping
33
+ # articles, possessives, honorific/title prefixes, and punctuation.
34
+ _ARTICLES = frozenset({"the", "a", "an"})
35
+ _HONORIFICS = frozenset({
36
+ "mr", "mrs", "ms", "dr", "sir", "lord", "lady", "master", "mister",
37
+ "madam", "prof", "professor", "captain", "king", "queen", "saint", "st",
38
+ })
39
+
40
+
41
+ def _content_tokens(text: str) -> list[str]:
42
+ """Normalized informative tokens of a name/alias string."""
43
+ t = text.strip().lower()
44
+ t = re.sub(r"'s\b", "", t) # drop possessive 's
45
+ t = re.sub(r"[^\w\s]", " ", t) # punctuation -> space
46
+ out = []
47
+ for tok in t.split():
48
+ if len(tok) <= 1: # stray single chars (possessive remnants)
49
+ continue
50
+ if tok in _ARTICLES or tok in _HONORIFICS:
51
+ continue
52
+ out.append(tok)
53
+ return out
54
+
55
+
56
+ class IdentityRegistry:
57
+ def __init__(
58
+ self, buffer: PatternBuffer, ingestor: WriterRole, semantics=None
59
+ ) -> None:
60
+ self._buffer = buffer
61
+ self._ingestor = ingestor
62
+ # AttributeSemantics, for the declared containment family the merge
63
+ # veto consults; falls back to the base family if not wired.
64
+ self._semantics = semantics
65
+ # Late-bound kind fold provider (entity -> FoldResult), wired by World
66
+ # after Indexes exists (mirrors indexes.set_closure_provider). The
67
+ # recall gate reads kind through it; absent provider => kind unknown.
68
+ self._kind_of = None
69
+ # Late-bound retract/classify providers (SHAPE-FIX-V1 `retype`): the
70
+ # retraction authority lives in TruthMaintenance and the durability
71
+ # verdict in the Classifier — both constructed after the registry, so
72
+ # World wires them (same idiom as set_kind_provider).
73
+ self._retract = None
74
+ self._classify_rows = None
75
+ # Late-bound fold + durability lookups for the durable-contradiction
76
+ # veto (SHAPE-FIX-V1 Win 4, HD 089: the fused-protagonist incident).
77
+ self._fold_of = None
78
+ self._durability_of = None
79
+
80
+ def set_kind_provider(self, fn) -> None:
81
+ """Install the kind-fold lookup the recall gate consults (§2)."""
82
+ self._kind_of = fn
83
+
84
+ def set_retract_provider(self, fn) -> None:
85
+ """Install the retraction write (TruthMaintenance.retract) `retype` uses."""
86
+ self._retract = fn
87
+
88
+ def set_classify_provider(self, fn) -> None:
89
+ """Install the rules-mode classifier pass for rows `retype` appends."""
90
+ self._classify_rows = fn
91
+
92
+ def set_fold_provider(self, fn) -> None:
93
+ """Install the general key-fold lookup ((entity, attribute) ->
94
+ FoldResult) the durable-contradiction veto reads through."""
95
+ self._fold_of = fn
96
+
97
+ def set_durability_provider(self, fn) -> None:
98
+ """Install the durability lookup (assertion_id -> class)."""
99
+ self._durability_of = fn
100
+
101
+ # --------------------------------------------------------------- write
102
+
103
+ def add_alias(self, entity: str, alias: str, status: str = "stated") -> None:
104
+ self._buffer.append(
105
+ entity=entity, attribute="alias", value=alias.strip().lower(),
106
+ status=status, role=self._ingestor,
107
+ )
108
+
109
+ def _containment_family(self) -> set[str]:
110
+ if self._semantics is not None:
111
+ return self._semantics.containment_family()
112
+ return set(CONTAINMENT_FAMILY)
113
+
114
+ def containment_block(self, a: str, b: str, asserted_as_of: int | None = None) -> list[str]:
115
+ """The containment/location edge descriptor(s) relating a member of a's
116
+ closure to a member of b's closure (either direction). A non-empty list
117
+ is the merge veto (010): a thing is never identical to what holds it.
118
+ Descriptors `entity·attr·value` name the blocking edge so a receipt can
119
+ tell the host which edge to retract (MERGE-RECONCILE-VERB-V1). Membrane-
120
+ clean: computed from present edges, never stored."""
121
+ family = self._containment_family()
122
+ clos_a = self.closure(a, asserted_as_of)
123
+ clos_b = self.closure(b, asserted_as_of)
124
+ out: list[str] = []
125
+ for row in self._buffer.visible(asserted_as_of=asserted_as_of):
126
+ if row.attribute not in family or row.value_type != "entity":
127
+ continue
128
+ if not isinstance(row.value, str):
129
+ continue
130
+ e, v = row.entity, row.value
131
+ if (e in clos_a and v in clos_b) or (e in clos_b and v in clos_a):
132
+ out.append(f"{e}·{row.attribute}·{v}")
133
+ return out
134
+
135
+ def distinct_block(self, a: str, b: str, asserted_as_of: int | None = None) -> list[str]:
136
+ """`distinct_from` edge descriptor(s) relating a's closure to b's — the
137
+ explicit anti-merge primitive (V2 §1, the mirror of `same_as`). A
138
+ non-empty list is a HARD veto: the author declared these definitively
139
+ not the same. Membrane-clean."""
140
+ clos_a = self.closure(a, asserted_as_of)
141
+ clos_b = self.closure(b, asserted_as_of)
142
+ out: list[str] = []
143
+ for row in self._buffer.visible(attribute="distinct_from", asserted_as_of=asserted_as_of):
144
+ if row.value_type != "entity" or not isinstance(row.value, str):
145
+ continue
146
+ e, v = row.entity, row.value
147
+ if (e in clos_a and v in clos_b) or (e in clos_b and v in clos_a):
148
+ out.append(f"{e}·distinct_from·{v}")
149
+ return out
150
+
151
+ def relating_edges_between(self, a: str, b: str, asserted_as_of: int | None = None) -> list[str]:
152
+ """Visible NON-identity, NON-containment, entity-valued edges relating
153
+ a's closure to b's (V2 §3a) — the soft "related ⇒ probably distinct"
154
+ signal (round-robin: any relating edge is evidence against identity).
155
+ Spans the lateral family and generic relations (`father_of`, `ally_of`,
156
+ …). Containment is the *hard* veto, handled separately; identity/meta
157
+ rows and `kind` are excluded. Membrane-clean."""
158
+ family = self._containment_family()
159
+ clos_a = self.closure(a, asserted_as_of)
160
+ clos_b = self.closure(b, asserted_as_of)
161
+ out: list[str] = []
162
+ for row in self._buffer.visible(asserted_as_of=asserted_as_of):
163
+ if row.value_type != "entity" or not isinstance(row.value, str):
164
+ continue
165
+ if row.attribute in family or row.attribute in _IDENTITY_ATTRS:
166
+ continue
167
+ if row.attribute in META_ATTRIBUTES or row.attribute in ("kind", "caused_by"):
168
+ continue # structural/meta edges are not domain relating edges
169
+ if row.entity.startswith("a:") or row.entity.startswith(ATTR_PREFIX):
170
+ continue
171
+ e, v = row.entity, row.value
172
+ if (e in clos_a and v in clos_b) or (e in clos_b and v in clos_a):
173
+ out.append(f"{e}·{row.attribute}·{v}")
174
+ return out
175
+
176
+ def durable_contradictions(self, a: str, b: str) -> list[str]:
177
+ """Shared attributes where BOTH sides hold present, DURABLE
178
+ (CONSTITUTIVE/DISPOSITIONAL), and CONTRADICTORY folded values — the
179
+ generalization of the kind veto to every standing fact (SHAPE-FIX-V1
180
+ Win 4; HD 089: a retrieval lead and a defense apprentice fused because
181
+ only `kind` was consulted). Two entities whose standing identities
182
+ contradict are probably two things: a soft distinctness signal, same
183
+ tier as a relating edge — auto-merge declines to a proposal; the host's
184
+ guarded `merge` remains available. Transient STATE differences (mood,
185
+ position) never trigger it. Descriptors `attr: va ≠ vb`."""
186
+ if self._fold_of is None or self._durability_of is None:
187
+ return []
188
+ family = self._containment_family()
189
+ clos_a, clos_b = self.closure(a), self.closure(b)
190
+
191
+ def authored_attrs(clos: set[str]) -> set[str]:
192
+ out = set()
193
+ for row in self._buffer.visible():
194
+ if row.entity not in clos:
195
+ continue
196
+ if row.attribute in ("name", "alias", "kind") \
197
+ or row.attribute in _IDENTITY_ATTRS \
198
+ or row.attribute in META_ATTRIBUTES \
199
+ or row.attribute in family:
200
+ continue
201
+ out.add(row.attribute)
202
+ return out
203
+
204
+ def norm(winner) -> object:
205
+ v = winner.value
206
+ if winner.value_type == "entity" and isinstance(v, str):
207
+ return self.resolve(v)
208
+ return v.strip().lower() if isinstance(v, str) else v
209
+
210
+ out: list[str] = []
211
+ for attr in sorted(authored_attrs(clos_a) & authored_attrs(clos_b)):
212
+ fa, fb = self._fold_of(a, attr), self._fold_of(b, attr)
213
+ if fa.winner is None or fb.winner is None:
214
+ continue
215
+ if norm(fa.winner) == norm(fb.winner):
216
+ continue
217
+ durable = {CONSTITUTIVE, DISPOSITIONAL}
218
+ if self._durability_of(fa.winner.id) in durable \
219
+ and self._durability_of(fb.winner.id) in durable:
220
+ out.append(f"{attr}: {fa.winner.value!r} vs {fb.winner.value!r}")
221
+ return out
222
+
223
+ def _kind_values(self, entity: str) -> set[str]:
224
+ """Lowercased folded-kind value(s) for an entity (winner + contested),
225
+ entity-resolved — the set a §3b non-distinctive-anchor check compares a
226
+ shared name against."""
227
+ fold = self._kind_of(entity) if self._kind_of is not None else None
228
+ if fold is None or fold.winner is None:
229
+ return set()
230
+
231
+ def _norm(value, value_type):
232
+ v = self.resolve(value) if value_type == "entity" and isinstance(value, str) else value
233
+ return str(v).strip().lower()
234
+
235
+ vals = {_norm(fold.winner.value, fold.winner.value_type)}
236
+ if fold.conflicted:
237
+ for cid in fold.conflicting:
238
+ row = self._buffer.get(cid)
239
+ if row is not None:
240
+ vals.add(_norm(row.value, row.value_type))
241
+ return vals
242
+
243
+ def containment_related(self, a: str, b: str, asserted_as_of: int | None = None) -> bool:
244
+ return bool(self.containment_block(a, b, asserted_as_of))
245
+
246
+ def merge(self, a: str, b: str, evidence: str) -> str | None:
247
+ """Identity merge, logged as an event (auditable, reversible by
248
+ retraction). Returns the merge event's entity id, or None if vetoed.
249
+
250
+ Non-bypassable containment veto (010): two entities related by a
251
+ containment/location edge are never the same thing (a container is
252
+ not its contents). The veto lives here so a direct/manual merge()
253
+ cannot bypass it — not only at the ingest same_as→maybe_same_as gate.
254
+ A vetoed pair is left distinct; the bad proposal stays unpromoted and
255
+ is repaired forward by retracting the offending edge."""
256
+ if self.containment_related(a, b):
257
+ logger.warning(
258
+ "merge vetoed: %s and %s are containment-related (a thing is "
259
+ "not what holds it); leaving distinct (%s)", a, b, evidence
260
+ )
261
+ return None
262
+ if self.distinct_block(a, b):
263
+ logger.warning(
264
+ "merge vetoed: %s and %s are marked distinct_from; leaving "
265
+ "distinct (%s)", a, b, evidence
266
+ )
267
+ return None
268
+ event_id = f"event:merge_{self._buffer.head() + 1}"
269
+ self._buffer.append(
270
+ entity=event_id, attribute="kind", value="identity_merge",
271
+ status="inferred", role=self._ingestor,
272
+ )
273
+ edge = self._buffer.append(
274
+ entity=a, attribute="same_as", value=b, value_type="entity",
275
+ status="inferred", role=self._ingestor,
276
+ )
277
+ self._buffer.append(
278
+ entity=edge.id, attribute="caused_by", value=event_id, value_type="entity",
279
+ status="inferred", role=self._ingestor,
280
+ )
281
+ self._buffer.append(
282
+ entity=event_id, attribute="evidence", value=evidence,
283
+ status="inferred", role=self._ingestor,
284
+ )
285
+ logger.info("merge %s == %s (%s)", a, b, evidence)
286
+ return event_id
287
+
288
+ def maybe_same_as(self, a: str, b: str, evidence: str) -> None:
289
+ """Ambiguity is represented, not forced: the edge survives until
290
+ a force collapses it (split/underdetermined anchors, §11)."""
291
+ edge = self._buffer.append(
292
+ entity=a, attribute="maybe_same_as", value=b, value_type="entity",
293
+ status="assumed", role=self._ingestor,
294
+ )
295
+ self._buffer.append(
296
+ entity=edge.id, attribute="evidence", value=evidence,
297
+ status="assumed", role=self._ingestor,
298
+ )
299
+
300
+ # ---------------------------------------------------------------- read
301
+
302
+ def resolve(self, entity: str, asserted_as_of: int | None = None) -> str:
303
+ """Canonical representative: the first-seen member of the entity's
304
+ same_as closure (stable across instances — first-seen is log order)."""
305
+ closure = self.closure(entity, asserted_as_of)
306
+ if len(closure) == 1:
307
+ return entity
308
+ first_seen: dict[str, int] = {}
309
+ for row in self._buffer.visible(asserted_as_of=asserted_as_of):
310
+ if row.entity in closure and row.entity not in first_seen:
311
+ first_seen[row.entity] = row.seq
312
+ if (
313
+ row.value_type == "entity"
314
+ and isinstance(row.value, str)
315
+ and row.value in closure
316
+ and row.value not in first_seen
317
+ ):
318
+ first_seen[row.value] = row.seq
319
+ return min(closure, key=lambda e: (first_seen.get(e, 1 << 62), e))
320
+
321
+ def closure(self, entity: str, asserted_as_of: int | None = None) -> set[str]:
322
+ edges: dict[str, set[str]] = {}
323
+ for row in self._buffer.visible(attribute="same_as", asserted_as_of=asserted_as_of):
324
+ if row.value_type == "entity":
325
+ edges.setdefault(row.entity, set()).add(row.value)
326
+ edges.setdefault(row.value, set()).add(row.entity)
327
+ out = {entity}
328
+ frontier = [entity]
329
+ while frontier:
330
+ for nxt in edges.get(frontier.pop(), ()):
331
+ if nxt not in out:
332
+ out.add(nxt)
333
+ frontier.append(nxt)
334
+ return out
335
+
336
+ def candidates(self, entity: str) -> set[str]:
337
+ """Open maybe_same_as edges touching this entity's closure."""
338
+ closure = self.closure(entity)
339
+ out = set()
340
+ for row in self._buffer.visible(attribute="maybe_same_as"):
341
+ if row.entity in closure:
342
+ out.add(row.value)
343
+ elif row.value in closure:
344
+ out.add(row.entity)
345
+ return out
346
+
347
+ def name_anchors(self, entity: str) -> set[str]:
348
+ """NAME-class anchors only (names + aliases, lowercased) — the
349
+ high-weight identity signal. Role/title tokens deliberately
350
+ excluded (036: a shared title register must never carry a merge)."""
351
+ closure = self.closure(entity)
352
+ out: set[str] = set()
353
+ for row in self._buffer.visible():
354
+ if row.entity in closure and row.attribute in ("name", "alias") \
355
+ and isinstance(row.value, str):
356
+ out.add(row.value.strip().lower())
357
+ return out
358
+
359
+ def typed_name_anchors(self, entity: str) -> set[tuple[str, str]]:
360
+ """NAME-class anchors as ``(attribute, normalized_text)`` (IDENTITY-
361
+ RECALL-V1 §3): like name_anchors but keeps the name-vs-alias kind so
362
+ the recall gate can weight a proper name above a casual alias.
363
+ ``normalized_text`` is ``strip().lower()`` (the grouping key)."""
364
+ closure = self.closure(entity)
365
+ out: set[tuple[str, str]] = set()
366
+ for row in self._buffer.visible():
367
+ if row.entity in closure and row.attribute in ("name", "alias") \
368
+ and isinstance(row.value, str):
369
+ out.add((row.attribute, row.value.strip().lower()))
370
+ return out
371
+
372
+ def _kind_state(self, a: str, b: str) -> str:
373
+ """Kind relation for the recall gate (§2): 'conflict' (a contested or
374
+ differing kind — never a merge basis), 'present_equal' (both folded
375
+ kinds present and equal), or 'noncommittal' (equal-or-absent)."""
376
+ if self._kind_of is None:
377
+ return "noncommittal"
378
+ ka, kb = self._kind_of(a), self._kind_of(b)
379
+ if (ka is not None and ka.conflicted) or (kb is not None and kb.conflicted):
380
+ return "conflict"
381
+
382
+ def _kind_value(fold):
383
+ if fold is None or fold.winner is None:
384
+ return None
385
+ v = fold.winner.value
386
+ # entity-valued kinds resolve through identity (§2): two kind
387
+ # entities later merged are the same kind, not a conflict.
388
+ if fold.winner.value_type == "entity" and isinstance(v, str):
389
+ return self.resolve(v)
390
+ return v
391
+
392
+ va, vb = _kind_value(ka), _kind_value(kb)
393
+ if va is not None and vb is not None:
394
+ return "present_equal" if va == vb else "conflict"
395
+ return "noncommittal"
396
+
397
+ def _mergeable(self, a: str, b: str) -> bool:
398
+ """The single AUTO-merge gate (used by promote + reconcile), now
399
+ precision-biased (V2 §3): the author individuates through structure and
400
+ the engine only auto-merges the obvious. Any distinctness signal
401
+ downgrades to a proposal — a shared proper `name` merges at any length
402
+ under non-conflicting kind, a shared `alias` merges only if specific
403
+ (>=2 content tokens) with present-equal kind, BUT not if a relating edge
404
+ joins them or the shared anchor is just the type word."""
405
+ if self.resolve(a) == self.resolve(b):
406
+ return False
407
+ if self.containment_related(a, b): # hard: a thing is not its container
408
+ return False
409
+ if self.distinct_block(a, b): # hard: explicitly marked distinct (§1)
410
+ return False
411
+ if self.relating_edges_between(a, b): # soft: related ⇒ probably distinct (§3a)
412
+ return False
413
+ if self.durable_contradictions(a, b): # soft: standing facts contradict (Win 4)
414
+ return False
415
+ ta, tb = self.typed_name_anchors(a), self.typed_name_anchors(b)
416
+ shared = {t for (_, t) in ta} & {t for (_, t) in tb}
417
+ if not shared:
418
+ return False
419
+ kind = self._kind_state(a, b)
420
+ if kind == "conflict":
421
+ return False
422
+ # §3b: a shared anchor whose text is just the entity's kind value (the
423
+ # *type word*, e.g. name "bedroom" on kind bedroom) is non-distinctive —
424
+ # it does not drive an auto-merge.
425
+ kinds = self._kind_values(a) | self._kind_values(b)
426
+ for text in shared:
427
+ if text in kinds:
428
+ continue # non-distinctive type-word anchor
429
+ name_strength = ("name", text) in ta or ("name", text) in tb
430
+ if name_strength:
431
+ return True # non-conflicting kind already ensured
432
+ if kind == "present_equal" and len(_content_tokens(text)) >= 2:
433
+ return True
434
+ return False
435
+
436
+ def name_collisions(self, frame: str = CANON,
437
+ valid_as_of: float | None = None) -> list[dict]:
438
+ """Fidelity read (INGESTION-FIDELITY-V1): normalized name/alias anchors
439
+ carried by >1 distinct resolved entity — the coreference-fragmentation
440
+ metric. Grouping is frame/as-of-scoped; per-pair status is computed from
441
+ the identity predicates directly (never the global list-builders, which
442
+ pre-filter hard-blocked/distinct pairs). A group is `live` (a real
443
+ fragmentation the host re-extracts) iff any pair is
444
+ unlinked/auto_declined/typing_slip; correlated/hard-blocked-only groups
445
+ are reported for visibility but flagged resolved. Zero writes."""
446
+ text_to_reps: dict[str, set[str]] = {}
447
+ for row in self._buffer.visible(frame=frame, valid_as_of=valid_as_of):
448
+ if row.attribute not in ("name", "alias") or not isinstance(row.value, str):
449
+ continue
450
+ if row.entity.startswith("a:") or row.entity.startswith(ATTR_PREFIX):
451
+ continue
452
+ text_to_reps.setdefault(
453
+ row.value.strip().lower(), set()).add(self.resolve(row.entity))
454
+
455
+ out: list[dict] = []
456
+ for anchor in sorted(text_to_reps):
457
+ reps = sorted(text_to_reps[anchor])
458
+ if len(reps) < 2:
459
+ continue
460
+ pairs: list[dict] = []
461
+ live = False
462
+ for i in range(len(reps)):
463
+ for j in range(i + 1, len(reps)):
464
+ a, b = reps[i], reps[j]
465
+ status, reason = self._collision_status(a, b, valid_as_of, frame)
466
+ entry = {"a": a, "b": b, "status": status}
467
+ if reason is not None:
468
+ entry["reason"] = reason
469
+ pairs.append(entry)
470
+ if status in ("unlinked", "auto_declined", "typing_slip"):
471
+ live = True
472
+ # Folded kind per entity (parallel to `entities`) — the host weights
473
+ # cross-kind collisions (person↔place) highest, and the folded kind
474
+ # is truth where the id namespace can lie (a mistyped id). INGESTION-
475
+ # FIDELITY-V1 follow-on (HD 107).
476
+ kinds = [self._kind_label(e) for e in reps]
477
+ out.append({"anchor": anchor, "entities": reps, "kinds": kinds,
478
+ "pairs": pairs, "live": live})
479
+ return out
480
+
481
+ def _collision_status(self, a: str, b: str, valid_as_of: float | None,
482
+ frame: str) -> tuple[str, str | None]:
483
+ """Why a same-anchor pair isn't one entity — from the per-pair predicates
484
+ in `_mergeable`'s own order (INGESTION-FIDELITY-V1)."""
485
+ if b in self.correlation_set(a, valid_as_of=valid_as_of, frame=frame):
486
+ return "correlated", None # an aka facet — not a gap
487
+ if self.containment_block(a, b):
488
+ return "hard_blocked", "containment"
489
+ if self.distinct_block(a, b):
490
+ return "hard_blocked", "distinct_from"
491
+ if self._kind_state(a, b) == "conflict" and (
492
+ self._slip_asymmetry(a, b) or self._slip_asymmetry(b, a)):
493
+ return "typing_slip", None # host fixes with retype(absorb=)
494
+ if self._has_proposal(a, b): # a real open maybe_same_as edge
495
+ code = self._decline_context(a, b)["code"]
496
+ if code is not None:
497
+ return "auto_declined", code # a logged proposal the gate declined
498
+ return "unlinked", None # no edge yet — host runs reconcile
499
+
500
+ def _has_proposal(self, a: str, b: str) -> bool:
501
+ """A visible maybe_same_as already relates a's closure to b's."""
502
+ clos_a, clos_b = self.closure(a), self.closure(b)
503
+ for row in self._buffer.visible(attribute="maybe_same_as"):
504
+ if not isinstance(row.value, str):
505
+ continue
506
+ if (row.entity in clos_a and row.value in clos_b) or \
507
+ (row.entity in clos_b and row.value in clos_a):
508
+ return True
509
+ return False
510
+
511
+ def promote_identity_proposals(self) -> int:
512
+ """Whole-world promotion pass (036): a maybe_same_as proposal promotes
513
+ to a logged merge iff the unified gate `_mergeable` holds (proper-name
514
+ or specific-alias-with-equal-kind, never a casual single-token alias,
515
+ never a containment pair). Otherwise it stays a proposal for explicit
516
+ host confirmation (#31). Returns merges performed."""
517
+ promoted = 0
518
+ for row in list(self._buffer.visible(attribute="maybe_same_as")):
519
+ a, b = row.entity, row.value
520
+ if not isinstance(b, str):
521
+ continue
522
+ if self._mergeable(a, b):
523
+ ev = self._buffer.visible(entity=row.id, attribute="evidence")
524
+ self.merge(a, b, evidence=(
525
+ f"promoted from {row.id}: gated coreference"
526
+ + (f"; proposal evidence: {ev[0].value}" if ev else "")))
527
+ promoted += 1
528
+ if promoted:
529
+ logger.info("identity promotion: %d merge(s)", promoted)
530
+ return promoted
531
+
532
+ def reconcile(self) -> int:
533
+ """Global coreference finalize pass (IDENTITY-RECALL-V1): discover
534
+ cross-closure candidates by shared NAME-class text — the cross-chunk
535
+ coreferents that never co-occurred in one extraction pass and so never
536
+ got a within-chunk proposal — and resolve them through the same gate.
537
+ Mergeable candidates merge; the rest are recorded as (deduped)
538
+ maybe_same_as proposals for host adjudication. Then promote any
539
+ pre-existing within-chunk proposals through the same gate. Idempotent.
540
+ Returns merges performed."""
541
+ text_to_closures: dict[str, set[str]] = {}
542
+ for row in self._buffer.visible():
543
+ if row.attribute not in ("name", "alias") or not isinstance(row.value, str):
544
+ continue
545
+ if row.entity.startswith("a:"):
546
+ continue
547
+ rep = self.resolve(row.entity)
548
+ text_to_closures.setdefault(row.value.strip().lower(), set()).add(rep)
549
+
550
+ merged = 0
551
+ for _text, reps in text_to_closures.items():
552
+ members = sorted(reps)
553
+ if len(members) < 2:
554
+ continue
555
+ for i in range(len(members)):
556
+ for j in range(i + 1, len(members)):
557
+ a, b = members[i], members[j]
558
+ if self.resolve(a) == self.resolve(b):
559
+ continue # collapsed by an earlier merge this pass
560
+ if self._mergeable(a, b):
561
+ self.merge(a, b, evidence="reconcile: gated shared-anchor coreference")
562
+ merged += 1
563
+ elif (not self.containment_related(a, b)
564
+ and not self.distinct_block(a, b)
565
+ and not self._has_proposal(a, b)):
566
+ # soft-declined (relating edge / non-distinctive anchor /
567
+ # kind) → adjudicable proposal; HARD-blocked (containment /
568
+ # distinct_from) pairs are settled, never re-proposed.
569
+ self.maybe_same_as(a, b, evidence="reconcile: shared anchor, gate declined (adjudicate)")
570
+
571
+ merged += self.promote_identity_proposals()
572
+ if merged:
573
+ logger.info("identity reconcile: %d merge(s)", merged)
574
+ return merged
575
+
576
+ # ------------------------------------ host reconciliation surface (#31)
577
+
578
+ def guarded_merge(self, a: str, b: str, evidence: str) -> dict:
579
+ """Host-authoritative merge through the guarded path (MERGE-RECONCILE-
580
+ VERB-V1/V2). Skips the soft discriminativeness heuristic (the host has
581
+ judged) but NOT the hard vetoes — containment AND `distinct_from`.
582
+ Returns a Receipt."""
583
+ ra = self.resolve(a)
584
+ if ra == self.resolve(b):
585
+ return self._receipt("noop_already_merged", ra)
586
+ blocks = self.containment_block(a, b)
587
+ if blocks:
588
+ return self._receipt("vetoed", ra, reason="containment", blocking_edges=blocks)
589
+ dblocks = self.distinct_block(a, b)
590
+ if dblocks:
591
+ return self._receipt("vetoed", ra, reason="distinct_from", blocking_edges=dblocks)
592
+ event_id = self.merge(a, b, evidence)
593
+ if event_id is None: # defensive: merge() re-checks both hard vetoes
594
+ return self._receipt(
595
+ "vetoed", ra, reason="containment_or_distinct",
596
+ blocking_edges=self.containment_block(a, b) + self.distinct_block(a, b),
597
+ )
598
+ return self._receipt("merged", self.resolve(a), merge_event_id=event_id)
599
+
600
+ def reject(self, a: str, b: str) -> dict:
601
+ """Assert `distinct_from(a, b)` — the sticky "these are definitively
602
+ different" call (V2 §2), complement of confirm/merge. `reconcile`/
603
+ `proposals` never re-surface the pair after this."""
604
+ ra = self.resolve(a)
605
+ if ra == self.resolve(b):
606
+ # contradiction: can't be distinct AND already merged — name the
607
+ # same_as / merge-event ids to retract first; write nothing.
608
+ return self._receipt(
609
+ "conflict_already_merged", ra, reason="already_merged",
610
+ blocking_edges=self._same_as_path_ids(a, b),
611
+ )
612
+ if self.distinct_block(a, b):
613
+ return self._receipt("noop_already_distinct", ra)
614
+ self._buffer.append(
615
+ entity=ra, attribute="distinct_from", value=self.resolve(b),
616
+ value_type="entity", status="stated", role=self._ingestor,
617
+ )
618
+ return self._receipt("rejected", ra)
619
+
620
+ def _same_as_path_ids(self, a: str, b: str) -> list[str]:
621
+ """Visible `same_as` assertion ids + their merge-event ids within the
622
+ (now shared) closure of a and b — what the host retracts to undo the
623
+ merge before asserting distinctness (V2 §2)."""
624
+ clos = self.closure(a)
625
+ out: list[str] = []
626
+ for row in self._buffer.visible(attribute="same_as"):
627
+ if (row.entity in clos and isinstance(row.value, str) and row.value in clos):
628
+ out.append(row.id)
629
+ for m in self._buffer.visible(entity=row.id, attribute="caused_by", value_type="entity"):
630
+ if isinstance(m.value, str):
631
+ out.append(m.value)
632
+ return out
633
+
634
+ def confirm(self, a: str, b: str) -> dict:
635
+ """Promote an existing maybe_same_as proposal through the guarded path.
636
+ Already merged => `noop_already_merged`; otherwise a missing live
637
+ proposal => `no_proposal` (never a silent merge — keeps confirm=
638
+ promote-judged honest vs merge=assert-new). C-014 / Codex post-impl."""
639
+ ra = self.resolve(a)
640
+ if ra == self.resolve(b):
641
+ return self._receipt("noop_already_merged", ra)
642
+ if not self._has_proposal(a, b):
643
+ return self._receipt("no_proposal", ra)
644
+ ev = None
645
+ for row in self._buffer.visible(attribute="maybe_same_as"):
646
+ if not isinstance(row.value, str):
647
+ continue
648
+ clos_a, clos_b = self.closure(a), self.closure(b)
649
+ if (row.entity in clos_a and row.value in clos_b) or \
650
+ (row.entity in clos_b and row.value in clos_a):
651
+ evr = self._buffer.visible(entity=row.id, attribute="evidence")
652
+ ev = evr[0].value if evr else None
653
+ break
654
+ return self.guarded_merge(a, b, evidence=f"confirmed proposal: {ev or '(no evidence)'}")
655
+
656
+ # ------------------------------------------ SHAPE-FIX-V1 (buckets 1 + 2)
657
+
658
+ def _distinctive_anchor_tokens(self, entity: str) -> set[str]:
659
+ """Content tokens of the entity's name-class anchors, minus type-word
660
+ anchors (an anchor that IS the kind value carries no identity)."""
661
+ kinds = self._kind_values(entity)
662
+ out: set[str] = set()
663
+ for _attr, text in self.typed_name_anchors(entity):
664
+ if text in kinds:
665
+ continue
666
+ out.update(_content_tokens(text))
667
+ return out
668
+
669
+ def _anchor_subsumed(self, a: str, b: str) -> bool:
670
+ """The decisive bucket-1 shape: one side's ENTIRE distinctive
671
+ anchor-token set is contained in the other's — a pure fragment with no
672
+ independent identity signal (`tovin` ⊆ {tovin, beck}). Two sides each
673
+ holding distinctive non-shared tokens are two individuated things
674
+ sharing a token — never decisive."""
675
+ ta, tb = self._distinctive_anchor_tokens(a), self._distinctive_anchor_tokens(b)
676
+ if not ta or not tb:
677
+ return False
678
+ return ta <= tb or tb <= ta
679
+
680
+ def adjudicate_deferred(self) -> dict:
681
+ """Merge the structurally-DECISIVE subset of live maybe_same_as
682
+ proposals (SHAPE-FIX-V1 Win 1); return receipts + the residue for host
683
+ adjudication. Opt-in — `reconcile()` is unchanged. Decisive = no hard
684
+ block, zero relating edges, no `aka` correlation (non-collapsing by
685
+ design), kind not in conflict, and anchor subsumption. Deterministic,
686
+ zero model calls, idempotent."""
687
+ merged: list[dict] = []
688
+ seen: set[tuple[str, str]] = set()
689
+ for row in list(self._buffer.visible(attribute="maybe_same_as")):
690
+ if not isinstance(row.value, str):
691
+ continue
692
+ ra, rb = self.resolve(row.entity), self.resolve(row.value)
693
+ if ra == rb:
694
+ continue
695
+ key = tuple(sorted([ra, rb]))
696
+ if key in seen:
697
+ continue
698
+ seen.add(key)
699
+ if self.containment_block(ra, rb) or self.distinct_block(ra, rb):
700
+ continue
701
+ if self.relating_edges_between(ra, rb):
702
+ continue
703
+ if set(self.closure(rb)) & self.correlation_set(ra):
704
+ continue # correlated facets are two things by design (Cx #3)
705
+ if self._kind_state(ra, rb) == "conflict":
706
+ continue
707
+ if self.durable_contradictions(ra, rb):
708
+ continue # standing facts contradict (Win 4) — host judges
709
+ if not self._anchor_subsumed(ra, rb):
710
+ continue
711
+ event_id = self.merge(
712
+ ra, rb, evidence="adjudicate_deferred: fragment subsumption"
713
+ )
714
+ if event_id is not None:
715
+ merged.append(self._receipt(
716
+ "merged", self.resolve(ra), merge_event_id=event_id))
717
+ if merged:
718
+ logger.info("adjudicate_deferred: %d merge(s)", len(merged))
719
+ return {"merged": merged, "residue": self.enumerate_proposals()}
720
+
721
+ def _containment_rows_between(self, a: str, b: str) -> list[Assertion]:
722
+ """The visible containment ROWS relating a's closure to b's — the
723
+ would-be-self-edge artifacts a Case-B retype retracts (row form of
724
+ `containment_block`, which serves descriptors)."""
725
+ family = self._containment_family()
726
+ clos_a, clos_b = self.closure(a), self.closure(b)
727
+ out = []
728
+ for row in self._buffer.visible():
729
+ if row.attribute not in family or row.value_type != "entity":
730
+ continue
731
+ if not isinstance(row.value, str):
732
+ continue
733
+ e, v = row.entity, row.value
734
+ if (e in clos_a and v in clos_b) or (e in clos_b and v in clos_a):
735
+ out.append(row)
736
+ return out
737
+
738
+ def _outgoing_domain_rows(self, entity: str) -> list[Assertion]:
739
+ """Domain facts authored ON the entity's closure — beyond naming, kind,
740
+ identity, and meta. The slip signature's bare-ness measure: incoming
741
+ children do NOT count (they are mis-bound rows about OTHER entities and
742
+ re-point correctly after the absorb)."""
743
+ clos = self.closure(entity)
744
+ out = []
745
+ for row in self._buffer.visible():
746
+ if row.entity not in clos:
747
+ continue
748
+ if row.attribute in ("name", "alias", "kind") \
749
+ or row.attribute in _IDENTITY_ATTRS \
750
+ or row.attribute in META_ATTRIBUTES:
751
+ continue
752
+ out.append(row)
753
+ return out
754
+
755
+ def _incoming_children(self, entity: str) -> list[Assertion]:
756
+ """Visible containment rows valued INTO the entity's closure."""
757
+ family = self._containment_family()
758
+ clos = {self.resolve(c) for c in self.closure(entity)}
759
+ return [
760
+ row for row in self._buffer.visible()
761
+ if row.attribute in family and row.value_type == "entity"
762
+ and isinstance(row.value, str) and self.resolve(row.value) in clos
763
+ ]
764
+
765
+ def _slip_asymmetry(self, spurious: str, target: str) -> bool:
766
+ """The Case-B slip signature's structural asymmetry: the spurious side
767
+ authored nothing of its own (outgoing-bare — where the inter-closure
768
+ containment ARTIFACTS don't count: `obj:street in place:street` is
769
+ evidence OF the slip, not independent structure); the target side is
770
+ real (outgoing domain facts OR incoming children). A side with any
771
+ non-artifact structure of its own — the locked chest — never reads as
772
+ spurious, so retype can't bypass the merge veto."""
773
+ artifact_ids = {r.id for r in self._containment_rows_between(spurious, target)}
774
+ own = [r for r in self._outgoing_domain_rows(spurious)
775
+ if r.id not in artifact_ids]
776
+ if own:
777
+ return False
778
+ return bool(self._outgoing_domain_rows(target)
779
+ or self._incoming_children(target))
780
+
781
+ def typing_conflicts(self) -> list[dict]:
782
+ """Read-only surfacing of typing slips (SHAPE-FIX-V1, Cx-mandated):
783
+ same-anchor cross-kind pairs carrying the slip signature. Proposals
784
+ cannot show these — `reconcile()` never re-proposes hard-blocked pairs —
785
+ so without this read the slips are invisible. Zero writes, zero model
786
+ calls; the host adjudicates each with `retype(...)` or leaves it."""
787
+ text_to_reps: dict[str, set[str]] = {}
788
+ for row in self._buffer.visible():
789
+ if row.attribute not in ("name", "alias") or not isinstance(row.value, str):
790
+ continue
791
+ if row.entity.startswith("a:") or row.entity.startswith(ATTR_PREFIX):
792
+ continue
793
+ text_to_reps.setdefault(
794
+ row.value.strip().lower(), set()).add(self.resolve(row.entity))
795
+ out: list[dict] = []
796
+ seen: set[tuple[str, str]] = set()
797
+ for text, reps in text_to_reps.items():
798
+ members = sorted(reps)
799
+ for i in range(len(members)):
800
+ for j in range(i + 1, len(members)):
801
+ a, b = members[i], members[j]
802
+ if self.resolve(a) == self.resolve(b):
803
+ continue
804
+ key = tuple(sorted([a, b]))
805
+ if key in seen:
806
+ continue
807
+ if self._kind_state(a, b) != "conflict":
808
+ continue
809
+ if self._slip_asymmetry(a, b):
810
+ spurious, target = a, b
811
+ elif self._slip_asymmetry(b, a):
812
+ spurious, target = b, a
813
+ else:
814
+ continue
815
+ seen.add(key)
816
+ artifact_ids = {
817
+ r.id for r in self._containment_rows_between(spurious, target)
818
+ }
819
+ out.append({
820
+ "spurious": spurious,
821
+ "target": target,
822
+ "kinds": [sorted(self._kind_values(spurious)),
823
+ sorted(self._kind_values(target))],
824
+ "shared_anchor": text,
825
+ # The structural evidence the signature rests on — what
826
+ # makes one side spurious and the other real.
827
+ "asymmetry": {
828
+ "spurious_own_facts": len([
829
+ r for r in self._outgoing_domain_rows(spurious)
830
+ if r.id not in artifact_ids]),
831
+ "target_own_facts": len(
832
+ self._outgoing_domain_rows(target)),
833
+ "target_children": len(
834
+ self._incoming_children(target)),
835
+ },
836
+ "artifact_edges": self.containment_block(spurious, target),
837
+ })
838
+ return sorted(out, key=lambda d: (d["spurious"], d["target"]))
839
+
840
+ def retype(self, entity: str, to_kind: str, evidence: str,
841
+ absorb: str | None = None) -> dict:
842
+ """Typing correction — DISTINCT from merge (SHAPE-FIX-V1 Win 2). The
843
+ containment veto correctly blocks merges; it must not block fixing a
844
+ kind slip. Append-only: wrong kind rows are RETRACTED (meta-assertions),
845
+ the correct kind appended and classified.
846
+
847
+ Case A (absorb=None): correct one mistyped entity's kind.
848
+ Case B (absorb=target): the entity is a spurious duplicate of `absorb`
849
+ at the wrong kind — verify the slip signature (shared anchor + kind
850
+ conflict + structural asymmetry), retract the slip kind row(s) and ONLY
851
+ the direct inter-closure containment artifacts (would-be self-edges;
852
+ incoming child containment is preserved untouched), then merge through
853
+ the guarded path. A non-slip invocation is `vetoed_not_a_slip` — retype
854
+ is never a veto bypass."""
855
+ if self._retract is None:
856
+ raise RuntimeError("no retraction authority wired for retype")
857
+ re_ = self.resolve(entity)
858
+ retracted: list[str] = []
859
+
860
+ def _retract_kind_rows(target_entity: str) -> None:
861
+ clos = self.closure(target_entity)
862
+ for row in self._buffer.visible(attribute="kind"):
863
+ if row.entity not in clos:
864
+ continue
865
+ v = row.value
866
+ if isinstance(v, str) and v.strip().lower() == str(to_kind).strip().lower() \
867
+ and absorb is None:
868
+ continue # already-correct row survives in Case A
869
+ self._retract(row.id, f"retype: {evidence}")
870
+ retracted.append(row.id)
871
+
872
+ if absorb is None:
873
+ kinds = self._kind_values(re_)
874
+ if kinds == {str(to_kind).strip().lower()}:
875
+ receipt = self._receipt("noop_already_kind", re_)
876
+ receipt["retracted"] = []
877
+ return receipt
878
+ _retract_kind_rows(re_)
879
+ row = self._buffer.append(
880
+ entity=re_, attribute="kind", value=to_kind,
881
+ status="stated", role=self._ingestor,
882
+ )
883
+ self._buffer.append(
884
+ entity=row.id, attribute="evidence", value=evidence,
885
+ status="stated", role=self._ingestor,
886
+ )
887
+ if self._classify_rows is not None:
888
+ self._classify_rows([row])
889
+ receipt = self._receipt("retyped", re_)
890
+ receipt["retracted"] = retracted
891
+ receipt["kind_assertion_id"] = row.id
892
+ return receipt
893
+
894
+ rt = self.resolve(absorb)
895
+ if re_ == rt:
896
+ receipt = self._receipt("noop_already_merged", re_)
897
+ receipt["retracted"] = []
898
+ return receipt
899
+ if self.distinct_block(re_, rt):
900
+ receipt = self._receipt(
901
+ "vetoed", re_, reason="distinct_from",
902
+ blocking_edges=self.distinct_block(re_, rt),
903
+ )
904
+ receipt["retracted"] = []
905
+ return receipt
906
+ shared = ({t for _, t in self.typed_name_anchors(re_)}
907
+ & {t for _, t in self.typed_name_anchors(rt)})
908
+ if (not shared or self._kind_state(re_, rt) != "conflict"
909
+ or not self._slip_asymmetry(re_, rt)):
910
+ receipt = self._receipt("vetoed_not_a_slip", re_)
911
+ receipt["retracted"] = []
912
+ return receipt
913
+ _retract_kind_rows(re_)
914
+ for row in self._containment_rows_between(re_, rt):
915
+ self._retract(row.id, f"retype artifact edge: {evidence}")
916
+ retracted.append(row.id)
917
+ result = self.guarded_merge(re_, rt, evidence=f"retype absorb: {evidence}")
918
+ result["retracted"] = retracted
919
+ return result
920
+
921
+ @staticmethod
922
+ def _receipt(outcome, canonical_id, merge_event_id=None, reason=None,
923
+ blocking_edges=None) -> dict:
924
+ return {
925
+ "outcome": outcome,
926
+ "canonical_id": canonical_id,
927
+ "merge_event_id": merge_event_id,
928
+ "reason": reason,
929
+ "blocking_edges": list(blocking_edges) if blocking_edges else [],
930
+ }
931
+
932
+ def _kind_label(self, entity: str) -> str | None:
933
+ """The folded-kind label for one entity: the winner's value, or the
934
+ '/'-joined value SET when the kind fold is contested (so a contested
935
+ side never hides a same-kind overlap — C-015). None if kind absent."""
936
+ fold = self._kind_of(entity) if self._kind_of is not None else None
937
+ if fold is None or fold.winner is None:
938
+ return None
939
+
940
+ def _norm(value, value_type):
941
+ return self.resolve(value) if value_type == "entity" and isinstance(value, str) else value
942
+
943
+ vals = {str(_norm(fold.winner.value, fold.winner.value_type))}
944
+ if fold.conflicted:
945
+ for cid in fold.conflicting:
946
+ row = self._buffer.get(cid)
947
+ if row is not None:
948
+ vals.add(str(_norm(row.value, row.value_type)))
949
+ return "/".join(sorted(vals))
950
+
951
+ def _kind_pair(self, a: str, b: str) -> str | None:
952
+ """The sorted folded-kind pair for a kind_conflict reason, e.g.
953
+ 'object↔place' or (contested) 'narrator/person↔person' (C-014/015).
954
+ The folded kind is the *agnostic* plausibility signal — same-kind is a
955
+ confirm candidate, different-kind a likely reject — without the engine
956
+ parsing host id namespaces."""
957
+ la, lb = self._kind_label(a), self._kind_label(b)
958
+ if la is None or lb is None:
959
+ return None
960
+ return "↔".join(sorted([la, lb]))
961
+
962
+ # -------------------------------------- structured triage (TRIAGE-CONTEXT-V1)
963
+
964
+ def _relation_family(self, attribute: str) -> str:
965
+ """Declared structural relation family of an attribute (containment /
966
+ lateral / none), with a membership fallback when semantics is unwired."""
967
+ if self._semantics is not None:
968
+ return self._semantics.semantics(attribute).relation_family
969
+ if attribute in self._containment_family():
970
+ return "containment"
971
+ if attribute in ("connects_to", "adjacent_to"):
972
+ return "lateral"
973
+ return "none"
974
+
975
+ def _relating_rows_between(self, a: str, b: str) -> list[dict]:
976
+ """All relating edges between a's closure and b's — containment INCLUDED
977
+ — as `{attribute, relation_family, assertion_id}`. The decisive triage
978
+ evidence (round-robin: any relating edge is evidence against identity).
979
+ Excludes identity/meta/kind/caused_by edges and a:/attr: subjects."""
980
+ clos_a, clos_b = self.closure(a), self.closure(b)
981
+ out: list[dict] = []
982
+ for row in self._buffer.visible():
983
+ if row.value_type != "entity" or not isinstance(row.value, str):
984
+ continue
985
+ if row.attribute in _IDENTITY_ATTRS or row.attribute in META_ATTRIBUTES:
986
+ continue
987
+ if row.attribute in ("kind", "caused_by"):
988
+ continue
989
+ if row.entity.startswith("a:") or row.entity.startswith(ATTR_PREFIX):
990
+ continue
991
+ e, v = row.entity, row.value
992
+ if (e in clos_a and v in clos_b) or (e in clos_b and v in clos_a):
993
+ out.append({
994
+ "attribute": row.attribute,
995
+ "relation_family": self._relation_family(row.attribute),
996
+ "assertion_id": row.id,
997
+ })
998
+ return out
999
+
1000
+ def _kind_context(self, entity: str) -> dict:
1001
+ """Per-side kind context for triage: `{entity, value, conflicted}`."""
1002
+ fold = self._kind_of(entity) if self._kind_of is not None else None
1003
+ if fold is None or fold.winner is None:
1004
+ return {"entity": self.resolve(entity), "value": None, "conflicted": False}
1005
+ v = fold.winner.value
1006
+ if fold.winner.value_type == "entity" and isinstance(v, str):
1007
+ v = self.resolve(v)
1008
+ return {"entity": self.resolve(entity), "value": v, "conflicted": bool(fold.conflicted)}
1009
+
1010
+ def _candidate_bindings(self, a: str, b: str) -> list[str]:
1011
+ """All visible `maybe_same_as` assertion ids relating the closures
1012
+ (live bindings are not unique — plural, Codex r1)."""
1013
+ clos_a, clos_b = self.closure(a), self.closure(b)
1014
+ out: list[str] = []
1015
+ for row in self._buffer.visible(attribute="maybe_same_as"):
1016
+ if not isinstance(row.value, str):
1017
+ continue
1018
+ if (row.entity in clos_a and row.value in clos_b) or \
1019
+ (row.entity in clos_b and row.value in clos_a):
1020
+ out.append(row.id)
1021
+ return out
1022
+
1023
+ def _decline_context(self, a: str, b: str) -> dict:
1024
+ """The single source of truth for why the auto-gate did not merge a
1025
+ proposal — `code` in the exact order `_mergeable` fails, plus the
1026
+ structured evidence. `decline_reason()` formats its string from this."""
1027
+ related = self._relating_rows_between(a, b)
1028
+ containment = [r for r in related if r["relation_family"] == "containment"]
1029
+ ta, tb = self.typed_name_anchors(a), self.typed_name_anchors(b)
1030
+ shared = {t for (_, t) in ta} & {t for (_, t) in tb}
1031
+ kind = self._kind_state(a, b)
1032
+
1033
+ contradictions = self.durable_contradictions(a, b)
1034
+ if containment:
1035
+ code = "containment"
1036
+ elif any(r["relation_family"] != "containment" for r in related):
1037
+ code = "relating_edge"
1038
+ elif contradictions:
1039
+ code = "durable_contradiction"
1040
+ elif not shared:
1041
+ code = "no_shared_anchor"
1042
+ elif kind == "conflict":
1043
+ code = "kind_conflict"
1044
+ else:
1045
+ kinds = self._kind_values(a) | self._kind_values(b)
1046
+ distinctive = [t for t in shared if t not in kinds]
1047
+ name_texts = ({t for (attr, t) in ta if attr == "name"}
1048
+ | {t for (attr, t) in tb if attr == "name"})
1049
+ if any(t in name_texts for t in distinctive):
1050
+ code = None # a distinctive name merges at any length (mirror _mergeable)
1051
+ elif not distinctive:
1052
+ code = "non_distinctive"
1053
+ elif not any(len(_content_tokens(t)) >= 2 for t in distinctive):
1054
+ code = "alias_not_specific"
1055
+ elif kind != "present_equal":
1056
+ code = "kind_absent"
1057
+ else:
1058
+ code = None # specific alias + present-equal kind would have merged
1059
+ return {
1060
+ "code": code,
1061
+ "kinds": [self._kind_context(a), self._kind_context(b)],
1062
+ "related_rows": related,
1063
+ "durable_contradictions": contradictions,
1064
+ "candidate_bindings": self._candidate_bindings(a, b),
1065
+ }
1066
+
1067
+ def decline_reason(self, a: str, b: str) -> str | None:
1068
+ """Display/back-compat string, formatted from `_decline_context`'s code
1069
+ so string and struct never diverge (recomputed, never stored)."""
1070
+ return self._format_decline(a, b, self._decline_context(a, b))
1071
+
1072
+ def _format_decline(self, a: str, b: str, ctx: dict) -> str | None:
1073
+ code = ctx["code"]
1074
+ if code is None:
1075
+ return None
1076
+ if code == "kind_conflict":
1077
+ pair = self._kind_pair(a, b)
1078
+ return f"kind_conflict: {pair}" if pair else "kind_conflict: contested"
1079
+ if code == "relating_edge":
1080
+ attrs = sorted({r["attribute"] for r in ctx["related_rows"]
1081
+ if r["relation_family"] != "containment"})
1082
+ return f"relating_edge: {attrs[0]}" if attrs else "relating_edge"
1083
+ if code == "durable_contradiction":
1084
+ cs = ctx.get("durable_contradictions") or []
1085
+ return f"durable_contradiction: {cs[0]}" if cs else code
1086
+ return code
1087
+
1088
+ def enumerate_proposals(self) -> list[dict]:
1089
+ """Visible un-promoted maybe_same_as as adjudicable proposals, each
1090
+ with its recomputed `auto_decline_reason`. De-duplicated by closure
1091
+ pair; stale (already-merged) proposals are skipped."""
1092
+ out: list[dict] = []
1093
+ seen: set[tuple[str, str]] = set()
1094
+ for row in self._buffer.visible(attribute="maybe_same_as"):
1095
+ if not isinstance(row.value, str):
1096
+ continue
1097
+ ra, rb = self.resolve(row.entity), self.resolve(row.value)
1098
+ if ra == rb:
1099
+ continue # collapsed since proposed
1100
+ if self.distinct_block(ra, rb):
1101
+ continue # settled distinct (rejected) — never re-surface (V2 §2)
1102
+ key = tuple(sorted([ra, rb]))
1103
+ if key in seen:
1104
+ continue
1105
+ seen.add(key)
1106
+ evr = self._buffer.visible(entity=row.id, attribute="evidence")
1107
+ ctx = self._decline_context(ra, rb)
1108
+ out.append({
1109
+ "a": ra, "b": rb,
1110
+ "evidence": evr[0].value if evr else None,
1111
+ "auto_decline_reason": self._format_decline(ra, rb, ctx), # display/back-compat
1112
+ "auto_decline": ctx, # structured machine surface
1113
+ })
1114
+ return out
1115
+
1116
+ def by_alias(self, alias: str, asserted_as_of: int | None = None) -> set[str]:
1117
+ needle = alias.strip().lower()
1118
+ hits = set()
1119
+ for row in self._buffer.visible(attribute="alias", asserted_as_of=asserted_as_of):
1120
+ if row.value == needle:
1121
+ hits.add(self.resolve(row.entity, asserted_as_of))
1122
+ # Names assert identity too: check 'name' rows.
1123
+ for row in self._buffer.visible(attribute="name", asserted_as_of=asserted_as_of):
1124
+ if isinstance(row.value, str) and row.value.strip().lower() == needle:
1125
+ hits.add(self.resolve(row.entity, asserted_as_of))
1126
+ return hits
1127
+
1128
+ # ----------------------------------- AKA-CORRELATION-V1 (the third lane)
1129
+ # `aka` is the NON-collapsing identity relation — between `same_as`
1130
+ # (collapse) and `distinct_from` (separate). It NEVER enters resolve()/
1131
+ # closure() (those read `same_as` only) and NEVER folds by default. It is
1132
+ # read only through the explicit, valid-time-gated walkers below.
1133
+
1134
+ def correlation_set(
1135
+ self,
1136
+ entity: str,
1137
+ valid_as_of: float | None = None,
1138
+ asserted_as_of: int | None = None,
1139
+ frame: str = CANON,
1140
+ ) -> set[str]:
1141
+ """The connected component of `entity` over visible `aka` edges, each
1142
+ independently as-of/frame filtered (transitive — an edge not yet valid
1143
+ is never traversed, so no future reveal leaks backward). Computed fresh;
1144
+ never stored; never elects a canonical id. Default => {entity}.
1145
+
1146
+ Closure-aware throughout (Cx 057 #1): the start frontier is the entity's
1147
+ whole `same_as` closure, and a reached correlated node is expanded
1148
+ through ITS `same_as` closure before traversing further — so an `aka`
1149
+ edge attached to any closure member is found from any other member
1150
+ (retrieval-invariance across the identity model)."""
1151
+ edges: dict[str, set[str]] = {}
1152
+ for row in self._buffer.visible(
1153
+ attribute="aka", frame=frame,
1154
+ valid_as_of=valid_as_of, asserted_as_of=asserted_as_of,
1155
+ ):
1156
+ if row.value_type != "entity" or not isinstance(row.value, str):
1157
+ continue
1158
+ edges.setdefault(row.entity, set()).add(row.value)
1159
+ edges.setdefault(row.value, set()).add(row.entity)
1160
+ out = set(self.closure(entity, asserted_as_of))
1161
+ frontier = list(out)
1162
+ while frontier:
1163
+ for nxt in edges.get(frontier.pop(), ()):
1164
+ if nxt not in out:
1165
+ for member in self.closure(nxt, asserted_as_of):
1166
+ if member not in out:
1167
+ out.add(member)
1168
+ frontier.append(member)
1169
+ return out
1170
+
1171
+ def correlations(
1172
+ self,
1173
+ entity: str,
1174
+ valid_as_of: float | None = None,
1175
+ asserted_as_of: int | None = None,
1176
+ frame: str = CANON,
1177
+ ) -> list[str]:
1178
+ """The facets correlated with `entity` as-of — the correlation set minus
1179
+ the entity's own `same_as` closure. Ordered first-seen/log order with a
1180
+ lexical tie-break (never a canonical election — Cx 056). Writes nothing."""
1181
+ cset = self.correlation_set(entity, valid_as_of, asserted_as_of, frame)
1182
+ own = self.closure(entity, asserted_as_of)
1183
+ facets = cset - own
1184
+ if not facets:
1185
+ return []
1186
+ first_seen: dict[str, int] = {}
1187
+ for row in self._buffer.visible(asserted_as_of=asserted_as_of):
1188
+ if row.entity in facets and row.entity not in first_seen:
1189
+ first_seen[row.entity] = row.seq
1190
+ if (row.value_type == "entity" and isinstance(row.value, str)
1191
+ and row.value in facets and row.value not in first_seen):
1192
+ first_seen[row.value] = row.seq
1193
+ return sorted(facets, key=lambda e: (first_seen.get(e, 1 << 62), e))
1194
+
1195
+ def _aka_relates(self, a: str, b: str, asserted_as_of: int | None = None) -> bool:
1196
+ """Whether b is already in a's correlation COMPONENT (Cx 057 #2): the
1197
+ transitive connected component, not a one-edge test — so `correlate(A,C)`
1198
+ with A-aka-B-aka-C is a noop, matching the read semantics. valid_as_of is
1199
+ unfiltered here (any visible aka edge, regardless of reveal time, means
1200
+ the pair is already correlated — a duplicate append is redundant)."""
1201
+ comp = self.correlation_set(a, valid_as_of=None, asserted_as_of=asserted_as_of)
1202
+ return bool(self.closure(b, asserted_as_of) & comp)
1203
+
1204
+ def correlate(
1205
+ self, a: str, b: str, evidence: str, valid_from: float | None = None
1206
+ ) -> dict:
1207
+ """The guarded host correlate verb (mirror of guarded_merge): append a
1208
+ non-collapsing `aka` edge unless the pair is marked `distinct_from`
1209
+ (hard veto — a contradiction the host must adjudicate). `valid_from` is
1210
+ the reveal time. Returns a Receipt:
1211
+ `correlated | noop_already_correlated | vetoed_distinct`."""
1212
+ ra = self.resolve(a)
1213
+ dblocks = self.distinct_block(a, b)
1214
+ if dblocks:
1215
+ return self._receipt(
1216
+ "vetoed_distinct", ra, reason="distinct_from", blocking_edges=dblocks
1217
+ )
1218
+ if self._aka_relates(a, b):
1219
+ return self._receipt("noop_already_correlated", ra)
1220
+ edge = self._buffer.append(
1221
+ entity=a, attribute="aka", value=b, value_type="entity",
1222
+ status="stated", role=self._ingestor, valid_from=valid_from,
1223
+ )
1224
+ self._buffer.append(
1225
+ entity=edge.id, attribute="evidence", value=evidence,
1226
+ status="stated", role=self._ingestor,
1227
+ )
1228
+ rec = self._receipt("correlated", ra)
1229
+ rec["aka_assertion_id"] = edge.id
1230
+ logger.info("correlate %s aka %s (%s)", a, b, evidence)
1231
+ return rec
1232
+
1233
+ def correlation_conflicts(
1234
+ self,
1235
+ valid_as_of: float | None = None,
1236
+ asserted_as_of: int | None = None,
1237
+ frame: str = CANON,
1238
+ ) -> list[dict]:
1239
+ """Pairs carrying BOTH an `aka` and a `distinct_from` between their
1240
+ closures (closure-aware, bidirectional) — a contradiction surfaced for
1241
+ host adjudication (a raw `aka` authored over a `distinct_from`). The
1242
+ guarded `correlate()` prevents this at the source; this read catches the
1243
+ raw-ingest path. Deduped by resolved pair.
1244
+
1245
+ The `aka` rows are filtered by `valid_as_of`/`frame`/`asserted_as_of`
1246
+ (Cx 057 #3) — an as-of-before-reveal view shows no conflict. NOTE:
1247
+ `distinct_from` is global by current engine convention (asserted-axis
1248
+ only), so the distinctness side is not valid-time scoped."""
1249
+ out: list[dict] = []
1250
+ seen: set[tuple[str, str]] = set()
1251
+ for row in self._buffer.visible(
1252
+ attribute="aka", frame=frame,
1253
+ valid_as_of=valid_as_of, asserted_as_of=asserted_as_of,
1254
+ ):
1255
+ if row.value_type != "entity" or not isinstance(row.value, str):
1256
+ continue
1257
+ a, b = row.entity, row.value
1258
+ dblocks = self.distinct_block(a, b, asserted_as_of)
1259
+ if not dblocks:
1260
+ continue
1261
+ key = tuple(sorted([self.resolve(a, asserted_as_of),
1262
+ self.resolve(b, asserted_as_of)]))
1263
+ if key in seen:
1264
+ continue
1265
+ seen.add(key)
1266
+ out.append({
1267
+ "a": a, "b": b,
1268
+ "aka_edge": f"{a}·aka·{b}",
1269
+ "distinct_edges": dblocks,
1270
+ })
1271
+ return out