pbuffer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patternbuffer/__init__.py +317 -0
- patternbuffer/buffer.py +312 -0
- patternbuffer/classify.py +359 -0
- patternbuffer/codec.py +122 -0
- patternbuffer/dump.py +113 -0
- patternbuffer/identity.py +1271 -0
- patternbuffer/indexes.py +1642 -0
- patternbuffer/ingest.py +617 -0
- patternbuffer/mcp.py +303 -0
- patternbuffer/model.py +100 -0
- patternbuffer/porcelain.py +943 -0
- patternbuffer/project.py +436 -0
- patternbuffer/refer.py +256 -0
- patternbuffer/roles.py +77 -0
- patternbuffer/salience.py +170 -0
- patternbuffer/semantics.py +192 -0
- patternbuffer/testing.py +92 -0
- patternbuffer/thunks.py +245 -0
- patternbuffer/tmaint.py +122 -0
- pbuffer-0.1.0.dist-info/METADATA +77 -0
- pbuffer-0.1.0.dist-info/RECORD +25 -0
- pbuffer-0.1.0.dist-info/WHEEL +5 -0
- pbuffer-0.1.0.dist-info/entry_points.txt +2 -0
- pbuffer-0.1.0.dist-info/licenses/LICENSE +21 -0
- pbuffer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
"""The porcelain: the frozen host surface (PORCELAIN-V1, porcelain-v0.1).
|
|
2
|
+
|
|
3
|
+
Typed, JSON-serializable verbs over shipped plumbing. Freeze semantics:
|
|
4
|
+
additive-only from the v0.1 tag — parameters gain defaults, verbs may be
|
|
5
|
+
added; nothing is renamed, removed, or re-typed.
|
|
6
|
+
|
|
7
|
+
No top-level import of this module from __init__ (lazy property there)
|
|
8
|
+
— porcelain imports World types only under TYPE_CHECKING to avoid
|
|
9
|
+
circularity.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from dataclasses import asdict, dataclass, field
|
|
19
|
+
from decimal import Decimal
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from patternbuffer.codec import decode_value, encode_out
|
|
23
|
+
from patternbuffer.model import ATTR_PREFIX, CANON, META_ATTRIBUTES
|
|
24
|
+
from patternbuffer.thunks import UNKNOWN, ResolutionDenied
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
27
|
+
from patternbuffer import World
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_ID_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z0-9_:]+$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------- payloads
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Receipt:
|
|
39
|
+
world_id: str
|
|
40
|
+
seq_range: list[int] | None
|
|
41
|
+
rows: list[dict] = field(default_factory=list)
|
|
42
|
+
frames: list[str] = field(default_factory=list)
|
|
43
|
+
canonicalization_receipts: list[str] = field(default_factory=list)
|
|
44
|
+
warnings: list[str] = field(default_factory=list)
|
|
45
|
+
# INGEST-HARDENING-V1 Part B: edges skipped at the gate (cycle / self-edge /
|
|
46
|
+
# lateral self-loop) — the host audits exactly what was dropped (no silent cap).
|
|
47
|
+
skipped: list[dict] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
return asdict(self)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Fact:
|
|
55
|
+
entity: str
|
|
56
|
+
attribute: str
|
|
57
|
+
value: Any
|
|
58
|
+
valid: list
|
|
59
|
+
provenance: dict
|
|
60
|
+
divergent: bool = False
|
|
61
|
+
b_value: Any = None
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict:
|
|
64
|
+
return asdict(self)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Answer:
|
|
69
|
+
answered: bool
|
|
70
|
+
facts: list[dict] = field(default_factory=list)
|
|
71
|
+
prose: str | None = None
|
|
72
|
+
unknown_reason: str | None = None
|
|
73
|
+
asks: list[dict] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict:
|
|
76
|
+
return asdict(self)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_ASK_PLAN_SCHEMA = {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"properties": {
|
|
82
|
+
"refer_targets": {"type": "array", "items": {"type": "string"}},
|
|
83
|
+
"keys": {"type": "array", "items": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {"target_index": {"type": "integer"},
|
|
86
|
+
"attribute": {"type": "string"}},
|
|
87
|
+
"required": ["target_index", "attribute"]}},
|
|
88
|
+
"wants_location": {"type": "boolean"},
|
|
89
|
+
"wants_events": {"type": "boolean"},
|
|
90
|
+
"as_of": {"type": ["number", "null"]},
|
|
91
|
+
},
|
|
92
|
+
"required": ["refer_targets", "keys"],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Porcelain:
|
|
97
|
+
"""The five+1 verbs. Construct via ``World.porcelain``."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, world: "World") -> None:
|
|
100
|
+
self._w = world
|
|
101
|
+
# BUILD-SESSION-V1 state. The session is a host-workflow concept and
|
|
102
|
+
# lives here (the engine's toggle/classifier don't know it exists).
|
|
103
|
+
# World.porcelain is THE handle — a second manual Porcelain(world) is
|
|
104
|
+
# unsupported (sessions would not see each other).
|
|
105
|
+
self._build_head: int | None = None
|
|
106
|
+
self._build_prev_inline: bool | None = None
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------- helpers
|
|
109
|
+
|
|
110
|
+
def _receipt(self, rows) -> Receipt:
|
|
111
|
+
rows = [r for r in rows if r is not None]
|
|
112
|
+
return Receipt(
|
|
113
|
+
world_id=self._w.world_id,
|
|
114
|
+
seq_range=[min(r.seq for r in rows), max(r.seq for r in rows)] if rows else None,
|
|
115
|
+
rows=[{"assertion_id": r.id, "entity": r.entity,
|
|
116
|
+
"attribute": r.attribute, "frame": r.frame} for r in rows],
|
|
117
|
+
frames=sorted({r.frame for r in rows}),
|
|
118
|
+
canonicalization_receipts=[
|
|
119
|
+
r.value for r in rows if r.attribute == "canonicalized_from"],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _fact(self, row, divergent=False, b_value=None) -> Fact:
|
|
123
|
+
chain = [m.value for m in self._w.buffer.visible(entity=row.id, attribute="source")]
|
|
124
|
+
return Fact(
|
|
125
|
+
entity=row.entity, attribute=row.attribute, value=row.value,
|
|
126
|
+
valid=[row.valid_from, row.valid_to],
|
|
127
|
+
provenance={"status": row.status, "source_chain": chain,
|
|
128
|
+
"assertion_id": row.id},
|
|
129
|
+
divergent=divergent, b_value=b_value,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _quantity_fact(
|
|
133
|
+
self,
|
|
134
|
+
row,
|
|
135
|
+
quantity: int | float | Decimal,
|
|
136
|
+
*,
|
|
137
|
+
entity: str | None = None,
|
|
138
|
+
attribute: str | None = None,
|
|
139
|
+
divergent: bool = False,
|
|
140
|
+
b_value: Any = None,
|
|
141
|
+
) -> Fact:
|
|
142
|
+
chain = [m.value for m in self._w.buffer.visible(entity=row.id, attribute="source")]
|
|
143
|
+
return Fact(
|
|
144
|
+
entity=entity or row.entity, attribute=attribute or row.attribute,
|
|
145
|
+
value=quantity, valid=[row.valid_from, row.valid_to],
|
|
146
|
+
provenance={"status": row.status, "source_chain": chain,
|
|
147
|
+
"assertion_id": row.id},
|
|
148
|
+
divergent=divergent, b_value=b_value,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _is_numeric(value: object) -> bool:
|
|
153
|
+
return isinstance(value, (int, float, Decimal)) and not isinstance(value, bool)
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------- BUILD-SESSION-V1
|
|
156
|
+
|
|
157
|
+
def begin_build(self, at: float | None = None) -> dict:
|
|
158
|
+
"""Enter build mode: every subsequent ingest DEFERS durability
|
|
159
|
+
classification (regardless of per-call `classify=` — the session
|
|
160
|
+
wins); `seal_build` runs one pass at the end. `at` places the scene
|
|
161
|
+
cursor. Raises on double-enter (not a nesting feature)."""
|
|
162
|
+
if self._build_head is not None:
|
|
163
|
+
raise RuntimeError("a build session is already open; seal or abort it")
|
|
164
|
+
ing = self._w.ingestor
|
|
165
|
+
self._build_prev_inline = ing.classify_inline
|
|
166
|
+
ing.classify_inline = False
|
|
167
|
+
self._build_head = self._w.buffer.head()
|
|
168
|
+
if at is not None:
|
|
169
|
+
ing.cursor.advance(at)
|
|
170
|
+
return {"outcome": "build_open", "since_seq": self._build_head,
|
|
171
|
+
"cursor": ing.cursor.position}
|
|
172
|
+
|
|
173
|
+
def seal_build(self, model: bool = False, scope: str = "session") -> dict:
|
|
174
|
+
"""Finalize the session: one classification pass over its rows
|
|
175
|
+
(already-classified rows — e.g. a per-call `classify="rules"` inside
|
|
176
|
+
the session — are skipped, never re-judged), restore the toggle,
|
|
177
|
+
close the session. `scope="all"` sweeps the whole log instead (the
|
|
178
|
+
classify_all-style pass, for pre-session deferred rows). `model=True`
|
|
179
|
+
sends ambiguous rows to the batch LM call; default is rules-only."""
|
|
180
|
+
if self._build_head is None:
|
|
181
|
+
raise RuntimeError("no build session open")
|
|
182
|
+
if scope not in ("session", "all"):
|
|
183
|
+
raise ValueError(f"unknown seal scope {scope!r}")
|
|
184
|
+
begin = self._build_head
|
|
185
|
+
head = self._w.buffer.head()
|
|
186
|
+
rows = self._w.buffer.all_rows()
|
|
187
|
+
if scope == "session":
|
|
188
|
+
rows = [r for r in rows if r.seq > begin]
|
|
189
|
+
classified = self._w.classifier.classify_rows(rows, model=model)
|
|
190
|
+
self._w.ingestor.classify_inline = self._build_prev_inline
|
|
191
|
+
self._build_head = None
|
|
192
|
+
self._build_prev_inline = None
|
|
193
|
+
return {"outcome": "sealed", "classified": classified,
|
|
194
|
+
"seq_range": [begin + 1, head], "scope": scope}
|
|
195
|
+
|
|
196
|
+
def abort_build(self) -> dict:
|
|
197
|
+
"""Close an open session WITHOUT classifying (restore the toggle,
|
|
198
|
+
clear the state). Idempotent — `no_session` when none is open. The
|
|
199
|
+
`build()` sugar's exception path and World.close() route through
|
|
200
|
+
this: a half-built world is the host's to inspect; classifying
|
|
201
|
+
wreckage helps nobody."""
|
|
202
|
+
if self._build_head is None:
|
|
203
|
+
return {"outcome": "no_session"}
|
|
204
|
+
self._w.ingestor.classify_inline = self._build_prev_inline
|
|
205
|
+
since = self._build_head
|
|
206
|
+
self._build_head = None
|
|
207
|
+
self._build_prev_inline = None
|
|
208
|
+
return {"outcome": "aborted", "since_seq": since}
|
|
209
|
+
|
|
210
|
+
@contextmanager
|
|
211
|
+
def build(self, at: float | None = None, model: bool = False,
|
|
212
|
+
scope: str = "session"):
|
|
213
|
+
"""Python sugar over begin_build/seal_build: seals on clean exit,
|
|
214
|
+
aborts (toggle restored, nothing classified) on exception."""
|
|
215
|
+
self.begin_build(at=at)
|
|
216
|
+
try:
|
|
217
|
+
yield self
|
|
218
|
+
except BaseException:
|
|
219
|
+
self.abort_build()
|
|
220
|
+
raise
|
|
221
|
+
else:
|
|
222
|
+
self.seal_build(model=model, scope=scope)
|
|
223
|
+
|
|
224
|
+
def fidelity_audit(self, frame: str = CANON, as_of: float | None = None) -> dict:
|
|
225
|
+
"""Structural ingestion-fidelity gaps as a queryable checklist
|
|
226
|
+
(INGESTION-FIDELITY-V1): `name_collisions` (distinct ids sharing an
|
|
227
|
+
anchor, each pair annotated with WHY it isn't merged — the coreference-
|
|
228
|
+
fragmentation metric), `unstamped_timed` (classified STATE/EVENT rows off
|
|
229
|
+
the time spine), `orphan_entities` (unanchored obj:/person:),
|
|
230
|
+
`open_conflicts`, and a `summary` with the headline live-fragmentation
|
|
231
|
+
count. Read-only; run after seal + `truth.scan()`. The host joins
|
|
232
|
+
arc/cast severity and drives targeted re-extraction of the flagged
|
|
233
|
+
spans; the engine surfaces, never repairs (membrane)."""
|
|
234
|
+
return encode_out(self._w.fidelity_audit(frame=frame, as_of=as_of))
|
|
235
|
+
|
|
236
|
+
def axis_heads(self) -> dict:
|
|
237
|
+
"""The two-axis high-water mark of the log (AXIS-HEAD-V1):
|
|
238
|
+
`asserted_head` (the seq head) and `valid_head` (MAX valid_from over
|
|
239
|
+
ALL rows, all frames; None when no timed rows exist). A coordinate
|
|
240
|
+
scalar, never content — no entity/attribute/value/frame crosses. The
|
|
241
|
+
entry-epoch read: a pre-play coordinate must sit above every seeded
|
|
242
|
+
row wherever it landed (a frame-scoped max under-raises)."""
|
|
243
|
+
return {"asserted_head": self._w.buffer.head(),
|
|
244
|
+
"valid_head": self._w.buffer.max_valid_from()}
|
|
245
|
+
|
|
246
|
+
# -------------------------------------------------------------- writes
|
|
247
|
+
|
|
248
|
+
def extract(self, text: str, scene: str | None = None,
|
|
249
|
+
extract: str = "full", pov: str | None = None) -> list[dict]:
|
|
250
|
+
"""Read-only extraction (INGEST-LATENCY-V2): returns the raw item dicts,
|
|
251
|
+
NO write. Run these concurrently in your runtime (your cap), then
|
|
252
|
+
ingest_structured() the results serially. `pov` (SHAPE-FIX-V1 4c): the
|
|
253
|
+
viewpoint entity id — deixis pronouns (I/you) bind to it instead of
|
|
254
|
+
minting phantom persons."""
|
|
255
|
+
context = f"\nSCENE HINT (context only, never a spatial anchor): {scene}" if scene else ""
|
|
256
|
+
return self._w.extract(text, context=context, extract=extract, pov=pov)
|
|
257
|
+
|
|
258
|
+
def ingest(self, text: str, source: str | None = None, scene: str | None = None,
|
|
259
|
+
at: float | None = None, frame: str | None = None,
|
|
260
|
+
classify: str = "inline", extract: str = "full",
|
|
261
|
+
cursor_authoritative: bool = False, pov: str | None = None) -> Receipt:
|
|
262
|
+
# classify (HD 079): "batch" collapses ~100 serial per-turn durability
|
|
263
|
+
# calls into one; "rules" (HD 083) does it with zero LM calls; "defer"
|
|
264
|
+
# skips. extract (HD 082): "lean" trims the prompt. cursor_authoritative
|
|
265
|
+
# (HD 084): the cursor governs valid_from (bible source-ingest).
|
|
266
|
+
if at is not None:
|
|
267
|
+
self._w.ingestor.cursor.advance(at)
|
|
268
|
+
context = f"\nSCENE HINT (context only, never a spatial anchor): {scene}" if scene else ""
|
|
269
|
+
rows = self._w.ingest(text, context=context, frame=frame, classify=classify,
|
|
270
|
+
extract=extract, cursor_authoritative=cursor_authoritative,
|
|
271
|
+
pov=pov)
|
|
272
|
+
if source is not None:
|
|
273
|
+
fact_rows = [
|
|
274
|
+
r for r in rows
|
|
275
|
+
if not r.entity.startswith("a:")
|
|
276
|
+
and not r.entity.startswith(ATTR_PREFIX)
|
|
277
|
+
and r.attribute not in META_ATTRIBUTES
|
|
278
|
+
]
|
|
279
|
+
rows += self._w.ingest_structured([
|
|
280
|
+
{"entity": r.id, "attribute": "source", "value": source,
|
|
281
|
+
"status": r.status if r.status in ("stated", "observed") else "stated",
|
|
282
|
+
"timeless": True}
|
|
283
|
+
for r in fact_rows
|
|
284
|
+
], classify=classify)
|
|
285
|
+
return self._receipt(rows)
|
|
286
|
+
|
|
287
|
+
def ingest_structured(self, items: list[dict], frame: str | None = None,
|
|
288
|
+
classify: str = "inline",
|
|
289
|
+
cursor_authoritative: bool = False,
|
|
290
|
+
at: float | None = None) -> Receipt:
|
|
291
|
+
# `at` places the scene cursor before the commit (AXIS-HEAD-V1 Win 2)
|
|
292
|
+
# — the per-chunk pose for parallel-extract/serial-commit paths,
|
|
293
|
+
# mirroring ingest(at=). The porcelain owns the pose; the gate reads it.
|
|
294
|
+
if at is not None:
|
|
295
|
+
self._w.ingestor.cursor.advance(at)
|
|
296
|
+
# frame= is a DEFAULT for unframed items (letter 028) — per-item frames
|
|
297
|
+
# win, which mixed batches (knows:B rows beside canon) require. Make the
|
|
298
|
+
# non-obvious case LOUD, never silent (HD 121: a staging frame silently
|
|
299
|
+
# lost to item-level stamps bypassed a quarantine gate for weeks).
|
|
300
|
+
kept_own = (sum(1 for i in items
|
|
301
|
+
if isinstance(i, dict) and i.get("frame") not in (None, frame))
|
|
302
|
+
if frame is not None else 0)
|
|
303
|
+
rows = self._w.ingest_structured(items, frame=frame, classify=classify,
|
|
304
|
+
cursor_authoritative=cursor_authoritative)
|
|
305
|
+
receipt = self._receipt(rows)
|
|
306
|
+
if kept_own:
|
|
307
|
+
receipt.warnings.append(
|
|
308
|
+
f"frame={frame!r} filled only unframed items; {kept_own} item(s) "
|
|
309
|
+
"kept their own frame key (frame= is a default, not an override "
|
|
310
|
+
"— strip item frames to re-target wholesale)")
|
|
311
|
+
receipt.skipped = [
|
|
312
|
+
{"entity": s.entity, "attribute": s.attribute,
|
|
313
|
+
"value": encode_out(s.value), "reason": s.reason}
|
|
314
|
+
for s in self._w.ingestor.last_skipped
|
|
315
|
+
]
|
|
316
|
+
return receipt
|
|
317
|
+
|
|
318
|
+
def resolve(self, entity: str, aspect: str, frame: str = CANON) -> dict:
|
|
319
|
+
try:
|
|
320
|
+
out = self._w.resolve(entity, aspect, frame)
|
|
321
|
+
except ResolutionDenied as exc:
|
|
322
|
+
return {"status": "denied", "facts": [],
|
|
323
|
+
"receipt": self._receipt([]).to_dict(), "reason": str(exc)}
|
|
324
|
+
if out is UNKNOWN:
|
|
325
|
+
return {"status": "unknown", "facts": [],
|
|
326
|
+
"receipt": self._receipt([]).to_dict()}
|
|
327
|
+
rows = [r for r in out if r is not None]
|
|
328
|
+
return encode_out({"status": "resolved",
|
|
329
|
+
"facts": [self._fact(r).to_dict() for r in rows],
|
|
330
|
+
"receipt": self._receipt(rows).to_dict()})
|
|
331
|
+
|
|
332
|
+
def retract(self, assertion_id: str, reason: str) -> Receipt:
|
|
333
|
+
return self._receipt([self._w.truth.retract(assertion_id, reason)])
|
|
334
|
+
|
|
335
|
+
# --------------------------------------------------- reads (LLM-free)
|
|
336
|
+
|
|
337
|
+
def snapshot(self, scope, frame: str = CANON, as_of: float | None = None,
|
|
338
|
+
lens: str = "current_state", budget: int | None = None,
|
|
339
|
+
since: float | None = None, correlated: bool = False,
|
|
340
|
+
features: bool = False) -> dict:
|
|
341
|
+
# correlated/features (AWARENESS-READS-V1.1, opt-in): fold each entity over
|
|
342
|
+
# its aka correlation union (the whole reveal scene) and/or inline each
|
|
343
|
+
# place's part_of-feature children. Default off = unchanged.
|
|
344
|
+
roots = [scope] if isinstance(scope, str) else list(scope)
|
|
345
|
+
known = {
|
|
346
|
+
self._w.registry.resolve(r.entity)
|
|
347
|
+
for r in self._w.buffer.visible()
|
|
348
|
+
if not r.entity.startswith("a:")
|
|
349
|
+
and not r.entity.startswith(ATTR_PREFIX)
|
|
350
|
+
}
|
|
351
|
+
bad = [s for s in roots
|
|
352
|
+
if not _ID_RE.fullmatch(s) or self._w.registry.resolve(s) not in known]
|
|
353
|
+
if bad:
|
|
354
|
+
return {"error": "snapshot scope must be KNOWN entity ids "
|
|
355
|
+
"(use ask for references)", "bad": bad}
|
|
356
|
+
m = self._w.materialize(roots, as_of=as_of, frame=frame, lens=lens,
|
|
357
|
+
budget=budget, since=since,
|
|
358
|
+
correlated=correlated, features=features)
|
|
359
|
+
# encode_out at the return: the porcelain's plain-JSON contract —
|
|
360
|
+
# exact-decimal values leave as the tag dict, never a raw Decimal.
|
|
361
|
+
return encode_out({
|
|
362
|
+
"world_id": self._w.world_id,
|
|
363
|
+
"charter": self._w.charter(),
|
|
364
|
+
"frame": m.frame, "lens": m.lens, "as_of": m.as_of,
|
|
365
|
+
"facts": [self._fact(r).to_dict() for r in m.assertions],
|
|
366
|
+
"quantities": [
|
|
367
|
+
{"entity": e, "attribute": a, "value": v}
|
|
368
|
+
for e, a, v in m.quantities
|
|
369
|
+
],
|
|
370
|
+
"unresolved": [list(u) for u in m.unresolved],
|
|
371
|
+
"conflicted": [list(c) for c in m.conflicted_keys],
|
|
372
|
+
"defaults": [asdict(d) for d in m.defaults],
|
|
373
|
+
"truncated": m.truncated,
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
def state(self, entity: str, attribute: str, frame: str = CANON,
|
|
377
|
+
as_of: float | None = None) -> dict:
|
|
378
|
+
fold = self._w.state(entity, attribute, frame, valid_as_of=as_of)
|
|
379
|
+
if fold.winner is None:
|
|
380
|
+
return {"status": "unknown"}
|
|
381
|
+
# Accrue: the host-facing value is the derived total, not the last
|
|
382
|
+
# delta row — keep `fact.value` consistent with ask()/snapshot().
|
|
383
|
+
fact = (self._quantity_fact(fold.winner, fold.quantity)
|
|
384
|
+
if fold.quantity is not None else self._fact(fold.winner))
|
|
385
|
+
out = {"status": "conflicted" if fold.conflicted else "known",
|
|
386
|
+
"fact": fact.to_dict()}
|
|
387
|
+
if fold.quantity is not None:
|
|
388
|
+
out["quantity"] = fold.quantity
|
|
389
|
+
if fold.conflicted:
|
|
390
|
+
out["conflicting"] = list(fold.conflicting)
|
|
391
|
+
return encode_out(out)
|
|
392
|
+
|
|
393
|
+
def where(self, attribute: str, op: str, value, frame: str = CANON,
|
|
394
|
+
as_of: float | None = None) -> list[str]:
|
|
395
|
+
comparators = {
|
|
396
|
+
">=": lambda a, b: a >= b,
|
|
397
|
+
">": lambda a, b: a > b,
|
|
398
|
+
"<=": lambda a, b: a <= b,
|
|
399
|
+
"<": lambda a, b: a < b,
|
|
400
|
+
"==": lambda a, b: a == b,
|
|
401
|
+
}
|
|
402
|
+
if op not in comparators:
|
|
403
|
+
raise ValueError(f"unknown comparison operator {op!r}")
|
|
404
|
+
value = decode_value(value) # tag-form symmetry: {"$decimal": ...} ok
|
|
405
|
+
if not self._is_numeric(value):
|
|
406
|
+
return []
|
|
407
|
+
candidates = {
|
|
408
|
+
self._w.registry.resolve(r.entity)
|
|
409
|
+
for r in self._w.buffer.visible(
|
|
410
|
+
attribute=attribute, frame=frame, valid_as_of=as_of
|
|
411
|
+
)
|
|
412
|
+
if not r.entity.startswith("a:")
|
|
413
|
+
and not r.entity.startswith(ATTR_PREFIX)
|
|
414
|
+
}
|
|
415
|
+
out: list[str] = []
|
|
416
|
+
for entity in sorted(candidates):
|
|
417
|
+
fold = self._w.state(entity, attribute, frame, valid_as_of=as_of)
|
|
418
|
+
if fold.quantity is not None:
|
|
419
|
+
target = fold.quantity
|
|
420
|
+
elif fold.winner is not None:
|
|
421
|
+
target = fold.winner.value
|
|
422
|
+
else:
|
|
423
|
+
continue
|
|
424
|
+
if self._is_numeric(target) and comparators[op](target, value):
|
|
425
|
+
out.append(entity)
|
|
426
|
+
return out
|
|
427
|
+
|
|
428
|
+
def aggregate(
|
|
429
|
+
self,
|
|
430
|
+
container: str,
|
|
431
|
+
member_attribute: str,
|
|
432
|
+
op: str,
|
|
433
|
+
frame: str = CANON,
|
|
434
|
+
as_of: float | None = None,
|
|
435
|
+
recursive: bool = False,
|
|
436
|
+
) -> dict:
|
|
437
|
+
return encode_out(self._w.aggregate(
|
|
438
|
+
container, member_attribute, op,
|
|
439
|
+
frame=frame, as_of=as_of, recursive=recursive,
|
|
440
|
+
))
|
|
441
|
+
|
|
442
|
+
def entities(self, frame: str, prefix: str | None = None,
|
|
443
|
+
as_of: float | None = None) -> list[str]:
|
|
444
|
+
"""The roster read (BOUNDED-READS-V1): entity ids carried by ONE
|
|
445
|
+
frame's rows, identity-resolved, deduped, sorted. `frame` is required —
|
|
446
|
+
every read fixes perspective (a prefix-only enumeration would leak
|
|
447
|
+
cross-frame entity existence). `prefix` narrows by id namespace
|
|
448
|
+
('place:'); `as_of` is the valid-time gate. Zero writes, no fold."""
|
|
449
|
+
if not frame:
|
|
450
|
+
# visible(frame=None) omits the frame predicate — that would be an
|
|
451
|
+
# unbounded cross-frame scan, exactly what this verb must not be.
|
|
452
|
+
raise ValueError("entities() requires a frame — every read fixes perspective")
|
|
453
|
+
out: set[str] = set()
|
|
454
|
+
for r in self._w.buffer.visible(frame=frame, valid_as_of=as_of):
|
|
455
|
+
if r.entity.startswith("a:") or r.entity.startswith(ATTR_PREFIX):
|
|
456
|
+
continue
|
|
457
|
+
eid = self._w.registry.resolve(r.entity)
|
|
458
|
+
if prefix is None or eid.startswith(prefix):
|
|
459
|
+
out.add(eid)
|
|
460
|
+
return sorted(out)
|
|
461
|
+
|
|
462
|
+
def facts(self, frame: str, entity: str | None = None,
|
|
463
|
+
attribute: str | None = None, prefix: str | None = None,
|
|
464
|
+
as_of: float | None = None, include_meta: bool = False) -> list[dict]:
|
|
465
|
+
"""The frame-scan read (BOUNDED-READS-V1): the visible rows OF one
|
|
466
|
+
frame as Fact payloads — RAW log reads for audited scans (receipt
|
|
467
|
+
trails, knowledge digests, marker rows), NOT folds (folded truth is
|
|
468
|
+
`state`/`snapshot`). `frame` is required (the bound). `entity` targets
|
|
469
|
+
one id (identity-closure for world entities; exact for `a:<n>` receipt
|
|
470
|
+
chains — always served); frame/prefix-wide listings exclude meta rows
|
|
471
|
+
unless `include_meta=True`."""
|
|
472
|
+
if not frame:
|
|
473
|
+
raise ValueError("facts() requires a frame — every read fixes perspective")
|
|
474
|
+
if entity is not None and not entity.startswith("a:"):
|
|
475
|
+
clos = sorted(self._w.registry.closure(entity))
|
|
476
|
+
rows = self._w.buffer.visible(entity_in=clos, frame=frame,
|
|
477
|
+
attribute=attribute, valid_as_of=as_of)
|
|
478
|
+
elif entity is not None:
|
|
479
|
+
rows = self._w.buffer.visible(entity=entity, frame=frame,
|
|
480
|
+
attribute=attribute, valid_as_of=as_of)
|
|
481
|
+
else:
|
|
482
|
+
rows = [
|
|
483
|
+
r for r in self._w.buffer.visible(frame=frame, attribute=attribute,
|
|
484
|
+
valid_as_of=as_of)
|
|
485
|
+
if include_meta or (not r.entity.startswith("a:")
|
|
486
|
+
and not r.entity.startswith(ATTR_PREFIX))
|
|
487
|
+
]
|
|
488
|
+
if prefix is not None:
|
|
489
|
+
rows = [r for r in rows
|
|
490
|
+
if self._w.registry.resolve(r.entity).startswith(prefix)]
|
|
491
|
+
return encode_out([self._fact(r).to_dict() for r in rows])
|
|
492
|
+
|
|
493
|
+
def locate(self, entity: str, as_of: float | None = None) -> list[str]:
|
|
494
|
+
return self._w.locate(entity, valid_as_of=as_of)
|
|
495
|
+
|
|
496
|
+
def contents(self, container: str, as_of: float | None = None) -> list[str]:
|
|
497
|
+
return self._w.contents(container, valid_as_of=as_of)
|
|
498
|
+
|
|
499
|
+
def composition(self, entity: str, frame: str = CANON,
|
|
500
|
+
as_of: float | None = None) -> list[str]:
|
|
501
|
+
"""The `part_of` chain up — the entity's place in the structure
|
|
502
|
+
(PLACE-FEATURE-ABSTRACTION-V1). The compositional sibling of `locate`."""
|
|
503
|
+
return self._w.composition(entity, frame=frame, valid_as_of=as_of)
|
|
504
|
+
|
|
505
|
+
def features(self, place: str, frame: str = CANON,
|
|
506
|
+
as_of: float | None = None) -> list[str]:
|
|
507
|
+
"""The `part_of`-children of a place — its structural sub-features
|
|
508
|
+
(a burrow under a hillside). The compositional sibling of `contents`."""
|
|
509
|
+
return self._w.features(place, frame=frame, valid_as_of=as_of)
|
|
510
|
+
|
|
511
|
+
def path(self, a: str, b: str, as_of: float | None = None) -> list[str] | None:
|
|
512
|
+
# as_of routes as the graph stood at that time: a severed edge
|
|
513
|
+
# (valid_to passed) drops; history is preserved (PATH-TEMPORAL-V1).
|
|
514
|
+
return self._w.path(a, b, valid_as_of=as_of)
|
|
515
|
+
|
|
516
|
+
def route(self, a: str, b: str, frame: str = CANON, as_of: float | None = None) -> dict:
|
|
517
|
+
"""Passability-aware route (RFC-003): {route, status, segments}. Status
|
|
518
|
+
per segment is clear|blocked|obscured (removed is temporal/diagnostic);
|
|
519
|
+
blocked carries obstructing-fact evidence, obscured a computed
|
|
520
|
+
unknown_basis. The engine derives status from portal facts under the
|
|
521
|
+
host's declared traversal policy; the host supplies the words."""
|
|
522
|
+
return encode_out(self._w.route(a, b, frame=frame, valid_as_of=as_of))
|
|
523
|
+
|
|
524
|
+
# ----------------------------------- host reconciliation (MERGE-RECONCILE-VERB-V1)
|
|
525
|
+
|
|
526
|
+
def reconcile(self) -> dict:
|
|
527
|
+
"""Run the global coreference finalize pass and return the count
|
|
528
|
+
merged plus the residual proposals to adjudicate. Host-invoked; never
|
|
529
|
+
auto-run by ingest. Zero model calls."""
|
|
530
|
+
merges = self._w.registry.reconcile()
|
|
531
|
+
return {"merges": merges, "proposals": self._w.registry.enumerate_proposals()}
|
|
532
|
+
|
|
533
|
+
def proposals(self) -> list[dict]:
|
|
534
|
+
"""Visible un-promoted maybe_same_as as adjudicable proposals, each
|
|
535
|
+
with a recomputed `auto_decline_reason` (the kind-pair on conflict)."""
|
|
536
|
+
return self._w.registry.enumerate_proposals()
|
|
537
|
+
|
|
538
|
+
def confirm(self, a: str, b: str) -> dict:
|
|
539
|
+
"""Promote an existing proposal through the guarded path. Returns a
|
|
540
|
+
Receipt; `no_proposal` if none relates them."""
|
|
541
|
+
return self._w.registry.confirm(a, b)
|
|
542
|
+
|
|
543
|
+
def merge(self, a: str, b: str, evidence: str) -> dict:
|
|
544
|
+
"""Assert a merge (no proposal required) through the guarded path.
|
|
545
|
+
Host-authoritative past the soft heuristic, but the hard vetoes
|
|
546
|
+
(containment, distinct_from) are absolute (a `vetoed` Receipt names the
|
|
547
|
+
blocking edge)."""
|
|
548
|
+
return self._w.registry.guarded_merge(a, b, evidence)
|
|
549
|
+
|
|
550
|
+
def reject(self, a: str, b: str) -> dict:
|
|
551
|
+
"""Assert these are definitively different (`distinct_from`) — the
|
|
552
|
+
sticky separation that keeps two same-named entities (two Clays) apart
|
|
553
|
+
on every future reconcile. Returns a Receipt
|
|
554
|
+
(rejected | noop_already_distinct | conflict_already_merged)."""
|
|
555
|
+
return self._w.registry.reject(a, b)
|
|
556
|
+
|
|
557
|
+
# -------------------------------------------- SHAPE-FIX-V1 (identity shape)
|
|
558
|
+
|
|
559
|
+
def adjudicate_deferred(self) -> dict:
|
|
560
|
+
"""Merge the structurally-DECISIVE subset of open proposals — pure
|
|
561
|
+
name-fragments with no independent identity signal (anchor subsumption:
|
|
562
|
+
`tovin` ⊆ `tovin beck`), no relating edges, no kind conflict, no `aka`.
|
|
563
|
+
Returns {merged: [receipts], residue: [proposals-with-auto_decline]} —
|
|
564
|
+
the residue is yours to adjudicate with confirm/merge/reject. Opt-in;
|
|
565
|
+
`reconcile()` is unchanged. Zero model calls; idempotent."""
|
|
566
|
+
return self._w.registry.adjudicate_deferred()
|
|
567
|
+
|
|
568
|
+
def typing_conflicts(self) -> list[dict]:
|
|
569
|
+
"""Read-only: same-anchor cross-kind pairs carrying the typing-slip
|
|
570
|
+
signature (an outgoing-bare spurious twin beside a structurally real
|
|
571
|
+
entity — person:harth beside place:harth). Proposals cannot surface
|
|
572
|
+
these; adjudicate each with `retype(...)` or leave it. Zero writes."""
|
|
573
|
+
return self._w.registry.typing_conflicts()
|
|
574
|
+
|
|
575
|
+
def retype(self, entity: str, to_kind: str, evidence: str,
|
|
576
|
+
absorb: str | None = None) -> dict:
|
|
577
|
+
"""Typing correction, distinct from merge. absorb=None: correct one
|
|
578
|
+
mistyped entity's kind (wrong kind rows retracted, correct kind
|
|
579
|
+
appended + classified). absorb=<target>: the entity is a spurious
|
|
580
|
+
duplicate at the wrong kind — verified against the slip signature,
|
|
581
|
+
artifact edges retracted, then merged through the guarded path.
|
|
582
|
+
Never a veto bypass: a non-slip invocation returns
|
|
583
|
+
`vetoed_not_a_slip`; `distinct_from` stays absolute."""
|
|
584
|
+
return self._w.registry.retype(entity, to_kind, evidence, absorb=absorb)
|
|
585
|
+
|
|
586
|
+
# ------------------------------------ AKA-CORRELATION-V1 (opt-in identity)
|
|
587
|
+
|
|
588
|
+
def correlate(self, a: str, b: str, evidence: str, at: float | None = None) -> dict:
|
|
589
|
+
"""Correlate two entities as facets of one identity (non-collapsing
|
|
590
|
+
`aka`), without merging them — the reveal/dual-persona/amalgamation call.
|
|
591
|
+
`at` is the reveal's valid_from. Returns a Receipt
|
|
592
|
+
(correlated | noop_already_correlated | vetoed_distinct). The hard
|
|
593
|
+
`distinct_from` veto is absolute."""
|
|
594
|
+
return self._w.registry.correlate(a, b, evidence, valid_from=at)
|
|
595
|
+
|
|
596
|
+
def correlations(self, entity: str, as_of: float | None = None,
|
|
597
|
+
frame: str = CANON) -> list[str]:
|
|
598
|
+
"""The facets correlated with `entity` as-of (the `aka` set minus its own
|
|
599
|
+
same_as closure), first-seen ordered. Before a reveal's valid_from this
|
|
600
|
+
is empty — the mystery is intact. Zero writes."""
|
|
601
|
+
return self._w.registry.correlations(entity, valid_as_of=as_of, frame=frame)
|
|
602
|
+
|
|
603
|
+
def state_union(self, entity: str, attribute: str, frame: str = CANON,
|
|
604
|
+
as_of: float | None = None) -> dict:
|
|
605
|
+
"""The explicit correlated read: fold `attribute` over `entity` ∪ its
|
|
606
|
+
correlated facets, as-of. Same shape as `state`. NOT a default read —
|
|
607
|
+
`state`/`snapshot`/`ask` never union. As-of-before a reveal returns the
|
|
608
|
+
uncorrelated view (no leak)."""
|
|
609
|
+
fold = self._w.state_union(entity, attribute, frame, valid_as_of=as_of)
|
|
610
|
+
if fold.winner is None:
|
|
611
|
+
return {"status": "unknown"}
|
|
612
|
+
fact = (self._quantity_fact(fold.winner, fold.quantity)
|
|
613
|
+
if fold.quantity is not None else self._fact(fold.winner))
|
|
614
|
+
out = {"status": "conflicted" if fold.conflicted else "known",
|
|
615
|
+
"fact": fact.to_dict()}
|
|
616
|
+
if fold.quantity is not None:
|
|
617
|
+
out["quantity"] = fold.quantity
|
|
618
|
+
if fold.conflicted:
|
|
619
|
+
out["conflicting"] = list(fold.conflicting)
|
|
620
|
+
return encode_out(out)
|
|
621
|
+
|
|
622
|
+
def correlation_conflicts(self, as_of: float | None = None,
|
|
623
|
+
frame: str = CANON) -> list[dict]:
|
|
624
|
+
"""Pairs carrying both an `aka` and a `distinct_from` (a raw-authored
|
|
625
|
+
contradiction) for host adjudication. The guarded `correlate()` prevents
|
|
626
|
+
these at the source; this surfaces any that slipped in via raw ingest.
|
|
627
|
+
`aka` rows are filtered by `as_of`/`frame`; `distinct_from` is global."""
|
|
628
|
+
return self._w.registry.correlation_conflicts(valid_as_of=as_of, frame=frame)
|
|
629
|
+
|
|
630
|
+
def salience(
|
|
631
|
+
self, entity: str, frame: str = CANON, as_of: float | None = None
|
|
632
|
+
) -> float:
|
|
633
|
+
return self._w.salience(entity, frame=frame, as_of=as_of)
|
|
634
|
+
|
|
635
|
+
def confidence(
|
|
636
|
+
self,
|
|
637
|
+
entity: str,
|
|
638
|
+
attribute: str,
|
|
639
|
+
frame: str | list[str] = CANON,
|
|
640
|
+
as_of: float | None = None,
|
|
641
|
+
) -> dict:
|
|
642
|
+
# frame may be a list: trust over an observer's effective knowledge
|
|
643
|
+
# (knows:O ∪ public), mirroring multi-frame frame_diff.
|
|
644
|
+
return self._w.confidence(entity, attribute, frame=frame, as_of=as_of)
|
|
645
|
+
|
|
646
|
+
def neighborhood(
|
|
647
|
+
self,
|
|
648
|
+
entity: str,
|
|
649
|
+
depth: int = 1,
|
|
650
|
+
frame: str = CANON,
|
|
651
|
+
as_of: float | None = None,
|
|
652
|
+
edge_kinds: list[str] | None = None,
|
|
653
|
+
max_fanout: int = 64,
|
|
654
|
+
budget: int | None = None,
|
|
655
|
+
) -> dict:
|
|
656
|
+
return encode_out(self._w.neighborhood(
|
|
657
|
+
entity,
|
|
658
|
+
depth=depth,
|
|
659
|
+
frame=frame,
|
|
660
|
+
as_of=as_of,
|
|
661
|
+
edge_kinds=edge_kinds,
|
|
662
|
+
max_fanout=max_fanout,
|
|
663
|
+
budget=budget,
|
|
664
|
+
))
|
|
665
|
+
|
|
666
|
+
def events(self, kind: str | None = None,
|
|
667
|
+
participants: str | list[str] | None = None,
|
|
668
|
+
since: float | None = None, until: float | None = None,
|
|
669
|
+
frame: str = CANON) -> list[dict]:
|
|
670
|
+
scope = sorted({
|
|
671
|
+
self._w.registry.resolve(r.entity)
|
|
672
|
+
for r in self._w.buffer.visible(frame=frame, entity_prefix="event:")})
|
|
673
|
+
m = self._w.materialize(scope or ["event:none"], frame=frame,
|
|
674
|
+
lens="what_happened", since=since, as_of=until)
|
|
675
|
+
by_event: dict[str, dict] = {}
|
|
676
|
+
for r in m.assertions:
|
|
677
|
+
ev = by_event.setdefault(r.entity, {"id": r.entity, "kind": None,
|
|
678
|
+
"agents": [], "patients": [],
|
|
679
|
+
"t": r.valid_from, "caused_by": []})
|
|
680
|
+
if r.attribute == "kind":
|
|
681
|
+
ev["kind"] = r.value
|
|
682
|
+
elif r.attribute == "agent":
|
|
683
|
+
ev["agents"].append(r.value)
|
|
684
|
+
elif r.attribute == "patient":
|
|
685
|
+
ev["patients"].append(r.value)
|
|
686
|
+
elif r.attribute == "caused_by":
|
|
687
|
+
ev["caused_by"].append(r.value)
|
|
688
|
+
if r.valid_from is not None:
|
|
689
|
+
ev["t"] = r.valid_from
|
|
690
|
+
out = list(by_event.values())
|
|
691
|
+
if kind is not None:
|
|
692
|
+
out = [e for e in out if e["kind"] == kind]
|
|
693
|
+
if participants:
|
|
694
|
+
wanted = {participants} if isinstance(participants, str) else set(participants)
|
|
695
|
+
wanted = {self._w.registry.resolve(p) for p in wanted}
|
|
696
|
+
out = [e for e in out
|
|
697
|
+
if wanted <= {self._w.registry.resolve(x)
|
|
698
|
+
for x in e["agents"] + e["patients"]}]
|
|
699
|
+
return encode_out(
|
|
700
|
+
sorted(out, key=lambda e: (e["t"] if e["t"] is not None else float("-inf")))
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
def who_knows(self, entity: str, attribute: str, value: Any = None,
|
|
704
|
+
as_of: float | None = None) -> list[str]:
|
|
705
|
+
"""The `knows:*` frames that KNOW `(entity, attribute)` — the computed
|
|
706
|
+
inverse of `frame_diff` (WHO-KNOWS-INVERSE-V1). A frame qualifies iff its
|
|
707
|
+
folded winner is present and (when `value` given) value-matches,
|
|
708
|
+
identity-aware. No stored `known_by`; superseded/retracted beliefs drop."""
|
|
709
|
+
return self._w.who_knows(entity, attribute, value, valid_as_of=as_of)
|
|
710
|
+
|
|
711
|
+
def frame_diff(self, a: str, b: str | list[str], scope,
|
|
712
|
+
as_of: float | None = None) -> list[dict]:
|
|
713
|
+
"""Semantic fact diff: keys folded in `a`, absent or divergent in
|
|
714
|
+
`b`. Never compares assertion ids (frames are sparse copies)."""
|
|
715
|
+
if isinstance(b, str):
|
|
716
|
+
roots = [scope] if isinstance(scope, str) else list(scope)
|
|
717
|
+
m_a = self._w.materialize(roots, frame=a, as_of=as_of)
|
|
718
|
+
out: list[dict] = []
|
|
719
|
+
for entity, attribute, quantity in m_a.quantities:
|
|
720
|
+
fold_a = self._w.state(entity, attribute, frame=a, valid_as_of=as_of)
|
|
721
|
+
if fold_a.winner is None:
|
|
722
|
+
continue
|
|
723
|
+
fold_b = self._w.state(entity, attribute, frame=b, valid_as_of=as_of)
|
|
724
|
+
if fold_b.quantity is None:
|
|
725
|
+
out.append(
|
|
726
|
+
self._quantity_fact(
|
|
727
|
+
fold_a.winner, quantity, entity=entity, attribute=attribute
|
|
728
|
+
).to_dict()
|
|
729
|
+
)
|
|
730
|
+
elif fold_b.quantity != quantity:
|
|
731
|
+
out.append(
|
|
732
|
+
self._quantity_fact(
|
|
733
|
+
fold_a.winner, quantity, entity=entity, attribute=attribute,
|
|
734
|
+
divergent=True, b_value=fold_b.quantity,
|
|
735
|
+
).to_dict()
|
|
736
|
+
)
|
|
737
|
+
for row in m_a.assertions:
|
|
738
|
+
if row.status == "default" or row.value_type == "unresolved":
|
|
739
|
+
continue
|
|
740
|
+
if self._w.semantics.is_accrue(row.attribute):
|
|
741
|
+
continue
|
|
742
|
+
entity = self._w.registry.resolve(row.entity)
|
|
743
|
+
fold_b = self._w.state(entity, row.attribute, frame=b, valid_as_of=as_of)
|
|
744
|
+
|
|
745
|
+
def _norm(value, value_type):
|
|
746
|
+
if value_type == "entity" and isinstance(value, str):
|
|
747
|
+
return ("entity", self._w.registry.resolve(value))
|
|
748
|
+
return (value_type, value)
|
|
749
|
+
|
|
750
|
+
if self._w.semantics.is_set_valued(row.attribute):
|
|
751
|
+
# Set membership, not a single-winner comparison: an A-frame
|
|
752
|
+
# member is a diff only when absent from B's whole set
|
|
753
|
+
# (multi-value fold; otherwise every member but B's winner
|
|
754
|
+
# reads as falsely divergent).
|
|
755
|
+
b_members = {
|
|
756
|
+
_norm(br.value, br.value_type)
|
|
757
|
+
for br in (fold_b._value_rows
|
|
758
|
+
or ((fold_b.winner,) if fold_b.winner else ()))
|
|
759
|
+
}
|
|
760
|
+
if _norm(row.value, row.value_type) not in b_members:
|
|
761
|
+
out.append(self._fact(row).to_dict()) # present in A, absent from B
|
|
762
|
+
continue
|
|
763
|
+
|
|
764
|
+
if fold_b.winner is None:
|
|
765
|
+
out.append(self._fact(row).to_dict())
|
|
766
|
+
continue
|
|
767
|
+
va, vb = row.value, fold_b.winner.value
|
|
768
|
+
if row.value_type == "entity" and fold_b.winner.value_type == "entity":
|
|
769
|
+
equivalent = self._w.registry.resolve(va) == self._w.registry.resolve(vb)
|
|
770
|
+
else:
|
|
771
|
+
equivalent = va == vb
|
|
772
|
+
if not equivalent:
|
|
773
|
+
out.append(self._fact(row, divergent=True, b_value=vb).to_dict())
|
|
774
|
+
return encode_out(out)
|
|
775
|
+
|
|
776
|
+
b_frames = list(b)
|
|
777
|
+
roots = [scope] if isinstance(scope, str) else list(scope)
|
|
778
|
+
m_a = self._w.materialize(roots, frame=a, as_of=as_of)
|
|
779
|
+
out: list[dict] = []
|
|
780
|
+
|
|
781
|
+
def _norm(value, value_type):
|
|
782
|
+
if value_type == "entity" and isinstance(value, str):
|
|
783
|
+
return ("entity", self._w.registry.resolve(value))
|
|
784
|
+
return (value_type, value)
|
|
785
|
+
|
|
786
|
+
def _equivalent(row, other) -> bool:
|
|
787
|
+
if row.value_type == "entity" and other.value_type == "entity":
|
|
788
|
+
return self._w.registry.resolve(row.value) == self._w.registry.resolve(other.value)
|
|
789
|
+
return row.value == other.value
|
|
790
|
+
|
|
791
|
+
def _recency(row) -> tuple[float, int]:
|
|
792
|
+
return (row.valid_from if row.valid_from is not None else float("-inf"),
|
|
793
|
+
row.asserted_at)
|
|
794
|
+
|
|
795
|
+
for entity, attribute, quantity in m_a.quantities:
|
|
796
|
+
fold_a = self._w.state(entity, attribute, frame=a, valid_as_of=as_of)
|
|
797
|
+
if fold_a.winner is None:
|
|
798
|
+
continue
|
|
799
|
+
covered = False
|
|
800
|
+
held = False
|
|
801
|
+
divergent: tuple[tuple[float, int], int | float | Decimal] | None = None
|
|
802
|
+
for b_frame in b_frames:
|
|
803
|
+
fold_b = self._w.state(entity, attribute, frame=b_frame, valid_as_of=as_of)
|
|
804
|
+
if fold_b.quantity == quantity:
|
|
805
|
+
covered = True
|
|
806
|
+
break
|
|
807
|
+
if fold_b.quantity is not None and fold_b.winner is not None:
|
|
808
|
+
held = True
|
|
809
|
+
candidate = (_recency(fold_b.winner), fold_b.quantity)
|
|
810
|
+
if divergent is None or candidate[0] > divergent[0]:
|
|
811
|
+
divergent = candidate
|
|
812
|
+
if covered:
|
|
813
|
+
continue
|
|
814
|
+
if not held:
|
|
815
|
+
out.append(
|
|
816
|
+
self._quantity_fact(
|
|
817
|
+
fold_a.winner, quantity, entity=entity, attribute=attribute
|
|
818
|
+
).to_dict()
|
|
819
|
+
)
|
|
820
|
+
else:
|
|
821
|
+
out.append(
|
|
822
|
+
self._quantity_fact(
|
|
823
|
+
fold_a.winner, quantity, entity=entity, attribute=attribute,
|
|
824
|
+
divergent=True, b_value=divergent[1],
|
|
825
|
+
).to_dict()
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
for row in m_a.assertions:
|
|
829
|
+
if row.status == "default" or row.value_type == "unresolved":
|
|
830
|
+
continue
|
|
831
|
+
if self._w.semantics.is_accrue(row.attribute):
|
|
832
|
+
continue
|
|
833
|
+
entity = self._w.registry.resolve(row.entity)
|
|
834
|
+
if self._w.semantics.is_set_valued(row.attribute):
|
|
835
|
+
b_members = set()
|
|
836
|
+
for b_frame in b_frames:
|
|
837
|
+
fold_b = self._w.state(entity, row.attribute, frame=b_frame,
|
|
838
|
+
valid_as_of=as_of)
|
|
839
|
+
b_members.update(
|
|
840
|
+
_norm(br.value, br.value_type)
|
|
841
|
+
for br in (fold_b._value_rows
|
|
842
|
+
or ((fold_b.winner,) if fold_b.winner else ()))
|
|
843
|
+
)
|
|
844
|
+
if _norm(row.value, row.value_type) not in b_members:
|
|
845
|
+
out.append(self._fact(row).to_dict())
|
|
846
|
+
continue
|
|
847
|
+
|
|
848
|
+
held = False
|
|
849
|
+
covered = False
|
|
850
|
+
divergent: tuple[tuple[float, int], Any] | None = None
|
|
851
|
+
for b_frame in b_frames:
|
|
852
|
+
fold_b = self._w.state(entity, row.attribute, frame=b_frame,
|
|
853
|
+
valid_as_of=as_of)
|
|
854
|
+
if fold_b.winner is None:
|
|
855
|
+
continue
|
|
856
|
+
held = True
|
|
857
|
+
if _equivalent(row, fold_b.winner):
|
|
858
|
+
covered = True
|
|
859
|
+
break
|
|
860
|
+
candidate = (_recency(fold_b.winner), fold_b.winner.value)
|
|
861
|
+
if divergent is None or candidate[0] > divergent[0]:
|
|
862
|
+
divergent = candidate
|
|
863
|
+
if covered:
|
|
864
|
+
continue
|
|
865
|
+
if not held:
|
|
866
|
+
out.append(self._fact(row).to_dict())
|
|
867
|
+
else:
|
|
868
|
+
out.append(self._fact(row, divergent=True, b_value=divergent[1]).to_dict())
|
|
869
|
+
return encode_out(out)
|
|
870
|
+
|
|
871
|
+
# ------------------------------------------------------------- ask
|
|
872
|
+
|
|
873
|
+
def ask(self, question: str, frame: str = CANON,
|
|
874
|
+
as_of: float | None = None) -> Answer:
|
|
875
|
+
if self._w.ingestor._model is None:
|
|
876
|
+
return Answer(answered=False, unknown_reason="no model injected for ask")
|
|
877
|
+
prompt = (
|
|
878
|
+
"Parse this question about a tracked world into a query plan. "
|
|
879
|
+
"refer_targets: the referring expressions for entities mentioned "
|
|
880
|
+
"(verbatim noun phrases). keys: which attribute of which target "
|
|
881
|
+
"is asked about (attribute 'in' for any where/location question). "
|
|
882
|
+
"wants_location true for where-questions.\n"
|
|
883
|
+
f"QUESTION: {question}"
|
|
884
|
+
)
|
|
885
|
+
plan = self._w.ingestor._model(prompt, _ASK_PLAN_SCHEMA)
|
|
886
|
+
# HD 003: identity/existence is a CANON question; only the answer is
|
|
887
|
+
# knowledge-scoped. Resolve references against canon with a scene
|
|
888
|
+
# scope mirroring the observe path — the asker (from a knows:<id>
|
|
889
|
+
# frame) plus the asker's current container chain — so co-located
|
|
890
|
+
# objects become candidates and the 018 escalation can fire. The
|
|
891
|
+
# answer keys below are still folded in the asked `frame`.
|
|
892
|
+
ask_scope: str | list[str] | None = None
|
|
893
|
+
if frame.startswith("knows:"):
|
|
894
|
+
asker = frame[len("knows:"):]
|
|
895
|
+
ask_scope = [asker, *self._w.locate(asker, valid_as_of=as_of)]
|
|
896
|
+
resolved: list[str | None] = []
|
|
897
|
+
asks: list[dict] = []
|
|
898
|
+
for target in plan.get("refer_targets", []):
|
|
899
|
+
r = self._w.refer(target, scope=ask_scope, frame=CANON, as_of=as_of)
|
|
900
|
+
if r.status == "resolved":
|
|
901
|
+
resolved.append(r.entity_id)
|
|
902
|
+
else:
|
|
903
|
+
resolved.append(None)
|
|
904
|
+
asks.append({"reference": target, "candidates": list(r.candidates)})
|
|
905
|
+
facts: list[dict] = []
|
|
906
|
+
for key in plan.get("keys", []):
|
|
907
|
+
idx = key.get("target_index", 0)
|
|
908
|
+
if not (0 <= idx < len(resolved)) or resolved[idx] is None:
|
|
909
|
+
continue
|
|
910
|
+
fold = self._w.state(resolved[idx], key["attribute"], frame,
|
|
911
|
+
valid_as_of=as_of)
|
|
912
|
+
if fold.winner is not None:
|
|
913
|
+
if fold.quantity is not None:
|
|
914
|
+
facts.append(
|
|
915
|
+
self._quantity_fact(fold.winner, fold.quantity).to_dict()
|
|
916
|
+
)
|
|
917
|
+
else:
|
|
918
|
+
facts.append(self._fact(fold.winner).to_dict())
|
|
919
|
+
effective_as_of = plan.get("as_of") if plan.get("as_of") is not None else as_of
|
|
920
|
+
if plan.get("wants_location"):
|
|
921
|
+
for eid in resolved:
|
|
922
|
+
if eid:
|
|
923
|
+
fold = self._w.state(eid, "in", frame, valid_as_of=effective_as_of)
|
|
924
|
+
if fold.winner is not None:
|
|
925
|
+
f = self._fact(fold.winner).to_dict()
|
|
926
|
+
# Frame-scope the chain like the fold beside it — a
|
|
927
|
+
# canon-frame locate would leak containment into a
|
|
928
|
+
# knows: answer (HD 003, incidental).
|
|
929
|
+
f["chain"] = self._w.locate(
|
|
930
|
+
eid, frame=frame, valid_as_of=effective_as_of)
|
|
931
|
+
facts.append(f)
|
|
932
|
+
if plan.get("wants_events"):
|
|
933
|
+
participants = [e for e in resolved if e]
|
|
934
|
+
for ev in self.events(participants=participants or None,
|
|
935
|
+
until=effective_as_of, frame=frame):
|
|
936
|
+
facts.append({"event": ev})
|
|
937
|
+
answered = bool(facts)
|
|
938
|
+
return Answer(
|
|
939
|
+
answered=answered, facts=encode_out(facts),
|
|
940
|
+
unknown_reason=None if answered else
|
|
941
|
+
("unresolved references" if asks else "no folded facts on the asked keys"),
|
|
942
|
+
asks=asks,
|
|
943
|
+
)
|