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
patternbuffer/indexes.py
ADDED
|
@@ -0,0 +1,1642 @@
|
|
|
1
|
+
"""Derived indexes: the durability-aware fold, the containment tree, the
|
|
2
|
+
lateral graph (whitepaper §13/§17; spec §7). Deterministic, no LLM,
|
|
3
|
+
rebuildable — these are views over the buffer, never truth.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from patternbuffer.buffer import PatternBuffer
|
|
15
|
+
from patternbuffer.classify import (
|
|
16
|
+
CONSTITUTIVE,
|
|
17
|
+
DISPOSITIONAL,
|
|
18
|
+
EVENT,
|
|
19
|
+
STATE,
|
|
20
|
+
Classifier,
|
|
21
|
+
)
|
|
22
|
+
from patternbuffer.codec import (
|
|
23
|
+
check_no_mix,
|
|
24
|
+
encode_out,
|
|
25
|
+
encode_value,
|
|
26
|
+
exact_div,
|
|
27
|
+
exact_sum,
|
|
28
|
+
)
|
|
29
|
+
from patternbuffer.model import ATTR_PREFIX, CANON, META_ATTRIBUTES, Assertion
|
|
30
|
+
from patternbuffer.semantics import AttributeSemantics, CONTAINMENT, builtin_default
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_FAMILY_KEY = "__containment__"
|
|
35
|
+
_NEIGHBORHOOD_EDGES = frozenset({"containment", "lateral", "relations", "events"})
|
|
36
|
+
_NEIGHBORHOOD_DEPTH_CAP = 3
|
|
37
|
+
CONFIDENCE_PARAMS = {
|
|
38
|
+
# CONFIDENCE-V1: fixed, tunable read-layer weights. These scores are
|
|
39
|
+
# derived on demand and are never stored in the assertion log or sidecar.
|
|
40
|
+
"weights": {
|
|
41
|
+
"provenance": 0.45,
|
|
42
|
+
"recency": 0.30,
|
|
43
|
+
"corroboration": 0.25,
|
|
44
|
+
},
|
|
45
|
+
"provenance": {
|
|
46
|
+
"stated": 1.0,
|
|
47
|
+
"observed": 1.0,
|
|
48
|
+
"inferred": 0.6,
|
|
49
|
+
"assumed": 0.3,
|
|
50
|
+
"generated": 0.4,
|
|
51
|
+
"default": 0.0,
|
|
52
|
+
},
|
|
53
|
+
"recency_scale": 10.0,
|
|
54
|
+
"corroboration_scale": 4.0,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fold_attribute(attribute: str) -> str:
|
|
59
|
+
"""Built-in fold-key compatibility helper.
|
|
60
|
+
|
|
61
|
+
World-bound reads use Indexes.fold_attribute(), which includes declared
|
|
62
|
+
semantics. This free function remains for callers that only need the
|
|
63
|
+
built-in defaults.
|
|
64
|
+
"""
|
|
65
|
+
return _FAMILY_KEY if builtin_default(attribute).relation_family == CONTAINMENT else attribute
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class FoldResult:
|
|
70
|
+
"""What the fold serves for one key."""
|
|
71
|
+
|
|
72
|
+
winner: Assertion | None
|
|
73
|
+
conflicted: bool = False
|
|
74
|
+
conflicting: tuple[str, ...] = () # assertion ids party to the conflict
|
|
75
|
+
corroborated_by: tuple[str, ...] = ()
|
|
76
|
+
values: tuple = ()
|
|
77
|
+
_value_rows: tuple[Assertion, ...] = field(default=(), repr=False, compare=False)
|
|
78
|
+
quantity: int | float | Decimal | None = None
|
|
79
|
+
_ledger_rows: tuple[Assertion, ...] = field(default=(), repr=False, compare=False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class _KeyRows:
|
|
84
|
+
rows: list[Assertion] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Indexes:
|
|
88
|
+
"""Fold + walks. Holds no durable state of its own (P2)."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
buffer: PatternBuffer,
|
|
93
|
+
classifier: Classifier,
|
|
94
|
+
identity_resolve: Callable[[str], str] | None = None,
|
|
95
|
+
semantics: AttributeSemantics | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
self._buffer = buffer
|
|
98
|
+
self._classifier = classifier
|
|
99
|
+
self._semantics = semantics or AttributeSemantics(buffer)
|
|
100
|
+
# Identity closure hook: maps any entity id to its canonical
|
|
101
|
+
# representative. Installed by World wiring once the registry exists.
|
|
102
|
+
self._resolve = identity_resolve or (lambda eid: eid)
|
|
103
|
+
self._closure_of = lambda eid: {eid}
|
|
104
|
+
# AKA-CORRELATION-V1: the non-collapsing correlation set (aka-component),
|
|
105
|
+
# injected by World from the registry. Default => just the entity, so
|
|
106
|
+
# state_union with no aka edges is identical to fold_key.
|
|
107
|
+
self._correlation_of = (
|
|
108
|
+
lambda eid, valid_as_of=None, asserted_as_of=None, frame=CANON: {eid}
|
|
109
|
+
)
|
|
110
|
+
self._salience = lambda eid, frame=CANON, as_of=None: 0.0
|
|
111
|
+
|
|
112
|
+
def set_identity_resolver(self, fn: Callable[[str], str]) -> None:
|
|
113
|
+
self._resolve = fn
|
|
114
|
+
|
|
115
|
+
def set_closure_provider(self, fn: Callable[[str], set]) -> None:
|
|
116
|
+
"""Identity-closure lookup for indexed (read-path) retrieval —
|
|
117
|
+
installed by World wiring alongside the resolver (037)."""
|
|
118
|
+
self._closure_of = fn
|
|
119
|
+
|
|
120
|
+
def set_correlation_provider(self, fn: Callable[..., set]) -> None:
|
|
121
|
+
"""`aka`-correlation-set lookup (AKA-CORRELATION-V1) — installed by World
|
|
122
|
+
wiring from the registry's correlation_set. Read only by state_union."""
|
|
123
|
+
self._correlation_of = fn
|
|
124
|
+
|
|
125
|
+
def set_salience_provider(
|
|
126
|
+
self, fn: Callable[[str, str, float | None], float]
|
|
127
|
+
) -> None:
|
|
128
|
+
self._salience = fn
|
|
129
|
+
|
|
130
|
+
def resolve_entity(self, entity: str) -> str:
|
|
131
|
+
"""The identity closure's canonical representative for `entity`."""
|
|
132
|
+
return self._resolve(entity)
|
|
133
|
+
|
|
134
|
+
def fold_attribute(self, attribute: str) -> str:
|
|
135
|
+
"""World-bound fold-key attribute, including declared semantics."""
|
|
136
|
+
return _FAMILY_KEY if self._semantics.is_containment(attribute) else attribute
|
|
137
|
+
|
|
138
|
+
# -------------------------------------------------------------- helpers
|
|
139
|
+
|
|
140
|
+
def _source_class(self, row: Assertion, asserted_as_of: int | None) -> str:
|
|
141
|
+
"""Provenance status, refined by the document-vs-direct distinction
|
|
142
|
+
(whitepaper §7.1, spec §7). The refinement applies to `stated` as
|
|
143
|
+
well as `observed`: in fiction mode an in-fiction document's claims
|
|
144
|
+
(the story observed the letter; the letter claims the facts) must
|
|
145
|
+
sit in a different trust chain from narration-direct facts."""
|
|
146
|
+
if row.status not in {"observed", "stated"}:
|
|
147
|
+
return row.status
|
|
148
|
+
metas = self._buffer.visible(
|
|
149
|
+
entity=row.id, attribute="source", asserted_as_of=asserted_as_of
|
|
150
|
+
)
|
|
151
|
+
for m in metas:
|
|
152
|
+
if isinstance(m.value, str) and m.value.startswith("doc:"):
|
|
153
|
+
return "document"
|
|
154
|
+
if isinstance(m.value, str) and m.value.startswith("person:"):
|
|
155
|
+
# Speaker-source class (027 Decision 2): a speaker is a
|
|
156
|
+
# document that talks — same speaker supersedes self,
|
|
157
|
+
# speakers disagreeing cross-source flag + ask (§7.2).
|
|
158
|
+
return f"speaker:{m.value}"
|
|
159
|
+
# stated and observed without a document chain are ONE supersession
|
|
160
|
+
# class: both are rank-3 authoritative, and ordinary narrative
|
|
161
|
+
# movement must supersede across them (run-4 finding: the §7.1
|
|
162
|
+
# boundary is document-vs-direct, not stated-vs-observed).
|
|
163
|
+
return "direct"
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _values_agree(old: object, new: object) -> bool:
|
|
167
|
+
"""Equal, or `new` refines an explicitly approximate `old`
|
|
168
|
+
({'gte': x} / {'lte': x} / {'approx': x}) — spec §7. When `new` is
|
|
169
|
+
an exact Decimal, bounds coerce via Decimal(str(...)) for the
|
|
170
|
+
comparison only — a read-time judgment, never a stored sum."""
|
|
171
|
+
if old == new:
|
|
172
|
+
return True
|
|
173
|
+
if isinstance(old, dict) and isinstance(new, (int, float, Decimal)):
|
|
174
|
+
if "gte" in old and new >= old["gte"]:
|
|
175
|
+
return True
|
|
176
|
+
if "lte" in old and new <= old["lte"]:
|
|
177
|
+
return True
|
|
178
|
+
if "approx" in old:
|
|
179
|
+
approx = old["approx"]
|
|
180
|
+
if isinstance(new, Decimal) and isinstance(approx, (int, float)):
|
|
181
|
+
approx = Decimal(str(approx))
|
|
182
|
+
return abs(new - approx) <= Decimal("0.1") * abs(approx)
|
|
183
|
+
if isinstance(new, (int, float)) and isinstance(approx, Decimal):
|
|
184
|
+
new = Decimal(str(new))
|
|
185
|
+
return abs(new - approx) <= Decimal("0.1") * abs(approx)
|
|
186
|
+
if abs(new - approx) <= 0.1 * abs(approx):
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _is_numeric(value: object) -> bool:
|
|
192
|
+
return isinstance(value, (int, float, Decimal)) and not isinstance(value, bool)
|
|
193
|
+
|
|
194
|
+
def _is_event_effect(self, row: Assertion, asserted_as_of: int | None) -> bool:
|
|
195
|
+
"""True iff the row carries a caused_by edge to an EVENT (spec §9.1)."""
|
|
196
|
+
return bool(
|
|
197
|
+
self._buffer.visible(
|
|
198
|
+
entity=row.id, attribute="caused_by", asserted_as_of=asserted_as_of
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _empty_confidence() -> dict:
|
|
204
|
+
return {
|
|
205
|
+
"score": None,
|
|
206
|
+
"status": None,
|
|
207
|
+
"last_observed_at": None,
|
|
208
|
+
"corroboration": 0,
|
|
209
|
+
"conflicted": False,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
def _confidence_values_equivalent(self, left: Assertion, right: Assertion) -> bool:
|
|
213
|
+
# Corroboration is "same value" (post-impl review): STRICT equivalence,
|
|
214
|
+
# not the loose approximate-bounds agreement — a {gte:40} bound must not
|
|
215
|
+
# count as corroborating a precise 42.
|
|
216
|
+
if left.value_type != right.value_type:
|
|
217
|
+
return False
|
|
218
|
+
if left.value_type == "entity" and isinstance(left.value, str) \
|
|
219
|
+
and isinstance(right.value, str):
|
|
220
|
+
return self._resolve(left.value) == self._resolve(right.value)
|
|
221
|
+
return left.value == right.value
|
|
222
|
+
|
|
223
|
+
def _visible_key_rows(
|
|
224
|
+
self,
|
|
225
|
+
entity: str,
|
|
226
|
+
attribute: str,
|
|
227
|
+
frame: str,
|
|
228
|
+
valid_as_of: float | None,
|
|
229
|
+
asserted_as_of: int | None,
|
|
230
|
+
) -> list[Assertion]:
|
|
231
|
+
fa = self.fold_attribute(attribute)
|
|
232
|
+
attrs = sorted(self._semantics.containment_family()) if fa == _FAMILY_KEY else [attribute]
|
|
233
|
+
return self._buffer.visible(
|
|
234
|
+
entity_in=sorted(self._closure_of(entity)),
|
|
235
|
+
attribute_in=attrs,
|
|
236
|
+
frame=frame,
|
|
237
|
+
valid_as_of=valid_as_of,
|
|
238
|
+
asserted_as_of=asserted_as_of,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# ----------------------------------------------------------------- fold
|
|
242
|
+
|
|
243
|
+
def fold_key(
|
|
244
|
+
self,
|
|
245
|
+
entity: str,
|
|
246
|
+
attribute: str,
|
|
247
|
+
frame: str = CANON,
|
|
248
|
+
valid_as_of: float | None = None,
|
|
249
|
+
asserted_as_of: int | None = None,
|
|
250
|
+
) -> FoldResult:
|
|
251
|
+
"""Serve one (entity, attribute, frame) key per the per-class rules."""
|
|
252
|
+
entity = self._resolve(entity)
|
|
253
|
+
fa = self.fold_attribute(attribute)
|
|
254
|
+
closure = sorted(self._closure_of(entity))
|
|
255
|
+
return self._fold_over_closure(
|
|
256
|
+
closure, attribute, fa, frame, valid_as_of, asserted_as_of
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _fold_over_closure(
|
|
260
|
+
self,
|
|
261
|
+
closure: list[str],
|
|
262
|
+
attribute: str,
|
|
263
|
+
fa: str,
|
|
264
|
+
frame: str,
|
|
265
|
+
valid_as_of: float | None,
|
|
266
|
+
asserted_as_of: int | None,
|
|
267
|
+
) -> FoldResult:
|
|
268
|
+
"""Fold one attribute over a fixed set of subject ids (the entity's
|
|
269
|
+
`same_as` closure for `fold_key`; the union of correlated facets'
|
|
270
|
+
closures for `state_union`). Identical fold semantics either way — so a
|
|
271
|
+
correlated-union read returns exactly what the normal fold would have
|
|
272
|
+
served had the rows been authored on one closure (AKA-CORRELATION-V1 §3,
|
|
273
|
+
the retrieval-invariance invariant)."""
|
|
274
|
+
attrs = sorted(self._semantics.containment_family()) if fa == _FAMILY_KEY else [attribute]
|
|
275
|
+
candidates: list[Assertion] = []
|
|
276
|
+
for row in self._buffer.visible(
|
|
277
|
+
entity_in=closure, attribute_in=attrs,
|
|
278
|
+
frame=frame, valid_as_of=valid_as_of, asserted_as_of=asserted_as_of,
|
|
279
|
+
):
|
|
280
|
+
if self._classifier.durability(row.id) == EVENT:
|
|
281
|
+
continue # events never fold
|
|
282
|
+
candidates.append(row)
|
|
283
|
+
if not candidates:
|
|
284
|
+
return FoldResult(winner=None)
|
|
285
|
+
|
|
286
|
+
# Accrue reads raw numeric literal/delta rows (it filters to numbers
|
|
287
|
+
# internally and ignores unresolved/thunk machinery), so it branches
|
|
288
|
+
# before the thunk filter. Deltas are STATE by guardrail, never EVENT,
|
|
289
|
+
# so the gather-loop EVENT drop above leaves the ledger intact.
|
|
290
|
+
if self._semantics.is_accrue(attribute):
|
|
291
|
+
return self._fold_accrue(candidates)
|
|
292
|
+
|
|
293
|
+
# A `delta` on a NON-accrue attribute is not an absolute value — drop
|
|
294
|
+
# it before the thunk filter so it can neither suppress an unresolved
|
|
295
|
+
# placeholder nor compete in _fold_state.
|
|
296
|
+
candidates = [r for r in candidates if r.value_type != "delta"]
|
|
297
|
+
if not candidates:
|
|
298
|
+
return FoldResult(winner=None)
|
|
299
|
+
|
|
300
|
+
# Resolution IS the supersession (whitepaper §8 — forcing memoizes
|
|
301
|
+
# into the log; the fold serves the memo): a spent thunk (one with a
|
|
302
|
+
# resolved_by marker) never competes, and once concrete rows exist
|
|
303
|
+
# on a key, its unresolved placeholder no longer competes either.
|
|
304
|
+
candidates = [
|
|
305
|
+
r
|
|
306
|
+
for r in candidates
|
|
307
|
+
if r.value_type != "unresolved"
|
|
308
|
+
or not self._buffer.visible(
|
|
309
|
+
entity=r.id, attribute="resolved_by", asserted_as_of=asserted_as_of
|
|
310
|
+
)
|
|
311
|
+
]
|
|
312
|
+
if any(r.value_type != "unresolved" for r in candidates):
|
|
313
|
+
candidates = [r for r in candidates if r.value_type != "unresolved"]
|
|
314
|
+
if not candidates:
|
|
315
|
+
return FoldResult(winner=None)
|
|
316
|
+
|
|
317
|
+
if self._semantics.is_set_valued(attribute):
|
|
318
|
+
return self._fold_set_valued(candidates)
|
|
319
|
+
|
|
320
|
+
by_class = {STATE: [], DISPOSITIONAL: [], CONSTITUTIVE: []}
|
|
321
|
+
for row in candidates:
|
|
322
|
+
by_class.setdefault(self._classifier.durability(row.id), []).append(row)
|
|
323
|
+
|
|
324
|
+
# CONSTITUTIVE outranks: structure is never superseded by recency.
|
|
325
|
+
if by_class[CONSTITUTIVE]:
|
|
326
|
+
rows = by_class[CONSTITUTIVE]
|
|
327
|
+
# Identity-aware value identity (Cx 065): entity-valued CONSTITUTIVE
|
|
328
|
+
# rows that point to a later-MERGED whole are NOT a conflict — resolve
|
|
329
|
+
# before comparing, so a `part_of` (or place containment) edge to
|
|
330
|
+
# h1/h1_alias collapses once they are same_as. Literals compare raw.
|
|
331
|
+
def _value_key(r: Assertion):
|
|
332
|
+
if r.value_type == "entity" and isinstance(r.value, str):
|
|
333
|
+
return ("entity", self._resolve(r.value))
|
|
334
|
+
return ("literal", repr(r.value))
|
|
335
|
+
values = {_value_key(r) for r in rows}
|
|
336
|
+
earliest = min(rows, key=lambda r: r.asserted_at)
|
|
337
|
+
if len(values) > 1:
|
|
338
|
+
return FoldResult(
|
|
339
|
+
winner=earliest,
|
|
340
|
+
conflicted=True,
|
|
341
|
+
conflicting=tuple(r.id for r in rows),
|
|
342
|
+
)
|
|
343
|
+
return FoldResult(winner=earliest)
|
|
344
|
+
|
|
345
|
+
if by_class[STATE]:
|
|
346
|
+
return self._fold_state(
|
|
347
|
+
by_class[STATE], asserted_as_of, is_containment=(fa == _FAMILY_KEY)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if by_class[DISPOSITIONAL]:
|
|
351
|
+
rows = by_class[DISPOSITIONAL]
|
|
352
|
+
winner = max(rows, key=lambda r: (r.valid_from or float("-inf"), r.asserted_at))
|
|
353
|
+
return FoldResult(winner=winner)
|
|
354
|
+
return FoldResult(winner=None)
|
|
355
|
+
|
|
356
|
+
def state_union(
|
|
357
|
+
self,
|
|
358
|
+
entity: str,
|
|
359
|
+
attribute: str,
|
|
360
|
+
frame: str = CANON,
|
|
361
|
+
valid_as_of: float | None = None,
|
|
362
|
+
asserted_as_of: int | None = None,
|
|
363
|
+
) -> FoldResult:
|
|
364
|
+
"""The explicit, opt-in correlated read (AKA-CORRELATION-V1 §3): fold one
|
|
365
|
+
attribute over the union of subject rows across the entity's `aka`
|
|
366
|
+
correlation set (each facet expanded through its own `same_as` closure),
|
|
367
|
+
as-of. With no visible `aka` edge the correlation set is just the entity,
|
|
368
|
+
so this is byte-for-byte `fold_key`. NEVER consulted by any default read;
|
|
369
|
+
nothing stored. The reveal is valid-time-gated: as-of-before the reveal,
|
|
370
|
+
the correlation set is the lone entity → the pre-reveal (uncorrelated)
|
|
371
|
+
view, never leaking the post-reveal identity."""
|
|
372
|
+
cset = self._correlation_of(
|
|
373
|
+
entity, valid_as_of=valid_as_of, asserted_as_of=asserted_as_of, frame=frame
|
|
374
|
+
)
|
|
375
|
+
union: set[str] = set()
|
|
376
|
+
for facet in cset:
|
|
377
|
+
union |= self._closure_of(facet)
|
|
378
|
+
fa = self.fold_attribute(attribute)
|
|
379
|
+
return self._fold_over_closure(
|
|
380
|
+
sorted(union), attribute, fa, frame, valid_as_of, asserted_as_of
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def confidence(
|
|
384
|
+
self,
|
|
385
|
+
entity: str,
|
|
386
|
+
attribute: str,
|
|
387
|
+
frame: str | list[str] = CANON,
|
|
388
|
+
as_of: float | None = None,
|
|
389
|
+
asserted_as_of: int | None = None,
|
|
390
|
+
) -> dict:
|
|
391
|
+
"""Derived trust score for one functional folded key.
|
|
392
|
+
|
|
393
|
+
CONFIDENCE-V1 is compute-only: it reads the visible closure and the
|
|
394
|
+
functional fold, writes nothing, and returns ``None`` fields for
|
|
395
|
+
absent, set-valued, or accrue keys.
|
|
396
|
+
|
|
397
|
+
``frame`` may be a list of frames (CONFIDENCE-MULTIFRAME-V1): trust
|
|
398
|
+
over an observer's *effective* knowledge = the read-union of those
|
|
399
|
+
frames (``knows:O ∪ public``), mirroring multi-frame ``frame_diff``.
|
|
400
|
+
A deduped single-frame list reduces to the str path byte-for-byte.
|
|
401
|
+
|
|
402
|
+
Identity closure is computed at current head (consistent with
|
|
403
|
+
``fold_key``/``current_state`` engine-wide), so under ``asserted_as_of``
|
|
404
|
+
a later merge can change a historical confidence read — a known,
|
|
405
|
+
shared limitation, not a confidence-specific one.
|
|
406
|
+
"""
|
|
407
|
+
if not isinstance(frame, str):
|
|
408
|
+
return self._confidence_multiframe(
|
|
409
|
+
entity, attribute, list(frame), as_of, asserted_as_of
|
|
410
|
+
)
|
|
411
|
+
entity = self._resolve(entity)
|
|
412
|
+
if self._semantics.is_set_valued(attribute) or self._semantics.is_accrue(attribute):
|
|
413
|
+
return self._empty_confidence()
|
|
414
|
+
|
|
415
|
+
fold = self.fold_key(
|
|
416
|
+
entity,
|
|
417
|
+
attribute,
|
|
418
|
+
frame=frame,
|
|
419
|
+
valid_as_of=as_of,
|
|
420
|
+
asserted_as_of=asserted_as_of,
|
|
421
|
+
)
|
|
422
|
+
winner = fold.winner
|
|
423
|
+
if winner is None:
|
|
424
|
+
return self._empty_confidence()
|
|
425
|
+
|
|
426
|
+
params = CONFIDENCE_PARAMS
|
|
427
|
+
provenance = params["provenance"].get(winner.status, 0.0)
|
|
428
|
+
if winner.confidence is not None:
|
|
429
|
+
# Clamp the source-confidence field to [0,1] before flooring — the
|
|
430
|
+
# gate accepts any float, and a garbage negative must not zero a
|
|
431
|
+
# stated fact (post-impl review).
|
|
432
|
+
provenance = min(provenance, max(0.0, min(1.0, winner.confidence)))
|
|
433
|
+
|
|
434
|
+
if winner.valid_from is None:
|
|
435
|
+
recency = 1.0
|
|
436
|
+
else:
|
|
437
|
+
ref = as_of
|
|
438
|
+
if ref is None:
|
|
439
|
+
closure_rows = self._buffer.visible(
|
|
440
|
+
entity_in=sorted(self._closure_of(entity)),
|
|
441
|
+
frame=frame,
|
|
442
|
+
asserted_as_of=asserted_as_of,
|
|
443
|
+
)
|
|
444
|
+
ref = max(
|
|
445
|
+
(row.valid_from for row in closure_rows if row.valid_from is not None),
|
|
446
|
+
default=winner.valid_from,
|
|
447
|
+
)
|
|
448
|
+
age = max(0.0, float(ref) - float(winner.valid_from))
|
|
449
|
+
recency = 1.0 / (1.0 + age / params["recency_scale"])
|
|
450
|
+
|
|
451
|
+
source_classes = {self._source_class(winner, asserted_as_of)}
|
|
452
|
+
for assertion_id in fold.corroborated_by:
|
|
453
|
+
row = self._buffer.get(assertion_id)
|
|
454
|
+
if row is not None:
|
|
455
|
+
source_classes.add(self._source_class(row, asserted_as_of))
|
|
456
|
+
for row in self._visible_key_rows(
|
|
457
|
+
entity,
|
|
458
|
+
attribute,
|
|
459
|
+
frame=frame,
|
|
460
|
+
valid_as_of=as_of,
|
|
461
|
+
asserted_as_of=asserted_as_of,
|
|
462
|
+
):
|
|
463
|
+
if self._confidence_values_equivalent(row, winner):
|
|
464
|
+
source_classes.add(self._source_class(row, asserted_as_of))
|
|
465
|
+
|
|
466
|
+
corroboration = max(0, len(source_classes) - 1)
|
|
467
|
+
corroboration_score = (
|
|
468
|
+
math.log1p(corroboration) / math.log1p(params["corroboration_scale"])
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
weights = params["weights"]
|
|
472
|
+
score = (
|
|
473
|
+
weights["provenance"] * provenance
|
|
474
|
+
+ weights["recency"] * recency
|
|
475
|
+
+ weights["corroboration"] * corroboration_score
|
|
476
|
+
)
|
|
477
|
+
if fold.conflicted:
|
|
478
|
+
score *= 0.5
|
|
479
|
+
score = min(1.0, max(0.0, score))
|
|
480
|
+
return {
|
|
481
|
+
"score": score,
|
|
482
|
+
"status": winner.status,
|
|
483
|
+
"last_observed_at": winner.valid_from,
|
|
484
|
+
"corroboration": corroboration,
|
|
485
|
+
"conflicted": fold.conflicted,
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
def _confidence_multiframe(
|
|
489
|
+
self,
|
|
490
|
+
entity: str,
|
|
491
|
+
attribute: str,
|
|
492
|
+
frames: list[str],
|
|
493
|
+
as_of: float | None,
|
|
494
|
+
asserted_as_of: int | None,
|
|
495
|
+
) -> dict:
|
|
496
|
+
"""Confidence over the read-union of `frames` (CONFIDENCE-MULTIFRAME-V1).
|
|
497
|
+
|
|
498
|
+
The effective winner is the most-recent across the per-frame folds;
|
|
499
|
+
conflict is any per-frame conflict OR a cross-frame value
|
|
500
|
+
disagreement; corroboration unions each fold's V1 source classes with
|
|
501
|
+
the strict cross-frame scan. Derived, writes nothing (the membrane)."""
|
|
502
|
+
# Dedup preserving order; a single distinct frame delegates to the str
|
|
503
|
+
# path so the reduction invariant is byte-identical (Codex r1).
|
|
504
|
+
ordered: list[str] = []
|
|
505
|
+
seen: set[str] = set()
|
|
506
|
+
for f in frames:
|
|
507
|
+
if f not in seen:
|
|
508
|
+
seen.add(f)
|
|
509
|
+
ordered.append(f)
|
|
510
|
+
if not ordered:
|
|
511
|
+
# An empty frame list names no knowledge — empty, never a canon
|
|
512
|
+
# read (Codex post-impl finding 1).
|
|
513
|
+
return self._empty_confidence()
|
|
514
|
+
if len(ordered) == 1:
|
|
515
|
+
# Reduction: a single distinct frame is byte-identical to the str
|
|
516
|
+
# path (and inherits its full V1 corroboration recovery).
|
|
517
|
+
return self.confidence(entity, attribute, ordered[0], as_of, asserted_as_of)
|
|
518
|
+
|
|
519
|
+
entity = self._resolve(entity)
|
|
520
|
+
if self._semantics.is_set_valued(attribute) or self._semantics.is_accrue(attribute):
|
|
521
|
+
return self._empty_confidence()
|
|
522
|
+
|
|
523
|
+
folds: list[tuple[str, FoldResult]] = []
|
|
524
|
+
for f in ordered:
|
|
525
|
+
fold = self.fold_key(
|
|
526
|
+
entity, attribute, frame=f, valid_as_of=as_of, asserted_as_of=asserted_as_of
|
|
527
|
+
)
|
|
528
|
+
if fold.winner is not None:
|
|
529
|
+
folds.append((f, fold))
|
|
530
|
+
if not folds:
|
|
531
|
+
return self._empty_confidence()
|
|
532
|
+
|
|
533
|
+
def recency_key(fold: FoldResult) -> tuple[float, int]:
|
|
534
|
+
w = fold.winner
|
|
535
|
+
return (w.valid_from if w.valid_from is not None else float("-inf"), w.asserted_at)
|
|
536
|
+
|
|
537
|
+
winner = max(folds, key=lambda ff: recency_key(ff[1]))[1].winner
|
|
538
|
+
|
|
539
|
+
# Conflict: any per-frame conflict, or two frames' winners disagree
|
|
540
|
+
# (strict, entity-resolved — the effective belief is contested).
|
|
541
|
+
conflicted = any(fold.conflicted for _, fold in folds)
|
|
542
|
+
if not conflicted:
|
|
543
|
+
for i in range(len(folds)):
|
|
544
|
+
if conflicted:
|
|
545
|
+
break
|
|
546
|
+
for j in range(i + 1, len(folds)):
|
|
547
|
+
if not self._confidence_values_equivalent(
|
|
548
|
+
folds[i][1].winner, folds[j][1].winner
|
|
549
|
+
):
|
|
550
|
+
conflicted = True
|
|
551
|
+
break
|
|
552
|
+
|
|
553
|
+
params = CONFIDENCE_PARAMS
|
|
554
|
+
provenance = params["provenance"].get(winner.status, 0.0)
|
|
555
|
+
if winner.confidence is not None:
|
|
556
|
+
provenance = min(provenance, max(0.0, min(1.0, winner.confidence)))
|
|
557
|
+
|
|
558
|
+
if winner.valid_from is None:
|
|
559
|
+
recency = 1.0
|
|
560
|
+
else:
|
|
561
|
+
ref = as_of
|
|
562
|
+
if ref is None:
|
|
563
|
+
closure = sorted(self._closure_of(entity))
|
|
564
|
+
refs = [
|
|
565
|
+
row.valid_from
|
|
566
|
+
for f in ordered
|
|
567
|
+
for row in self._buffer.visible(
|
|
568
|
+
entity_in=closure, frame=f, asserted_as_of=asserted_as_of
|
|
569
|
+
)
|
|
570
|
+
if row.valid_from is not None
|
|
571
|
+
]
|
|
572
|
+
ref = max(refs) if refs else winner.valid_from
|
|
573
|
+
age = max(0.0, float(ref) - float(winner.valid_from))
|
|
574
|
+
recency = 1.0 / (1.0 + age / params["recency_scale"])
|
|
575
|
+
|
|
576
|
+
# Corroboration: distinct source classes attesting the EFFECTIVE served
|
|
577
|
+
# value (Codex post-impl finding 2). Counting sources that back a
|
|
578
|
+
# *different* per-frame value would inflate trust in a contested value;
|
|
579
|
+
# those are conflict, not corroboration. This is the strict cross-frame
|
|
580
|
+
# scan against the effective winner (recovers every agreeing frame's
|
|
581
|
+
# incumbents — V1's recovery) unioned with the V1 loose-refinement
|
|
582
|
+
# `corroborated_by` of only the folds that serve the effective value.
|
|
583
|
+
# A per-call memo collapses _source_class's repeated visible() reads.
|
|
584
|
+
sc_memo: dict[str, str] = {}
|
|
585
|
+
|
|
586
|
+
def source_class(row: Assertion) -> str:
|
|
587
|
+
cached = sc_memo.get(row.id)
|
|
588
|
+
if cached is None:
|
|
589
|
+
cached = self._source_class(row, asserted_as_of)
|
|
590
|
+
sc_memo[row.id] = cached
|
|
591
|
+
return cached
|
|
592
|
+
|
|
593
|
+
source_classes: set[str] = set()
|
|
594
|
+
for f in ordered:
|
|
595
|
+
for row in self._visible_key_rows(
|
|
596
|
+
entity, attribute, frame=f, valid_as_of=as_of, asserted_as_of=asserted_as_of
|
|
597
|
+
):
|
|
598
|
+
if self._confidence_values_equivalent(row, winner):
|
|
599
|
+
source_classes.add(source_class(row))
|
|
600
|
+
for _, fold in folds:
|
|
601
|
+
if not self._confidence_values_equivalent(fold.winner, winner):
|
|
602
|
+
continue # this frame serves a different value — not corroboration
|
|
603
|
+
source_classes.add(source_class(fold.winner))
|
|
604
|
+
for assertion_id in fold.corroborated_by:
|
|
605
|
+
row = self._buffer.get(assertion_id)
|
|
606
|
+
if row is not None:
|
|
607
|
+
source_classes.add(source_class(row))
|
|
608
|
+
corroboration = max(0, len(source_classes) - 1)
|
|
609
|
+
corroboration_score = (
|
|
610
|
+
math.log1p(corroboration) / math.log1p(params["corroboration_scale"])
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
weights = params["weights"]
|
|
614
|
+
score = (
|
|
615
|
+
weights["provenance"] * provenance
|
|
616
|
+
+ weights["recency"] * recency
|
|
617
|
+
+ weights["corroboration"] * corroboration_score
|
|
618
|
+
)
|
|
619
|
+
if conflicted:
|
|
620
|
+
score *= 0.5
|
|
621
|
+
score = min(1.0, max(0.0, score))
|
|
622
|
+
return {
|
|
623
|
+
"score": score,
|
|
624
|
+
"status": winner.status,
|
|
625
|
+
"last_observed_at": winner.valid_from,
|
|
626
|
+
"corroboration": corroboration,
|
|
627
|
+
"conflicted": conflicted,
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
def _fold_accrue(self, rows: list[Assertion]) -> FoldResult:
|
|
631
|
+
"""Accrue quantities: latest absolute numeric baseline plus later
|
|
632
|
+
signed numeric deltas. The ledger is provenance-only; the derived
|
|
633
|
+
quantity is surfaced separately from stored facts."""
|
|
634
|
+
def recency(row: Assertion) -> tuple[float, int]:
|
|
635
|
+
return (
|
|
636
|
+
row.valid_from if row.valid_from is not None else float("-inf"),
|
|
637
|
+
row.asserted_at,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
literals = [
|
|
641
|
+
r for r in rows
|
|
642
|
+
if r.value_type == "literal" and self._is_numeric(r.value)
|
|
643
|
+
]
|
|
644
|
+
deltas = [
|
|
645
|
+
r for r in rows
|
|
646
|
+
if r.value_type == "delta" and self._is_numeric(r.value)
|
|
647
|
+
]
|
|
648
|
+
baseline = max(literals, key=recency) if literals else None
|
|
649
|
+
baseline_value: int | float | Decimal = baseline.value if baseline is not None else 0
|
|
650
|
+
baseline_key = recency(baseline) if baseline is not None else None
|
|
651
|
+
contributing = [
|
|
652
|
+
r for r in deltas if baseline_key is None or recency(r) > baseline_key
|
|
653
|
+
]
|
|
654
|
+
contributing.sort(key=recency)
|
|
655
|
+
if baseline is None and not contributing:
|
|
656
|
+
return FoldResult(winner=None)
|
|
657
|
+
# Any-Decimal ledger folds exactly under MONEY_CTX; a Decimal/float
|
|
658
|
+
# mix raises with the offending rows (EXACT-DECIMAL-QUANTITIES-V1).
|
|
659
|
+
# The non-Decimal path keeps the pre-change expression VERBATIM —
|
|
660
|
+
# float addition is non-associative, so re-grouping the same values
|
|
661
|
+
# would break byte-identity for existing float worlds.
|
|
662
|
+
ledger_ids = ([baseline.id] if baseline is not None else []) + [
|
|
663
|
+
r.id for r in contributing
|
|
664
|
+
]
|
|
665
|
+
ledger_values = [baseline_value] + [r.value for r in contributing]
|
|
666
|
+
check_no_mix(ledger_values, ids=ledger_ids)
|
|
667
|
+
if any(isinstance(v, Decimal) for v in ledger_values):
|
|
668
|
+
total = exact_sum(ledger_values, ids=ledger_ids)
|
|
669
|
+
else:
|
|
670
|
+
total = baseline_value + sum(r.value for r in contributing)
|
|
671
|
+
winner = contributing[-1] if contributing else baseline
|
|
672
|
+
ledger_rows = ((baseline,) if baseline is not None else ()) + tuple(contributing)
|
|
673
|
+
return FoldResult(
|
|
674
|
+
winner=winner,
|
|
675
|
+
quantity=total,
|
|
676
|
+
_ledger_rows=ledger_rows,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
def _fold_set_valued(self, rows: list[Assertion]) -> FoldResult:
|
|
680
|
+
"""Set-valued keys accumulate current members instead of conflicting.
|
|
681
|
+
|
|
682
|
+
Duplicate assertions for the same member supersede within that
|
|
683
|
+
member's own (entity, attribute, value-identity) key. The served
|
|
684
|
+
winner remains the most-recent current member for compatibility.
|
|
685
|
+
"""
|
|
686
|
+
def recency(row: Assertion) -> tuple[float, int]:
|
|
687
|
+
return (row.valid_from if row.valid_from is not None else float("-inf"), row.asserted_at)
|
|
688
|
+
|
|
689
|
+
def value_identity(row: Assertion) -> tuple[str, object]:
|
|
690
|
+
if row.value_type == "entity" and isinstance(row.value, str):
|
|
691
|
+
return row.value_type, self._resolve(row.value)
|
|
692
|
+
return row.value_type, repr(row.value)
|
|
693
|
+
|
|
694
|
+
current: dict[tuple[str, str, tuple[str, object]], Assertion] = {}
|
|
695
|
+
for row in rows:
|
|
696
|
+
key = (self._resolve(row.entity), row.attribute, value_identity(row))
|
|
697
|
+
prior = current.get(key)
|
|
698
|
+
if prior is None or recency(row) > recency(prior):
|
|
699
|
+
current[key] = row
|
|
700
|
+
value_rows = tuple(sorted(current.values(), key=lambda r: (r.asserted_at, r.id)))
|
|
701
|
+
winner = max(value_rows, key=recency) if value_rows else None
|
|
702
|
+
return FoldResult(
|
|
703
|
+
winner=winner,
|
|
704
|
+
values=tuple(r.value for r in value_rows),
|
|
705
|
+
_value_rows=value_rows,
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
def _fold_state(
|
|
709
|
+
self, rows: list[Assertion], asserted_as_of: int | None,
|
|
710
|
+
is_containment: bool = False,
|
|
711
|
+
) -> FoldResult:
|
|
712
|
+
"""STATE: recency wins within a source class; across classes,
|
|
713
|
+
agreement corroborates (serve the more precise value), disagreement
|
|
714
|
+
flags and keeps serving the prior in-class winner (spec §7).
|
|
715
|
+
|
|
716
|
+
``is_containment`` (HD 002 finding 2): for the containment/movement
|
|
717
|
+
family, relocation is inherently time-sequential — a later move
|
|
718
|
+
supersedes an earlier placement across source classes, instead of
|
|
719
|
+
raising the §7.2 cross-source flag. A genuine same-valid-time
|
|
720
|
+
cross-source disagreement still flags."""
|
|
721
|
+
# Evidence rank (the assumption quarantine, generalized): provisional
|
|
722
|
+
# classes never hold incumbency against authoritative ones — an
|
|
723
|
+
# authored/observed fact arriving over a character's inference or a
|
|
724
|
+
# working assumption is confirmation or correction, never a conflict
|
|
725
|
+
# to ask about. Peers ({stated, observed}) keep the full
|
|
726
|
+
# corroborate-vs-flag machinery below. (Found by chapter-test run 3:
|
|
727
|
+
# a narrator's wrong `inferred` theory outheld later `stated` canon.)
|
|
728
|
+
rank = {"stated": 3, "observed": 3, "generated": 2, "inferred": 1, "assumed": 0}
|
|
729
|
+
top = max(rank.get(r.status, 0) for r in rows)
|
|
730
|
+
rows = [r for r in rows if rank.get(r.status, 0) == top]
|
|
731
|
+
|
|
732
|
+
by_source: dict[str, list[Assertion]] = {}
|
|
733
|
+
for r in rows:
|
|
734
|
+
by_source.setdefault(self._source_class(r, asserted_as_of), []).append(r)
|
|
735
|
+
winners: dict[str, Assertion] = {}
|
|
736
|
+
for sc, rs in by_source.items():
|
|
737
|
+
top = max(rs, key=lambda r: (r.valid_from or float("-inf"), r.asserted_at))
|
|
738
|
+
# Supersession requires world-time progression: rows tied at the
|
|
739
|
+
# winner's valid_from with a DIFFERENT value are a simultaneous
|
|
740
|
+
# contradiction, not an update — flag, serve earliest-asserted
|
|
741
|
+
# (run-4 finding: log order alone must never pick a truth).
|
|
742
|
+
tied = [r for r in rs if r.valid_from == top.valid_from]
|
|
743
|
+
if len({repr(r.value) for r in tied}) > 1:
|
|
744
|
+
earliest = min(tied, key=lambda r: r.asserted_at)
|
|
745
|
+
return FoldResult(
|
|
746
|
+
winner=earliest,
|
|
747
|
+
conflicted=True,
|
|
748
|
+
conflicting=tuple(r.id for r in tied),
|
|
749
|
+
)
|
|
750
|
+
winners[sc] = top
|
|
751
|
+
if len(winners) == 1:
|
|
752
|
+
return FoldResult(winner=next(iter(winners.values())))
|
|
753
|
+
|
|
754
|
+
if is_containment:
|
|
755
|
+
# Movement is time-sequential: the latest-valid move supersedes
|
|
756
|
+
# earlier placements across source classes (HD 002 finding 2).
|
|
757
|
+
# Only a same-latest-valid_from disagreement is a true
|
|
758
|
+
# contradiction — serve earliest-asserted, flag those rows.
|
|
759
|
+
top_vf = max(
|
|
760
|
+
(w.valid_from if w.valid_from is not None else float("-inf"))
|
|
761
|
+
for w in winners.values()
|
|
762
|
+
)
|
|
763
|
+
current = [
|
|
764
|
+
w for w in winners.values()
|
|
765
|
+
if (w.valid_from if w.valid_from is not None else float("-inf")) == top_vf
|
|
766
|
+
]
|
|
767
|
+
if len({repr(w.value) for w in current}) > 1:
|
|
768
|
+
earliest = min(current, key=lambda r: r.asserted_at)
|
|
769
|
+
return FoldResult(
|
|
770
|
+
winner=earliest,
|
|
771
|
+
conflicted=True,
|
|
772
|
+
conflicting=tuple(sorted(r.id for r in current)),
|
|
773
|
+
)
|
|
774
|
+
return FoldResult(winner=max(current, key=lambda r: r.asserted_at))
|
|
775
|
+
|
|
776
|
+
# Multiple source classes on one key: order in-class winners by
|
|
777
|
+
# arrival; the earliest-arrived class is the incumbent.
|
|
778
|
+
ordered = sorted(winners.values(), key=lambda r: r.asserted_at)
|
|
779
|
+
incumbent, rest = ordered[0], ordered[1:]
|
|
780
|
+
serving = incumbent
|
|
781
|
+
corroborations: list[str] = []
|
|
782
|
+
conflicts: list[str] = []
|
|
783
|
+
for challenger in rest:
|
|
784
|
+
if self._values_agree(serving.value, challenger.value):
|
|
785
|
+
corroborations.append(challenger.id)
|
|
786
|
+
serving = challenger # the refinement is the more precise value
|
|
787
|
+
else:
|
|
788
|
+
conflicts.append(challenger.id)
|
|
789
|
+
if conflicts:
|
|
790
|
+
return FoldResult(
|
|
791
|
+
winner=serving if not corroborations else serving,
|
|
792
|
+
conflicted=True,
|
|
793
|
+
conflicting=tuple([incumbent.id, *conflicts]),
|
|
794
|
+
corroborated_by=tuple(corroborations),
|
|
795
|
+
)
|
|
796
|
+
return FoldResult(winner=serving, corroborated_by=tuple(corroborations))
|
|
797
|
+
|
|
798
|
+
def current_state(
|
|
799
|
+
self,
|
|
800
|
+
entity: str,
|
|
801
|
+
frame: str = CANON,
|
|
802
|
+
valid_as_of: float | None = None,
|
|
803
|
+
asserted_as_of: int | None = None,
|
|
804
|
+
correlated: bool = False,
|
|
805
|
+
) -> dict[str, FoldResult]:
|
|
806
|
+
"""All folded keys for one entity (attribute -> result).
|
|
807
|
+
|
|
808
|
+
``correlated=True`` (AWARENESS-READS-V1.1): discover keys over the
|
|
809
|
+
entity's `aka` correlation union and fold each via `state_union` instead
|
|
810
|
+
of the bare closure — the projection-level form of `state_union`. Opt-in;
|
|
811
|
+
the META filter is preserved either way so `aka`/identity never leak."""
|
|
812
|
+
entity = self._resolve(entity)
|
|
813
|
+
if correlated:
|
|
814
|
+
keyset: set[str] = set()
|
|
815
|
+
for member in self._correlation_of(
|
|
816
|
+
entity, valid_as_of=valid_as_of,
|
|
817
|
+
asserted_as_of=asserted_as_of, frame=frame,
|
|
818
|
+
):
|
|
819
|
+
keyset |= self._closure_of(member)
|
|
820
|
+
closure = sorted(keyset)
|
|
821
|
+
else:
|
|
822
|
+
closure = sorted(self._closure_of(entity))
|
|
823
|
+
attrs: set[str] = set()
|
|
824
|
+
for row in self._buffer.visible(
|
|
825
|
+
entity_in=closure,
|
|
826
|
+
frame=frame, valid_as_of=valid_as_of, asserted_as_of=asserted_as_of,
|
|
827
|
+
):
|
|
828
|
+
if row.entity.startswith("a:") or row.entity.startswith(ATTR_PREFIX):
|
|
829
|
+
continue
|
|
830
|
+
if row.attribute in META_ATTRIBUTES:
|
|
831
|
+
continue # identity/meta edges (same_as, distinct_from, …) are
|
|
832
|
+
# machinery, not materialized facts (membrane)
|
|
833
|
+
attrs.add(row.attribute)
|
|
834
|
+
out: dict[str, FoldResult] = {}
|
|
835
|
+
for attr in attrs:
|
|
836
|
+
result = (self.state_union(entity, attr, frame, valid_as_of, asserted_as_of)
|
|
837
|
+
if correlated
|
|
838
|
+
else self.fold_key(entity, attr, frame, valid_as_of, asserted_as_of))
|
|
839
|
+
if result.winner is not None:
|
|
840
|
+
out[self.fold_attribute(attr)] = result
|
|
841
|
+
return out
|
|
842
|
+
|
|
843
|
+
# ---------------------------------------------------------------- walks
|
|
844
|
+
|
|
845
|
+
def locate(
|
|
846
|
+
self,
|
|
847
|
+
entity: str,
|
|
848
|
+
frame: str = CANON,
|
|
849
|
+
valid_as_of: float | None = None,
|
|
850
|
+
asserted_as_of: int | None = None,
|
|
851
|
+
) -> list[str]:
|
|
852
|
+
"""Walk the containment family up to a root. Returns the chain,
|
|
853
|
+
nearest container first. Single-parent: the family fold yields at
|
|
854
|
+
most one edge per entity."""
|
|
855
|
+
chain: list[str] = []
|
|
856
|
+
seen = {self._resolve(entity)}
|
|
857
|
+
current = self._resolve(entity)
|
|
858
|
+
while True:
|
|
859
|
+
result = self.fold_key(current, "in", frame, valid_as_of, asserted_as_of)
|
|
860
|
+
if result.winner is None or result.winner.value_type != "entity":
|
|
861
|
+
return chain
|
|
862
|
+
parent = self._resolve(result.winner.value)
|
|
863
|
+
if parent in seen:
|
|
864
|
+
logger.warning("containment cycle at %s", parent)
|
|
865
|
+
return chain
|
|
866
|
+
chain.append(parent)
|
|
867
|
+
seen.add(parent)
|
|
868
|
+
current = parent
|
|
869
|
+
|
|
870
|
+
def contents(
|
|
871
|
+
self,
|
|
872
|
+
container: str,
|
|
873
|
+
frame: str = CANON,
|
|
874
|
+
valid_as_of: float | None = None,
|
|
875
|
+
asserted_as_of: int | None = None,
|
|
876
|
+
) -> list[str]:
|
|
877
|
+
"""Entities whose folded containment edge lands on `container`.
|
|
878
|
+
Emptiness is this query returning [] — stored nowhere (P2)."""
|
|
879
|
+
container = self._resolve(container)
|
|
880
|
+
members: set[str] = set()
|
|
881
|
+
candidates: set[str] = set()
|
|
882
|
+
for target in sorted(self._closure_of(container)):
|
|
883
|
+
for row in self._buffer.visible(
|
|
884
|
+
attribute_in=sorted(self._semantics.containment_family()), value=target,
|
|
885
|
+
frame=frame, valid_as_of=valid_as_of, asserted_as_of=asserted_as_of,
|
|
886
|
+
):
|
|
887
|
+
if not row.entity.startswith("a:") and not row.entity.startswith(ATTR_PREFIX):
|
|
888
|
+
candidates.add(self._resolve(row.entity))
|
|
889
|
+
for eid in candidates:
|
|
890
|
+
result = self.fold_key(eid, "in", frame, valid_as_of, asserted_as_of)
|
|
891
|
+
if (
|
|
892
|
+
result.winner is not None
|
|
893
|
+
and result.winner.value_type == "entity"
|
|
894
|
+
and self._resolve(result.winner.value) == container
|
|
895
|
+
):
|
|
896
|
+
members.add(eid)
|
|
897
|
+
return sorted(members)
|
|
898
|
+
|
|
899
|
+
# ------------------------------- PLACE-FEATURE-ABSTRACTION-V1 (composition)
|
|
900
|
+
# The compositional axis (`part_of`), mirroring locate/contents but for
|
|
901
|
+
# structural part-of-the-whole, NOT movable location. Conflict HALTS the
|
|
902
|
+
# walk — a co-existing-parent fold is never silently resolved to a winner
|
|
903
|
+
# (Cx 064 #1); the conflict surfaces through state()/truth-maintenance.
|
|
904
|
+
|
|
905
|
+
def composition(
|
|
906
|
+
self,
|
|
907
|
+
entity: str,
|
|
908
|
+
frame: str = CANON,
|
|
909
|
+
valid_as_of: float | None = None,
|
|
910
|
+
asserted_as_of: int | None = None,
|
|
911
|
+
) -> list[str]:
|
|
912
|
+
"""The `part_of` chain upward, nearest whole first — the entity's
|
|
913
|
+
place-in-the-structure. Mirror of `locate` over the compositional axis;
|
|
914
|
+
single-parent, cycle-guarded. A conflicted `part_of` fold stops the walk
|
|
915
|
+
(never picks among parents)."""
|
|
916
|
+
chain: list[str] = []
|
|
917
|
+
seen = {self._resolve(entity)}
|
|
918
|
+
current = self._resolve(entity)
|
|
919
|
+
while True:
|
|
920
|
+
result = self.fold_key(current, "part_of", frame, valid_as_of, asserted_as_of)
|
|
921
|
+
if (result.winner is None or result.conflicted
|
|
922
|
+
or result.winner.value_type != "entity"):
|
|
923
|
+
return chain
|
|
924
|
+
parent = self._resolve(result.winner.value)
|
|
925
|
+
if parent in seen:
|
|
926
|
+
logger.warning("composition cycle at %s", parent)
|
|
927
|
+
return chain
|
|
928
|
+
chain.append(parent)
|
|
929
|
+
seen.add(parent)
|
|
930
|
+
current = parent
|
|
931
|
+
|
|
932
|
+
def features(
|
|
933
|
+
self,
|
|
934
|
+
place: str,
|
|
935
|
+
frame: str = CANON,
|
|
936
|
+
valid_as_of: float | None = None,
|
|
937
|
+
asserted_as_of: int | None = None,
|
|
938
|
+
) -> list[str]:
|
|
939
|
+
"""The `part_of`-children of `place` — its structural sub-features. A
|
|
940
|
+
child is included iff its folded, non-conflicted current `part_of`
|
|
941
|
+
winner resolves to `place` (Cx 064: not "any visible row with
|
|
942
|
+
value=place" — so valid-time/identity/retraction/conflict all align).
|
|
943
|
+
Ordered first-seen/log order, lexical tie-break."""
|
|
944
|
+
place = self._resolve(place)
|
|
945
|
+
first_seen: dict[str, int] = {}
|
|
946
|
+
for target in sorted(self._closure_of(place)):
|
|
947
|
+
for row in self._buffer.visible(
|
|
948
|
+
attribute="part_of", value=target,
|
|
949
|
+
frame=frame, valid_as_of=valid_as_of, asserted_as_of=asserted_as_of,
|
|
950
|
+
):
|
|
951
|
+
if row.entity.startswith("a:") or row.entity.startswith(ATTR_PREFIX):
|
|
952
|
+
continue
|
|
953
|
+
eid = self._resolve(row.entity)
|
|
954
|
+
if eid not in first_seen or row.seq < first_seen[eid]:
|
|
955
|
+
first_seen[eid] = row.seq
|
|
956
|
+
members: list[str] = []
|
|
957
|
+
for eid in first_seen:
|
|
958
|
+
result = self.fold_key(eid, "part_of", frame, valid_as_of, asserted_as_of)
|
|
959
|
+
if (result.winner is not None and not result.conflicted
|
|
960
|
+
and result.winner.value_type == "entity"
|
|
961
|
+
and self._resolve(result.winner.value) == place):
|
|
962
|
+
members.append(eid)
|
|
963
|
+
return sorted(members, key=lambda e: (first_seen[e], e))
|
|
964
|
+
|
|
965
|
+
# --------------------------------- WHO-KNOWS-INVERSE-V1 (transpose of frame_diff)
|
|
966
|
+
|
|
967
|
+
def who_knows(
|
|
968
|
+
self,
|
|
969
|
+
entity: str,
|
|
970
|
+
attribute: str,
|
|
971
|
+
value: Any = None,
|
|
972
|
+
valid_as_of: float | None = None,
|
|
973
|
+
asserted_as_of: int | None = None,
|
|
974
|
+
) -> list[str]:
|
|
975
|
+
"""The `knows:*` observer frames for which `(entity, attribute)` is KNOWN
|
|
976
|
+
(WHO-KNOWS-INVERSE-V1) — the inverse of `frame_diff`, computed not stored
|
|
977
|
+
(a `known_by` edge would breach the membrane). A frame knows the fact iff
|
|
978
|
+
its *folded* winner is present and, when `value` is given, value-matches
|
|
979
|
+
(identity-aware). Folded-not-raw: a superseded/retracted belief no longer
|
|
980
|
+
counts. Candidate frames are key/closure-scoped (only frames that touched
|
|
981
|
+
the key), never a full-log scan. V1 is own-`knows:`-frame membership; the
|
|
982
|
+
`knows:O ∪ public` union and set-valued membership are V1.1."""
|
|
983
|
+
entity = self._resolve(entity)
|
|
984
|
+
fa = self.fold_attribute(attribute)
|
|
985
|
+
attrs = sorted(self._semantics.containment_family()) if fa == _FAMILY_KEY else [attribute]
|
|
986
|
+
closure = sorted(self._closure_of(entity))
|
|
987
|
+
candidates: set[str] = set()
|
|
988
|
+
for row in self._buffer.visible(
|
|
989
|
+
entity_in=closure, attribute_in=attrs,
|
|
990
|
+
valid_as_of=valid_as_of, asserted_as_of=asserted_as_of,
|
|
991
|
+
):
|
|
992
|
+
if row.frame.startswith("knows:"):
|
|
993
|
+
candidates.add(row.frame)
|
|
994
|
+
known: list[str] = []
|
|
995
|
+
for frame in candidates:
|
|
996
|
+
result = self.fold_key(entity, attribute, frame, valid_as_of, asserted_as_of)
|
|
997
|
+
if result.winner is None:
|
|
998
|
+
continue
|
|
999
|
+
if value is None or self._value_matches(result.winner, value):
|
|
1000
|
+
known.append(frame)
|
|
1001
|
+
return sorted(known)
|
|
1002
|
+
|
|
1003
|
+
def _value_matches(self, winner: Assertion, value: Any) -> bool:
|
|
1004
|
+
"""Identity-aware value match: entity values compare by resolved id,
|
|
1005
|
+
literals by equality."""
|
|
1006
|
+
if winner.value_type == "entity" and isinstance(winner.value, str) \
|
|
1007
|
+
and isinstance(value, str):
|
|
1008
|
+
return self._resolve(winner.value) == self._resolve(value)
|
|
1009
|
+
return winner.value == value
|
|
1010
|
+
|
|
1011
|
+
def aggregate(
|
|
1012
|
+
self,
|
|
1013
|
+
container: str,
|
|
1014
|
+
member_attribute: str,
|
|
1015
|
+
op: str,
|
|
1016
|
+
frame: str = CANON,
|
|
1017
|
+
as_of: float | None = None,
|
|
1018
|
+
recursive: bool = False,
|
|
1019
|
+
asserted_as_of: int | None = None,
|
|
1020
|
+
) -> dict[str, Any]:
|
|
1021
|
+
"""Bounded numeric rollup over a container's folded member values."""
|
|
1022
|
+
if op not in {"sum", "count", "min", "max", "avg"}:
|
|
1023
|
+
raise ValueError(f"unknown aggregate operator {op!r}")
|
|
1024
|
+
|
|
1025
|
+
subject = self._resolve(container)
|
|
1026
|
+
|
|
1027
|
+
def members() -> list[str]:
|
|
1028
|
+
direct = self.contents(subject, frame, as_of, asserted_as_of)
|
|
1029
|
+
if not recursive:
|
|
1030
|
+
return direct
|
|
1031
|
+
out: list[str] = []
|
|
1032
|
+
seen = {subject}
|
|
1033
|
+
queue = list(direct)
|
|
1034
|
+
while queue:
|
|
1035
|
+
member = self._resolve(queue.pop(0))
|
|
1036
|
+
if member in seen:
|
|
1037
|
+
continue
|
|
1038
|
+
seen.add(member)
|
|
1039
|
+
out.append(member)
|
|
1040
|
+
queue.extend(self.contents(member, frame, as_of, asserted_as_of))
|
|
1041
|
+
return out
|
|
1042
|
+
|
|
1043
|
+
contributing: list[str] = []
|
|
1044
|
+
values: list[int | float | Decimal] = []
|
|
1045
|
+
if not self._semantics.is_set_valued(member_attribute):
|
|
1046
|
+
for member in members():
|
|
1047
|
+
folded = self.fold_key(
|
|
1048
|
+
member, member_attribute, frame, as_of, asserted_as_of
|
|
1049
|
+
)
|
|
1050
|
+
if self._semantics.is_accrue(member_attribute):
|
|
1051
|
+
value = folded.quantity
|
|
1052
|
+
elif folded.winner is not None:
|
|
1053
|
+
value = folded.winner.value
|
|
1054
|
+
else:
|
|
1055
|
+
value = None
|
|
1056
|
+
if self._is_numeric(value):
|
|
1057
|
+
contributing.append(member)
|
|
1058
|
+
values.append(value)
|
|
1059
|
+
|
|
1060
|
+
# Mix validation runs on the contributing set BEFORE any op —
|
|
1061
|
+
# a Decimal/float mix is the same authoring smell for min/max/count
|
|
1062
|
+
# as for sum/avg (EXACT-DECIMAL-QUANTITIES-V1 §4).
|
|
1063
|
+
check_no_mix(values, ids=contributing)
|
|
1064
|
+
count = len(values)
|
|
1065
|
+
if op == "count":
|
|
1066
|
+
value = count
|
|
1067
|
+
elif op == "sum":
|
|
1068
|
+
value = exact_sum(values, ids=contributing) if values else 0
|
|
1069
|
+
elif op == "min":
|
|
1070
|
+
value = min(values) if values else None
|
|
1071
|
+
elif op == "max":
|
|
1072
|
+
value = max(values) if values else None
|
|
1073
|
+
else:
|
|
1074
|
+
value = (
|
|
1075
|
+
exact_div(exact_sum(values, ids=contributing), count)
|
|
1076
|
+
if count else None
|
|
1077
|
+
)
|
|
1078
|
+
return {
|
|
1079
|
+
"op": op,
|
|
1080
|
+
"value": value,
|
|
1081
|
+
"count": count,
|
|
1082
|
+
"members": contributing,
|
|
1083
|
+
"container": subject,
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
def lateral_neighbors(
|
|
1087
|
+
self,
|
|
1088
|
+
entity: str,
|
|
1089
|
+
frame: str = CANON,
|
|
1090
|
+
valid_as_of: float | None = None,
|
|
1091
|
+
asserted_as_of: int | None = None,
|
|
1092
|
+
) -> list[str]:
|
|
1093
|
+
"""One indexed hop over the lateral family from an identity closure."""
|
|
1094
|
+
entity = self._resolve(entity)
|
|
1095
|
+
attrs = sorted(self._semantics.lateral_family())
|
|
1096
|
+
out: set[str] = set()
|
|
1097
|
+
for eid in sorted(self._closure_of(entity)):
|
|
1098
|
+
for row in self._buffer.visible(
|
|
1099
|
+
entity=eid,
|
|
1100
|
+
attribute_in=attrs,
|
|
1101
|
+
value_type="entity",
|
|
1102
|
+
frame=frame,
|
|
1103
|
+
valid_as_of=valid_as_of,
|
|
1104
|
+
asserted_as_of=asserted_as_of,
|
|
1105
|
+
):
|
|
1106
|
+
if isinstance(row.value, str):
|
|
1107
|
+
out.add(self._resolve(row.value))
|
|
1108
|
+
for row in self._buffer.visible(
|
|
1109
|
+
attribute_in=attrs,
|
|
1110
|
+
value=eid,
|
|
1111
|
+
value_type="entity",
|
|
1112
|
+
frame=frame,
|
|
1113
|
+
valid_as_of=valid_as_of,
|
|
1114
|
+
asserted_as_of=asserted_as_of,
|
|
1115
|
+
):
|
|
1116
|
+
if not row.entity.startswith("a:") and not row.entity.startswith(ATTR_PREFIX):
|
|
1117
|
+
out.add(self._resolve(row.entity))
|
|
1118
|
+
out.discard(entity)
|
|
1119
|
+
return sorted(out)
|
|
1120
|
+
|
|
1121
|
+
def event_participation(
|
|
1122
|
+
self,
|
|
1123
|
+
entity: str,
|
|
1124
|
+
frame: str = CANON,
|
|
1125
|
+
valid_as_of: float | None = None,
|
|
1126
|
+
asserted_as_of: int | None = None,
|
|
1127
|
+
) -> list[str]:
|
|
1128
|
+
"""Events whose participant row points at this entity's closure.
|
|
1129
|
+
|
|
1130
|
+
EVENT rows are intentionally not folded, so this reads visible rows
|
|
1131
|
+
directly through indexed participant/value filters.
|
|
1132
|
+
"""
|
|
1133
|
+
entity = self._resolve(entity)
|
|
1134
|
+
out: set[str] = set()
|
|
1135
|
+
for eid in sorted(self._closure_of(entity)):
|
|
1136
|
+
for row in self._buffer.visible(
|
|
1137
|
+
attribute_in=["agent", "patient"],
|
|
1138
|
+
value=eid,
|
|
1139
|
+
frame=frame,
|
|
1140
|
+
valid_as_of=valid_as_of,
|
|
1141
|
+
asserted_as_of=asserted_as_of,
|
|
1142
|
+
):
|
|
1143
|
+
if not row.entity.startswith("a:") and not row.entity.startswith(ATTR_PREFIX):
|
|
1144
|
+
out.add(self._resolve(row.entity))
|
|
1145
|
+
return sorted(out)
|
|
1146
|
+
|
|
1147
|
+
def caused_by_of(
|
|
1148
|
+
self,
|
|
1149
|
+
event_ids: list[str] | set[str] | tuple[str, ...],
|
|
1150
|
+
frame: str = CANON,
|
|
1151
|
+
valid_as_of: float | None = None,
|
|
1152
|
+
asserted_as_of: int | None = None,
|
|
1153
|
+
) -> list[str]:
|
|
1154
|
+
# Identity-closure-scoped: a caused_by row may sit on any member of a
|
|
1155
|
+
# merged event's closure, not only the canonical id (post-impl review).
|
|
1156
|
+
events: set[str] = set()
|
|
1157
|
+
for e in event_ids:
|
|
1158
|
+
events |= self._closure_of(self._resolve(e))
|
|
1159
|
+
events = sorted(events)
|
|
1160
|
+
if not events:
|
|
1161
|
+
return []
|
|
1162
|
+
out: set[str] = set()
|
|
1163
|
+
for row in self._buffer.visible(
|
|
1164
|
+
entity_in=events,
|
|
1165
|
+
attribute="caused_by",
|
|
1166
|
+
value_type="entity",
|
|
1167
|
+
frame=frame,
|
|
1168
|
+
valid_as_of=valid_as_of,
|
|
1169
|
+
asserted_as_of=asserted_as_of,
|
|
1170
|
+
):
|
|
1171
|
+
if isinstance(row.value, str):
|
|
1172
|
+
out.add(self._resolve(row.value))
|
|
1173
|
+
return sorted(out)
|
|
1174
|
+
|
|
1175
|
+
def incoming_refs(
|
|
1176
|
+
self,
|
|
1177
|
+
entity: str,
|
|
1178
|
+
frame: str = CANON,
|
|
1179
|
+
valid_as_of: float | None = None,
|
|
1180
|
+
asserted_as_of: int | None = None,
|
|
1181
|
+
) -> list[str]:
|
|
1182
|
+
"""Entities with entity-valued rows pointing at the full closure."""
|
|
1183
|
+
entity = self._resolve(entity)
|
|
1184
|
+
out: set[str] = set()
|
|
1185
|
+
for eid in sorted(self._closure_of(entity)):
|
|
1186
|
+
for row in self._buffer.visible(
|
|
1187
|
+
value=eid,
|
|
1188
|
+
value_type="entity",
|
|
1189
|
+
frame=frame,
|
|
1190
|
+
valid_as_of=valid_as_of,
|
|
1191
|
+
asserted_as_of=asserted_as_of,
|
|
1192
|
+
):
|
|
1193
|
+
if row.entity.startswith("a:") or row.entity.startswith(ATTR_PREFIX):
|
|
1194
|
+
continue
|
|
1195
|
+
out.add(self._resolve(row.entity))
|
|
1196
|
+
return sorted(out)
|
|
1197
|
+
|
|
1198
|
+
# --------------------------------------------------------- neighborhood
|
|
1199
|
+
|
|
1200
|
+
def _fact_payload(self, row: Assertion) -> dict[str, Any]:
|
|
1201
|
+
durability = self._classifier.durability(row.id)
|
|
1202
|
+
out = {
|
|
1203
|
+
"entity": self._resolve(row.entity),
|
|
1204
|
+
"attribute": row.attribute,
|
|
1205
|
+
"value": encode_out(row.value), # recursive: nested Decimal leaves too
|
|
1206
|
+
"value_type": row.value_type,
|
|
1207
|
+
"valid": [row.valid_from, row.valid_to],
|
|
1208
|
+
"provenance": {
|
|
1209
|
+
"status": row.status,
|
|
1210
|
+
"assertion_id": row.id,
|
|
1211
|
+
"durability": durability,
|
|
1212
|
+
},
|
|
1213
|
+
}
|
|
1214
|
+
if row.value_type == "unresolved":
|
|
1215
|
+
out["status"] = "unresolved"
|
|
1216
|
+
if isinstance(row.value, dict):
|
|
1217
|
+
out["policy"] = row.value.get("policy")
|
|
1218
|
+
return out
|
|
1219
|
+
|
|
1220
|
+
def _state_payload(
|
|
1221
|
+
self,
|
|
1222
|
+
entity: str,
|
|
1223
|
+
frame: str,
|
|
1224
|
+
valid_as_of: float | None,
|
|
1225
|
+
asserted_as_of: int | None,
|
|
1226
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
1227
|
+
facts: list[dict[str, Any]] = []
|
|
1228
|
+
quantities: list[dict[str, Any]] = []
|
|
1229
|
+
for attr, result in sorted(
|
|
1230
|
+
self.current_state(entity, frame, valid_as_of, asserted_as_of).items()
|
|
1231
|
+
):
|
|
1232
|
+
if result.quantity is not None and result.winner is not None:
|
|
1233
|
+
quantities.append(
|
|
1234
|
+
{
|
|
1235
|
+
"entity": self._resolve(entity),
|
|
1236
|
+
"attribute": attr,
|
|
1237
|
+
"value": encode_value(result.quantity),
|
|
1238
|
+
"provenance": {
|
|
1239
|
+
"status": result.winner.status,
|
|
1240
|
+
"assertion_id": result.winner.id,
|
|
1241
|
+
"durability": self._classifier.durability(result.winner.id),
|
|
1242
|
+
},
|
|
1243
|
+
}
|
|
1244
|
+
)
|
|
1245
|
+
continue
|
|
1246
|
+
for row in result._value_rows or (
|
|
1247
|
+
(result.winner,) if result.winner is not None else ()
|
|
1248
|
+
):
|
|
1249
|
+
facts.append(self._fact_payload(row))
|
|
1250
|
+
return facts, quantities
|
|
1251
|
+
|
|
1252
|
+
def _entity_relation_neighbors(
|
|
1253
|
+
self,
|
|
1254
|
+
entity: str,
|
|
1255
|
+
frame: str,
|
|
1256
|
+
valid_as_of: float | None,
|
|
1257
|
+
asserted_as_of: int | None,
|
|
1258
|
+
) -> list[str]:
|
|
1259
|
+
out: set[str] = set()
|
|
1260
|
+
skip = self._semantics.containment_family() | self._semantics.lateral_family()
|
|
1261
|
+
for attr, result in self.current_state(
|
|
1262
|
+
entity, frame, valid_as_of, asserted_as_of
|
|
1263
|
+
).items():
|
|
1264
|
+
if attr in skip:
|
|
1265
|
+
continue
|
|
1266
|
+
for row in result._value_rows or (
|
|
1267
|
+
(result.winner,) if result.winner is not None else ()
|
|
1268
|
+
):
|
|
1269
|
+
if row.value_type == "entity" and isinstance(row.value, str):
|
|
1270
|
+
out.add(self._resolve(row.value))
|
|
1271
|
+
out.discard(self._resolve(entity))
|
|
1272
|
+
return sorted(out)
|
|
1273
|
+
|
|
1274
|
+
def _containment_edge_protected(
|
|
1275
|
+
self,
|
|
1276
|
+
child: str,
|
|
1277
|
+
parent: str,
|
|
1278
|
+
frame: str,
|
|
1279
|
+
valid_as_of: float | None,
|
|
1280
|
+
asserted_as_of: int | None,
|
|
1281
|
+
) -> bool:
|
|
1282
|
+
result = self.fold_key(child, "in", frame, valid_as_of, asserted_as_of)
|
|
1283
|
+
return (
|
|
1284
|
+
result.winner is not None
|
|
1285
|
+
and result.winner.value_type == "entity"
|
|
1286
|
+
and self._resolve(result.winner.value) == self._resolve(parent)
|
|
1287
|
+
and self._classifier.durability(result.winner.id) == CONSTITUTIVE
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
def neighborhood(
|
|
1291
|
+
self,
|
|
1292
|
+
entity: str,
|
|
1293
|
+
depth: int = 1,
|
|
1294
|
+
frame: str = CANON,
|
|
1295
|
+
as_of: float | None = None,
|
|
1296
|
+
edge_kinds: list[str] | set[str] | tuple[str, ...] | None = None,
|
|
1297
|
+
max_fanout: int = 64,
|
|
1298
|
+
budget: int | None = None,
|
|
1299
|
+
asserted_as_of: int | None = None,
|
|
1300
|
+
) -> dict[str, Any]:
|
|
1301
|
+
"""Bounded structural neighborhood around one identity-closed entity."""
|
|
1302
|
+
subject = self._resolve(entity)
|
|
1303
|
+
allowed = set(_NEIGHBORHOOD_EDGES if edge_kinds is None else edge_kinds)
|
|
1304
|
+
unknown = allowed - _NEIGHBORHOOD_EDGES
|
|
1305
|
+
if unknown:
|
|
1306
|
+
raise ValueError(f"unknown neighborhood edge kind(s): {sorted(unknown)}")
|
|
1307
|
+
depth = max(0, min(int(depth), _NEIGHBORHOOD_DEPTH_CAP))
|
|
1308
|
+
max_fanout = max(0, int(max_fanout))
|
|
1309
|
+
subject_facts, subject_quantities = self._state_payload(
|
|
1310
|
+
subject, frame, as_of, asserted_as_of
|
|
1311
|
+
)
|
|
1312
|
+
out: dict[str, Any] = {
|
|
1313
|
+
"subject": {
|
|
1314
|
+
"entity": subject,
|
|
1315
|
+
"facts": subject_facts,
|
|
1316
|
+
"quantities": subject_quantities,
|
|
1317
|
+
"location": self.locate(subject, frame, as_of, asserted_as_of),
|
|
1318
|
+
"contents": self.contents(subject, frame, as_of, asserted_as_of),
|
|
1319
|
+
},
|
|
1320
|
+
"neighbors": [],
|
|
1321
|
+
"truncated": 0,
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
def score(eid: str) -> float:
|
|
1325
|
+
return float(self._salience(eid, frame, as_of))
|
|
1326
|
+
|
|
1327
|
+
neighbors: dict[str, dict[str, Any]] = {}
|
|
1328
|
+
queue: list[tuple[str, int]] = [(subject, 0)]
|
|
1329
|
+
seen = {subject}
|
|
1330
|
+
expanded: set[str] = set()
|
|
1331
|
+
while queue:
|
|
1332
|
+
current, hop = queue.pop(0)
|
|
1333
|
+
if current in expanded or hop >= depth:
|
|
1334
|
+
continue
|
|
1335
|
+
expanded.add(current)
|
|
1336
|
+
candidates: dict[str, dict[str, Any]] = {}
|
|
1337
|
+
|
|
1338
|
+
def add_candidate(eid: str, via: str, protected: bool = False) -> None:
|
|
1339
|
+
resolved = self._resolve(eid)
|
|
1340
|
+
if resolved == subject:
|
|
1341
|
+
return
|
|
1342
|
+
existing = candidates.get(resolved)
|
|
1343
|
+
if existing is None:
|
|
1344
|
+
candidates[resolved] = {
|
|
1345
|
+
"entity": resolved,
|
|
1346
|
+
"via": via,
|
|
1347
|
+
"protected": protected,
|
|
1348
|
+
}
|
|
1349
|
+
else:
|
|
1350
|
+
existing["protected"] = existing["protected"] or protected
|
|
1351
|
+
|
|
1352
|
+
if "containment" in allowed:
|
|
1353
|
+
chain = self.locate(current, frame, as_of, asserted_as_of)
|
|
1354
|
+
if chain:
|
|
1355
|
+
parent = chain[0]
|
|
1356
|
+
add_candidate(
|
|
1357
|
+
parent,
|
|
1358
|
+
"containment",
|
|
1359
|
+
self._containment_edge_protected(
|
|
1360
|
+
current, parent, frame, as_of, asserted_as_of
|
|
1361
|
+
),
|
|
1362
|
+
)
|
|
1363
|
+
for child in self.contents(current, frame, as_of, asserted_as_of):
|
|
1364
|
+
add_candidate(
|
|
1365
|
+
child,
|
|
1366
|
+
"containment",
|
|
1367
|
+
self._containment_edge_protected(
|
|
1368
|
+
child, current, frame, as_of, asserted_as_of
|
|
1369
|
+
),
|
|
1370
|
+
)
|
|
1371
|
+
if "lateral" in allowed:
|
|
1372
|
+
for neighbor in self.lateral_neighbors(current, frame, as_of, asserted_as_of):
|
|
1373
|
+
add_candidate(neighbor, "lateral")
|
|
1374
|
+
if "relations" in allowed:
|
|
1375
|
+
for neighbor in self._entity_relation_neighbors(
|
|
1376
|
+
current, frame, as_of, asserted_as_of
|
|
1377
|
+
):
|
|
1378
|
+
add_candidate(neighbor, "relations")
|
|
1379
|
+
if "events" in allowed:
|
|
1380
|
+
events = self.event_participation(current, frame, as_of, asserted_as_of)
|
|
1381
|
+
for event_id in events:
|
|
1382
|
+
add_candidate(event_id, "events")
|
|
1383
|
+
causal_sources = set(events)
|
|
1384
|
+
if current.startswith("event:"):
|
|
1385
|
+
causal_sources.add(current)
|
|
1386
|
+
for cause in self.caused_by_of(
|
|
1387
|
+
causal_sources, frame, as_of, asserted_as_of
|
|
1388
|
+
):
|
|
1389
|
+
add_candidate(cause, "events")
|
|
1390
|
+
|
|
1391
|
+
ranked = sorted(
|
|
1392
|
+
candidates.values(),
|
|
1393
|
+
key=lambda c: (score(c["entity"]), c["entity"]),
|
|
1394
|
+
reverse=True,
|
|
1395
|
+
)
|
|
1396
|
+
if len(ranked) > max_fanout:
|
|
1397
|
+
out["truncated"] += len(ranked) - max_fanout
|
|
1398
|
+
ranked = ranked[:max_fanout]
|
|
1399
|
+
for candidate in ranked:
|
|
1400
|
+
eid = candidate["entity"]
|
|
1401
|
+
if eid not in neighbors:
|
|
1402
|
+
facts, quantities = self._state_payload(eid, frame, as_of, asserted_as_of)
|
|
1403
|
+
neighbors[eid] = {
|
|
1404
|
+
"entity": eid,
|
|
1405
|
+
"via": candidate["via"],
|
|
1406
|
+
"hop": hop + 1,
|
|
1407
|
+
"salience": score(eid),
|
|
1408
|
+
"facts": facts,
|
|
1409
|
+
"quantities": quantities,
|
|
1410
|
+
"_protected": bool(candidate["protected"]),
|
|
1411
|
+
}
|
|
1412
|
+
elif candidate["protected"]:
|
|
1413
|
+
neighbors[eid]["_protected"] = True
|
|
1414
|
+
if eid not in seen:
|
|
1415
|
+
seen.add(eid)
|
|
1416
|
+
queue.append((eid, hop + 1))
|
|
1417
|
+
|
|
1418
|
+
shaped = sorted(
|
|
1419
|
+
neighbors.values(),
|
|
1420
|
+
key=lambda n: (n["salience"], -n["hop"], n["entity"]),
|
|
1421
|
+
reverse=True,
|
|
1422
|
+
)
|
|
1423
|
+
if budget is not None and len(shaped) > budget:
|
|
1424
|
+
protected = [n for n in shaped if n["_protected"]]
|
|
1425
|
+
rest = [n for n in shaped if not n["_protected"]]
|
|
1426
|
+
keep = max(0, int(budget) - len(protected))
|
|
1427
|
+
out["truncated"] += max(0, len(rest) - keep)
|
|
1428
|
+
shaped = sorted(
|
|
1429
|
+
protected + rest[:keep],
|
|
1430
|
+
key=lambda n: (n["salience"], -n["hop"], n["entity"]),
|
|
1431
|
+
reverse=True,
|
|
1432
|
+
)
|
|
1433
|
+
for neighbor in shaped:
|
|
1434
|
+
neighbor.pop("_protected", None)
|
|
1435
|
+
out["neighbors"] = shaped
|
|
1436
|
+
return out
|
|
1437
|
+
|
|
1438
|
+
def path(
|
|
1439
|
+
self,
|
|
1440
|
+
a: str,
|
|
1441
|
+
b: str,
|
|
1442
|
+
frame: str = CANON,
|
|
1443
|
+
valid_as_of: float | None = None,
|
|
1444
|
+
asserted_as_of: int | None = None,
|
|
1445
|
+
) -> list[str] | None:
|
|
1446
|
+
"""BFS over the lateral graph (connects_to/adjacent_to, undirected).
|
|
1447
|
+
None = no path: vertical proximity is not connectivity.
|
|
1448
|
+
|
|
1449
|
+
``valid_as_of`` (PATH-TEMPORAL-V1): when given, edges are filtered to
|
|
1450
|
+
those valid at that time, so a severed edge (one whose ``valid_to`` has
|
|
1451
|
+
passed) drops from current routing while remaining visible to an earlier
|
|
1452
|
+
as-of query — `removed` is derived from temporal validity, never a
|
|
1453
|
+
stored flag. Default ``None`` = no bound (unchanged)."""
|
|
1454
|
+
a, b = self._resolve(a), self._resolve(b)
|
|
1455
|
+
edges: dict[str, set[str]] = {}
|
|
1456
|
+
for row in self._buffer.visible(
|
|
1457
|
+
valid_as_of=valid_as_of, asserted_as_of=asserted_as_of, frame=frame
|
|
1458
|
+
):
|
|
1459
|
+
if (
|
|
1460
|
+
row.attribute in self._semantics.lateral_family()
|
|
1461
|
+
and row.value_type == "entity"
|
|
1462
|
+
and not row.entity.startswith("a:")
|
|
1463
|
+
and not row.entity.startswith(ATTR_PREFIX)
|
|
1464
|
+
):
|
|
1465
|
+
x, y = self._resolve(row.entity), self._resolve(row.value)
|
|
1466
|
+
edges.setdefault(x, set()).add(y)
|
|
1467
|
+
edges.setdefault(y, set()).add(x)
|
|
1468
|
+
if a == b:
|
|
1469
|
+
return [a]
|
|
1470
|
+
frontier = [[a]]
|
|
1471
|
+
visited = {a}
|
|
1472
|
+
while frontier:
|
|
1473
|
+
path_so_far = frontier.pop(0)
|
|
1474
|
+
for nxt in sorted(edges.get(path_so_far[-1], ())):
|
|
1475
|
+
if nxt in visited:
|
|
1476
|
+
continue
|
|
1477
|
+
if nxt == b:
|
|
1478
|
+
return path_so_far + [nxt]
|
|
1479
|
+
visited.add(nxt)
|
|
1480
|
+
frontier.append(path_so_far + [nxt])
|
|
1481
|
+
return None
|
|
1482
|
+
|
|
1483
|
+
# ------------------------------------------- traversability (RFC-003)
|
|
1484
|
+
|
|
1485
|
+
def _traversal_policy(self, kind: str) -> tuple[set, set] | None:
|
|
1486
|
+
"""A declared traversal policy for a portal `kind` (RFC-003 §3), or None
|
|
1487
|
+
if the kind is not a gating portal. Host-declared as `traversal:<kind>`
|
|
1488
|
+
meta-assertions, scoped to the kind (a shut cabinet ≠ a shut door):
|
|
1489
|
+
`blocks_when_state` values and `blocks_when_relation` attrs. Presence of
|
|
1490
|
+
any declaration marks the kind as gating (so absence-of-state → obscured)."""
|
|
1491
|
+
rows = self._buffer.visible(entity=f"traversal:{kind}")
|
|
1492
|
+
states = {r.value for r in rows if r.attribute == "blocks_when_state"}
|
|
1493
|
+
rels = {r.value for r in rows if r.attribute == "blocks_when_relation"}
|
|
1494
|
+
if not states and not rels:
|
|
1495
|
+
return None # unrelated traversal:<kind> metadata ≠ a gating policy
|
|
1496
|
+
return (states, rels)
|
|
1497
|
+
|
|
1498
|
+
def _classify_portal(
|
|
1499
|
+
self, node: str, frame: str, valid_as_of: float | None, asserted_as_of: int | None
|
|
1500
|
+
) -> tuple[str, dict | None]:
|
|
1501
|
+
"""Derive a portal node's traversability status + evidence (RFC-003 §1/§4),
|
|
1502
|
+
never stored. clear (transparent / passable) | blocked (a declared
|
|
1503
|
+
blocking state or relation) | obscured (gating kind, state unestablished
|
|
1504
|
+
— the unknown doctrine, computed unknown_basis, never a fake row)."""
|
|
1505
|
+
kind_fold = self.fold_key(node, "kind", frame, valid_as_of, asserted_as_of)
|
|
1506
|
+
kind = kind_fold.winner.value if kind_fold.winner is not None else None
|
|
1507
|
+
policy = self._traversal_policy(kind) if isinstance(kind, str) else None
|
|
1508
|
+
if policy is None:
|
|
1509
|
+
return ("clear", None) # not a gating portal kind — transparent
|
|
1510
|
+
blocking_states, blocking_rels = policy
|
|
1511
|
+
closure = sorted(self._closure_of(node))
|
|
1512
|
+
for attr in sorted(blocking_rels):
|
|
1513
|
+
for r in self._buffer.visible(
|
|
1514
|
+
entity_in=closure, attribute=attr, value_type="entity",
|
|
1515
|
+
frame=frame, valid_as_of=valid_as_of, asserted_as_of=asserted_as_of,
|
|
1516
|
+
):
|
|
1517
|
+
obj = self._resolve(r.value) if isinstance(r.value, str) else r.value
|
|
1518
|
+
return ("blocked", {"evidence": [{
|
|
1519
|
+
"entity": self._resolve(r.entity), "attribute": r.attribute,
|
|
1520
|
+
"value": encode_value(obj), "assertion_id": r.id}]})
|
|
1521
|
+
state_fold = self.fold_key(node, "state", frame, valid_as_of, asserted_as_of)
|
|
1522
|
+
w = state_fold.winner
|
|
1523
|
+
if w is None:
|
|
1524
|
+
return ("obscured", {"unknown_basis": {
|
|
1525
|
+
"kind": "relational_absence", "portal": self._resolve(node),
|
|
1526
|
+
"required_attribute": "state", "frame": frame, "as_of": valid_as_of}})
|
|
1527
|
+
if w.value_type == "unresolved":
|
|
1528
|
+
return ("obscured", {"unknown_basis": {
|
|
1529
|
+
"kind": "unresolved_thunk", "portal": self._resolve(node),
|
|
1530
|
+
"required_attribute": "state", "assertion_id": w.id,
|
|
1531
|
+
"frame": frame, "as_of": valid_as_of}})
|
|
1532
|
+
if w.value in blocking_states:
|
|
1533
|
+
return ("blocked", {"evidence": [{
|
|
1534
|
+
"entity": self._resolve(node), "attribute": "state",
|
|
1535
|
+
"value": encode_value(w.value), "assertion_id": w.id}]})
|
|
1536
|
+
return ("clear", None)
|
|
1537
|
+
|
|
1538
|
+
def route(
|
|
1539
|
+
self, a: str, b: str, frame: str = CANON,
|
|
1540
|
+
valid_as_of: float | None = None, asserted_as_of: int | None = None,
|
|
1541
|
+
) -> dict:
|
|
1542
|
+
"""Passability-aware routing (RFC-003): the structural `path()` graph,
|
|
1543
|
+
with each portal segment classified under the declared traversal policy.
|
|
1544
|
+
Two-pass (classify, never delete-then-BFS): a clear route if one exists,
|
|
1545
|
+
else the structural route with blocked/obscured segments flagged; only
|
|
1546
|
+
`no_path` when the as-of lateral graph has no route at all — so
|
|
1547
|
+
"all known ways are blocked" never collapses to "no path exists"."""
|
|
1548
|
+
a, b = self._resolve(a), self._resolve(b)
|
|
1549
|
+
if a == b:
|
|
1550
|
+
# routing to self: reflect the node's own status, never a forced
|
|
1551
|
+
# clear (Cx final review — a==b on a blocked portal is not clear).
|
|
1552
|
+
st, p = self._classify_portal(a, frame, valid_as_of, asserted_as_of)
|
|
1553
|
+
seg = {"node": a, "status": st}
|
|
1554
|
+
if p:
|
|
1555
|
+
seg.update(p)
|
|
1556
|
+
return {"route": [a], "status": st, "segments": [seg]}
|
|
1557
|
+
edges: dict[str, set[str]] = {}
|
|
1558
|
+
for row in self._buffer.visible(
|
|
1559
|
+
valid_as_of=valid_as_of, asserted_as_of=asserted_as_of, frame=frame
|
|
1560
|
+
):
|
|
1561
|
+
if (row.attribute in self._semantics.lateral_family()
|
|
1562
|
+
and row.value_type == "entity"
|
|
1563
|
+
and not row.entity.startswith("a:")
|
|
1564
|
+
and not row.entity.startswith(ATTR_PREFIX)):
|
|
1565
|
+
x, y = self._resolve(row.entity), self._resolve(row.value)
|
|
1566
|
+
edges.setdefault(x, set()).add(y)
|
|
1567
|
+
edges.setdefault(y, set()).add(x)
|
|
1568
|
+
|
|
1569
|
+
status_of: dict[str, str] = {}
|
|
1570
|
+
payload_of: dict[str, dict | None] = {}
|
|
1571
|
+
for node in edges:
|
|
1572
|
+
s, p = self._classify_portal(node, frame, valid_as_of, asserted_as_of)
|
|
1573
|
+
status_of[node] = s
|
|
1574
|
+
payload_of[node] = p
|
|
1575
|
+
|
|
1576
|
+
def bfs(avoid_nonclear: bool) -> list[str] | None:
|
|
1577
|
+
if a == b:
|
|
1578
|
+
return [a]
|
|
1579
|
+
# a clear route requires clear endpoints too (Cx final review): a
|
|
1580
|
+
# non-clear source has no clear route, and a non-clear destination
|
|
1581
|
+
# is rejected by the same per-node check below before the b-return.
|
|
1582
|
+
if avoid_nonclear and status_of.get(a, "clear") != "clear":
|
|
1583
|
+
return None
|
|
1584
|
+
frontier, visited = [[a]], {a}
|
|
1585
|
+
while frontier:
|
|
1586
|
+
cur = frontier.pop(0)
|
|
1587
|
+
for nxt in sorted(edges.get(cur[-1], ())):
|
|
1588
|
+
if nxt in visited:
|
|
1589
|
+
continue
|
|
1590
|
+
if avoid_nonclear and status_of.get(nxt, "clear") != "clear":
|
|
1591
|
+
continue # skip non-clear portals — including b itself
|
|
1592
|
+
if nxt == b:
|
|
1593
|
+
return cur + [nxt]
|
|
1594
|
+
visited.add(nxt)
|
|
1595
|
+
frontier.append(cur + [nxt])
|
|
1596
|
+
return None
|
|
1597
|
+
|
|
1598
|
+
def result(route_nodes: list[str], overall: str) -> dict:
|
|
1599
|
+
segs = []
|
|
1600
|
+
for n in route_nodes:
|
|
1601
|
+
seg = {"node": n, "status": status_of.get(n, "clear")}
|
|
1602
|
+
if payload_of.get(n):
|
|
1603
|
+
seg.update(payload_of[n])
|
|
1604
|
+
segs.append(seg)
|
|
1605
|
+
return {"route": route_nodes, "status": overall, "segments": segs}
|
|
1606
|
+
|
|
1607
|
+
clear = bfs(avoid_nonclear=True)
|
|
1608
|
+
if clear is not None:
|
|
1609
|
+
return result(clear, "clear")
|
|
1610
|
+
structural = bfs(avoid_nonclear=False)
|
|
1611
|
+
if structural is None:
|
|
1612
|
+
return {"route": None, "status": "no_path", "segments": [],
|
|
1613
|
+
"former_passages": self._former_passages(
|
|
1614
|
+
a, b, frame, valid_as_of, asserted_as_of)}
|
|
1615
|
+
overall = "blocked" if any(status_of.get(n) == "blocked" for n in structural) else "obscured"
|
|
1616
|
+
return result(structural, overall)
|
|
1617
|
+
|
|
1618
|
+
def _former_passages(
|
|
1619
|
+
self, a: str, b: str, frame: str,
|
|
1620
|
+
valid_as_of: float | None, asserted_as_of: int | None,
|
|
1621
|
+
) -> list[dict]:
|
|
1622
|
+
"""`removed` diagnostic (RFC-003 §4): lateral edges that **ended on or
|
|
1623
|
+
before** the query `valid_as_of` (`valid_to <= valid_as_of`) and touch a
|
|
1624
|
+
or b — former passages, history kept. Only meaningful under an explicit
|
|
1625
|
+
as-of (no `valid_as_of` → no "now" to be former relative to → []). Never
|
|
1626
|
+
a current segment; surfaced only to explain a missing connection."""
|
|
1627
|
+
if valid_as_of is None:
|
|
1628
|
+
return []
|
|
1629
|
+
endpoints = self._closure_of(a) | self._closure_of(b)
|
|
1630
|
+
out: list[dict] = []
|
|
1631
|
+
for row in self._buffer.visible(asserted_as_of=asserted_as_of, frame=frame):
|
|
1632
|
+
if (row.attribute in self._semantics.lateral_family()
|
|
1633
|
+
and row.value_type == "entity"
|
|
1634
|
+
and isinstance(row.value, str)
|
|
1635
|
+
and row.valid_to is not None
|
|
1636
|
+
and row.valid_to <= valid_as_of
|
|
1637
|
+
and (row.entity in endpoints or self._resolve(row.value) in endpoints)):
|
|
1638
|
+
out.append({
|
|
1639
|
+
"entity": self._resolve(row.entity), "attribute": row.attribute,
|
|
1640
|
+
"value": self._resolve(row.value), "valid_to": row.valid_to,
|
|
1641
|
+
"assertion_id": row.id})
|
|
1642
|
+
return out
|