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,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
+ )