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.
- patternbuffer/__init__.py +317 -0
- patternbuffer/buffer.py +312 -0
- patternbuffer/classify.py +359 -0
- patternbuffer/codec.py +122 -0
- patternbuffer/dump.py +113 -0
- patternbuffer/identity.py +1271 -0
- patternbuffer/indexes.py +1642 -0
- patternbuffer/ingest.py +617 -0
- patternbuffer/mcp.py +303 -0
- patternbuffer/model.py +100 -0
- patternbuffer/porcelain.py +943 -0
- patternbuffer/project.py +436 -0
- patternbuffer/refer.py +256 -0
- patternbuffer/roles.py +77 -0
- patternbuffer/salience.py +170 -0
- patternbuffer/semantics.py +192 -0
- patternbuffer/testing.py +92 -0
- patternbuffer/thunks.py +245 -0
- patternbuffer/tmaint.py +122 -0
- pbuffer-0.1.0.dist-info/METADATA +77 -0
- pbuffer-0.1.0.dist-info/RECORD +25 -0
- pbuffer-0.1.0.dist-info/WHEEL +5 -0
- pbuffer-0.1.0.dist-info/entry_points.txt +2 -0
- pbuffer-0.1.0.dist-info/licenses/LICENSE +21 -0
- pbuffer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|