pbuffer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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