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,617 @@
1
+ """The ingest gate (whitepaper §10/§17): the ONLY path for stated truth.
2
+
3
+ Pipeline per item: canonicalize the attribute (receipts in the log, map
4
+ in a rebuildable sidecar) -> identity-resolve -> stamp valid_time from
5
+ the scene cursor -> role-checked append -> classify. The scene cursor is
6
+ the pose; anchoring never claims more precision than was observed.
7
+
8
+ A2 rider (whitepaper amendment log): in observe_or_unknown worlds the
9
+ gate stamps a wall-clock learned-at meta-assertion on every non-timeless
10
+ write — staleness decay computes from real time and silently breaks
11
+ without it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ import time
19
+ from dataclasses import dataclass
20
+ from typing import Any, Callable
21
+
22
+ from patternbuffer.buffer import PatternBuffer
23
+ from patternbuffer.classify import Classifier
24
+ from patternbuffer.codec import decode_value
25
+ from patternbuffer.identity import IdentityRegistry
26
+ from patternbuffer.model import ATTR_PREFIX, CANON, SEMANTICS_PREDICATES, Assertion
27
+ from patternbuffer.roles import WriterRole
28
+ from patternbuffer.semantics import AttributeSemantics
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # The id grammar (SHAPE-FIX-V1 4a): namespaced snake_case, no stray slashes.
33
+ # A malformed id (person:/you) is SKIPPED with a typed receipt, never
34
+ # normalized — guessing person:you would manufacture the phantom well-formed.
35
+ _ID_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z0-9_:]+$")
36
+
37
+ # Built-in attribute aliases: the fold key must never fragment. Domain
38
+ # vocabulary emerges freely; these structural repairs are fixed.
39
+ _BUILTIN_ALIASES = {
40
+ "inside": "in",
41
+ "located_in": "in",
42
+ "location": "in",
43
+ "contained_in": "in",
44
+ "within": "in",
45
+ "wearing": "worn_by", # direction repaired by the extractor contract
46
+ "holds": "held_by",
47
+ "connected_to": "connects_to",
48
+ "adjacent": "adjacent_to",
49
+ "feature_of": "part_of", # compositional axis (PLACE-FEATURE-ABSTRACTION-V1)
50
+ "component_of": "part_of",
51
+ "type": "kind",
52
+ "is_a": "kind",
53
+ }
54
+
55
+ _EXTRACT_SCHEMA = {
56
+ "type": "object",
57
+ "properties": {
58
+ "items": {
59
+ "type": "array",
60
+ "items": {
61
+ "type": "object",
62
+ "properties": {
63
+ "entity": {"type": "string"},
64
+ "attribute": {"type": "string"},
65
+ "value": {},
66
+ "value_type": {"enum": ["entity", "literal", "unresolved", "delta"]},
67
+ "frame": {"type": "string"},
68
+ "status": {"enum": ["stated", "observed", "inferred", "assumed"]},
69
+ "timeless": {"type": "boolean"},
70
+ "valid_from": {"type": ["number", "null"]},
71
+ "confidence": {"type": ["number", "null"]},
72
+ "source_doc": {"type": ["string", "null"]},
73
+ "caused_by": {"type": ["string", "null"]},
74
+ "aliases": {"type": "array", "items": {"type": "string"}},
75
+ "same_as": {"type": ["string", "null"]},
76
+ "correction": {"type": "boolean"},
77
+ },
78
+ "required": ["entity", "attribute", "value"],
79
+ },
80
+ }
81
+ },
82
+ "required": ["items"],
83
+ }
84
+
85
+ # The extraction rules block (HD 082). `full` is the complete contract; `lean`
86
+ # (opt-in via ingest(extract="lean")) keeps the LOAD-BEARING rules — id
87
+ # namespacing, the `in`/`connects_to`/`kind` canonicalization, value_type, the
88
+ # canon-vs-knows: frame discipline, aliases, timeless, never-invent — and drops
89
+ # the rarely-needed-per-turn ones (document-claims/source_doc, the
90
+ # overturned-belief status nuance, the habits/catchphrases enumeration) to trim
91
+ # input tokens for the hot per-turn render extraction. NOTE: a ~30s extraction is
92
+ # dominated by OUTPUT generation (item count), not this block — so lean is a
93
+ # marginal input-side lever; the structural cut is extracting fewer items
94
+ # (delta/scoped extraction). Quality must be eval-guarded before enabling.
95
+ _EXTRACT_RULES_FULL = (
96
+ "Extract world-state assertions from this narrative passage.\n"
97
+ "Return triples (entity, attribute, value). Rules:\n"
98
+ "- entity ids namespaced: person:/place:/obj:/event:/doc: + snake_case.\n"
99
+ "- attributes: use 'in' for all containment/location; 'connects_to' for "
100
+ "passage; 'kind' for what a thing is; domain attributes freely otherwise.\n"
101
+ "- value_type 'entity' when the value is another entity id.\n"
102
+ "- FRAMES: facts about the world are frame 'canon' — even facts revealed "
103
+ "late or learned by a character mid-story; give them their TRUE historical "
104
+ "valid_from. Use frame 'knows:<person_id>' ONLY for the additional fact "
105
+ "that a character has learned something (a copy marking knowledge), never "
106
+ "instead of the canon row. When character A tells character B an "
107
+ "already-established fact, emit knows:B rows for it — no new canon rows.\n"
108
+ "- status: 'stated' for asserted fact, 'inferred' for a character's "
109
+ "deduction, 'assumed' for a working theory not yet confirmed. When the "
110
+ "story later overturns an earlier belief or official verdict, close it "
111
+ "with valid_to at the time it was overturned.\n"
112
+ "- DOCUMENT CLAIMS (letters, ledgers, logs): emit the claimed fact with "
113
+ "source_doc=<doc id>. Approximate quantities as bounds: 'over forty "
114
+ "thousand' -> {\"gte\": 40000}. When a later source confirms or refines "
115
+ "the SAME fact, use the SAME entity and attribute so the records converge.\n"
116
+ "- ALIASES: attach every referring expression used for an entity as "
117
+ "aliases (e.g. 'the clerk with the tin ear', 'the vault'). When a "
118
+ "previously-seen entity gains a name, keep its id and add the name; if "
119
+ "you minted a duplicate id for the same individual, emit same_as.\n"
120
+ "- SPACE: emit connects_to edges for every passage/route the text "
121
+ "describes (stairs, gates, corridors); never invent an edge the text "
122
+ "does not support — vertical proximity is not connectivity.\n"
123
+ "- TIME: timeless=true ONLY for identity and structure (kind, names, "
124
+ "fixed adjacency). Everything that happens or holds-at-a-time gets "
125
+ "valid_from on the provided timeline.\n"
126
+ "- Repeated habits, spoken catchphrases, confrontations, confessions, "
127
+ "and scheduled/conditional future events are assertions too (use event: "
128
+ "entities with caused_by where the text gives causality).\n"
129
+ "- NEVER invent: extract only what the text supports. Atmosphere and "
130
+ "sensory texture are not assertions.\n"
131
+ "- The narrative voice is not an entity: never emit person: entities for "
132
+ "the narrator, an unnamed speaker, or the audience. Never mint a person "
133
+ "from a bare pronoun; if a pronoun's referent is unknown, skip that "
134
+ "assertion.\n"
135
+ )
136
+ _EXTRACT_RULES_LEAN = (
137
+ "Extract world-state assertions from this narrative passage.\n"
138
+ "Return triples (entity, attribute, value). Rules:\n"
139
+ "- entity ids namespaced: person:/place:/obj:/event: + snake_case.\n"
140
+ "- attributes: 'in' for containment/location; 'connects_to' for passage; "
141
+ "'kind' for what a thing is; domain attributes otherwise.\n"
142
+ "- value_type 'entity' when the value is another entity id.\n"
143
+ "- FRAMES: world facts are frame 'canon' (give their true historical "
144
+ "valid_from even if revealed late); use 'knows:<person_id>' ONLY for the "
145
+ "extra fact that a character learned something, never instead of canon.\n"
146
+ "- aliases: attach referring expressions used for an entity.\n"
147
+ "- ALWAYS extract location changes: X moves/leaves/arrives => a new 'in' "
148
+ "row for X. Presence and departure are core state, never atmosphere "
149
+ "(a departure that goes unrecorded makes presence lie).\n"
150
+ "- timeless=true ONLY for identity/structure (kind, names, fixed adjacency); "
151
+ "everything else gets valid_from.\n"
152
+ "- NEVER invent: extract only what the text supports; atmosphere is not an "
153
+ "assertion.\n"
154
+ "- The narrative voice is not an entity: never emit person: entities for "
155
+ "the narrator, an unnamed speaker, or the audience. Never mint a person "
156
+ "from a bare pronoun; if a pronoun's referent is unknown, skip that "
157
+ "assertion.\n"
158
+ )
159
+
160
+ _SEMANTICS_HINT_KEYS = ("arity", "relation_family", "fold_policy")
161
+ _SEMANTICS_DECL_KEYS = (*_SEMANTICS_HINT_KEYS, "structural")
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class SkipRecord:
166
+ """An edge skipped at the gate (INGEST-HARDENING-V1 Part B): a single
167
+ structurally-invalid edge (cycle / self-edge / lateral self-loop) dropped
168
+ with a reason, while the rest of the chunk ingests. No silent caps — the
169
+ host reads these off the porcelain Receipt's `skipped`."""
170
+
171
+ entity: str
172
+ attribute: str
173
+ value: Any
174
+ reason: str
175
+
176
+
177
+ @dataclass
178
+ class SceneCursor:
179
+ """The ingest-time pose: where on the timeline the narrated action is."""
180
+
181
+ position: float = 0.0
182
+
183
+ def advance(self, to: float) -> None:
184
+ self.position = to
185
+
186
+
187
+ class Ingestor:
188
+ def __init__(
189
+ self,
190
+ buffer: PatternBuffer,
191
+ classifier: Classifier,
192
+ registry: IdentityRegistry,
193
+ role: WriterRole,
194
+ model: Callable[[str, dict], Any] | None = None,
195
+ observe_mode: bool = False,
196
+ clock: Callable[[], float] = time.time,
197
+ classify_inline: bool = True,
198
+ resolver_role: WriterRole | None = None,
199
+ containment_ancestors: Callable[[str, str, float | None], set[str]] | None = None,
200
+ semantics: AttributeSemantics | None = None,
201
+ attribute_default: Callable[[str], dict | None] | None = None,
202
+ ) -> None:
203
+ self._buffer = buffer
204
+ self._classifier = classifier
205
+ self._registry = registry
206
+ self._role = role
207
+ self._semantics = semantics or AttributeSemantics(buffer)
208
+ self._attribute_default = attribute_default
209
+ self._attribute_default_checked: set[str] = set()
210
+ # Letter 029: host-authored `generated` rows (arc repair into
211
+ # plot:-style frames) enter through THIS gate but are appended
212
+ # under RESOLVER authority — the API is ingest_structured, the
213
+ # authority stays the matrix's. Guard enforced below.
214
+ self._resolver_role = resolver_role
215
+ # HD 002 finding 1: cycle-forming containment edges are rejected at
216
+ # the gate (a write-time invariant, not a read-time symptom). The
217
+ # ancestor walk is injected (a thin lambda over indexes.locate) so
218
+ # the engine stays decoupled; when unwired, only the self-edge check
219
+ # runs (it needs no derived state and is always enforced).
220
+ self._containment_ancestors = containment_ancestors
221
+ self._model = model
222
+ self._observe_mode = observe_mode
223
+ self._clock = clock
224
+ self.classify_inline = classify_inline # harness defers to batch
225
+ self.cursor = SceneCursor()
226
+ # INGEST-HARDENING-V1: per-call batched-classify collector + skip records.
227
+ self._classify_collect: list[Assertion] | None = None
228
+ self._skipped: list[SkipRecord] | None = None
229
+ self.last_skipped: list[SkipRecord] = []
230
+ # INGEST-LATENCY-V2 Win 3: cursor governs valid_from for this ingest call.
231
+ self._cursor_authoritative: bool = False
232
+ self._alias_map: dict[str, str] = dict(_BUILTIN_ALIASES)
233
+ self._rebuild_alias_map()
234
+
235
+ # -------------------------------------------------- canonicalization
236
+
237
+ def _rebuild_alias_map(self) -> None:
238
+ """The map is a sidecar judgment, rebuildable from the receipts in
239
+ the log (spec §3.7, letter 002 Q6)."""
240
+ self._alias_map = dict(_BUILTIN_ALIASES)
241
+ for row in self._buffer.visible(attribute="canonicalized_from"):
242
+ if isinstance(row.value, str) and "->" in row.value:
243
+ src, dst = row.value.split("->", 1)
244
+ self._alias_map[src.strip()] = dst.strip()
245
+
246
+ def add_attribute_alias(self, alias: str, canonical: str) -> None:
247
+ self._alias_map[alias.strip().lower()] = canonical
248
+
249
+ def _canonicalize(self, attribute: str) -> tuple[str, str | None]:
250
+ """Returns (canonical, receipt-or-None)."""
251
+ attr = attribute.strip().lower().replace(" ", "_")
252
+ if self._semantics.is_structural(attr) or attr not in self._alias_map:
253
+ return attr, None
254
+ canonical = self._alias_map[attr]
255
+ return canonical, f"{attr}->{canonical}"
256
+
257
+ # ---------------------------------------------------- attr semantics
258
+
259
+ @staticmethod
260
+ def _semantics_payload(source: dict[str, Any], keys) -> dict[str, Any]:
261
+ return {k: source[k] for k in keys if k in source and source[k] is not None}
262
+
263
+ def _maybe_declare_attribute(self, attribute: str, item: dict[str, Any]) -> list[Assertion]:
264
+ """Emit first-use attr:* declarations before the triggering data row."""
265
+ if self._semantics.is_core(attribute) or self._semantics.is_declared(attribute):
266
+ return []
267
+ declaration = self._semantics_payload(item, _SEMANTICS_HINT_KEYS)
268
+ if not declaration and attribute not in self._attribute_default_checked:
269
+ self._attribute_default_checked.add(attribute)
270
+ if self._attribute_default is not None:
271
+ default = self._attribute_default(attribute)
272
+ if default:
273
+ declaration = self._semantics_payload(default, _SEMANTICS_DECL_KEYS)
274
+ if not declaration:
275
+ return []
276
+
277
+ out: list[Assertion] = []
278
+ for predicate, value in declaration.items():
279
+ row = self._buffer.append(
280
+ entity=f"{ATTR_PREFIX}{attribute}",
281
+ attribute=predicate,
282
+ value=value,
283
+ status="inferred",
284
+ role=self._role,
285
+ )
286
+ out.append(row)
287
+ if self.classify_inline:
288
+ self._classifier.classify(row)
289
+ self._semantics.rebuild()
290
+ return out
291
+
292
+ # -------------------------------------------------------- structured
293
+
294
+ def ingest_structured(
295
+ self, items: list[dict[str, Any]], frame: str | None = None,
296
+ classify: str = "inline", cursor_authoritative: bool = False,
297
+ ) -> list[Assertion]:
298
+ """The no-model gate entry: pre-extracted items, full discipline.
299
+ Synthetic test content only — never bible-derived (spec §6).
300
+
301
+ ``frame`` (letter 028): default frame for items that carry none —
302
+ the sanctioned doorway to named-frame authoring (knows:<id>
303
+ session-zero seeding, plot: arcs). Frame is a TARGET only; every
304
+ other gate discipline (provenance, canonicalization, cursor,
305
+ roles) applies unchanged. Per-item frames still win.
306
+
307
+ ``classify`` (INGEST-HARDENING-V1 Part A): durability classification mode.
308
+ ``"inline"`` (default) classifies each row per-row as it lands (unchanged).
309
+ ``"batch"`` defers during the call and runs ONE batch model call over the
310
+ call's model-needing rows at the end — the first-class form of the
311
+ manual ``classify_inline=False`` + ``classify_all`` recipe (~65% build-time
312
+ cut). ``"defer"`` skips classification entirely (the host runs
313
+ ``classify_all`` later over the whole build). ``batch``/``defer`` inherit
314
+ the deferred-classification residual (the read-time ``locate()`` guard
315
+ remains the transitive-cycle backstop, as in the harness build)."""
316
+ if classify not in ("inline", "batch", "defer", "rules"):
317
+ raise ValueError(f"unknown classify mode {classify!r}")
318
+ self._skipped = []
319
+ # "rules" collects like "batch", then applies guardrails+STATE (no LM).
320
+ collect: list[Assertion] | None = [] if classify in ("batch", "rules") else None
321
+ prev_inline = self.classify_inline
322
+ if classify in ("batch", "defer", "rules"):
323
+ self.classify_inline = False
324
+ prev_collect = self._classify_collect # save/restore (re-entrancy-safe)
325
+ self._classify_collect = collect
326
+ prev_cursor_auth = self._cursor_authoritative
327
+ self._cursor_authoritative = cursor_authoritative
328
+ try:
329
+ appended: list[Assertion] = []
330
+ for item in items:
331
+ if frame is not None and "frame" not in item:
332
+ item = {**item, "frame": frame}
333
+ appended.extend(self._ingest_item(item))
334
+ if collect:
335
+ self._classifier.classify_rows(collect, model=(classify == "batch"))
336
+ return appended
337
+ finally:
338
+ # Always reflect THIS call's skips, even if an item raised mid-batch
339
+ # (Cx final: no stale carryover from a prior call).
340
+ self.classify_inline = prev_inline
341
+ self._classify_collect = prev_collect
342
+ self._cursor_authoritative = prev_cursor_auth
343
+ self.last_skipped = list(self._skipped or [])
344
+ self._skipped = None
345
+
346
+ def _cycle_reason(
347
+ self, child: str, parent: str, frame: str, valid_from: float | None
348
+ ) -> str | None:
349
+ """The reason a containment edge would form a cycle, or None (HD 002
350
+ finding 1). Both ids are already identity-resolved. Self-edges are
351
+ detected unconditionally (no derived state). Transitive cycles are
352
+ detected as-of the new edge's valid_from — best-effort: a back-dated
353
+ edge closing a cycle only at a different valid-time isn't visible to a
354
+ single write-time walk and remains caught by the read-time locate()
355
+ guard. INGEST-HARDENING-V1: returns the reason; the caller SKIPS the
356
+ single edge (typed receipt) rather than aborting the chunk."""
357
+ if child == parent:
358
+ return (f"cycle-forming containment edge: {child!r} cannot contain "
359
+ "itself (self-edge; append-only tree invariant, §4)")
360
+ if self._containment_ancestors is None:
361
+ return None
362
+ if child in self._containment_ancestors(parent, frame, valid_from):
363
+ return (f"cycle-forming containment edge: {child!r} is already an "
364
+ f"ancestor of {parent!r} as-of valid_from={valid_from} — "
365
+ "containment is a single-parent tree (§4)")
366
+ return None
367
+
368
+ def _edge_skip_reason(
369
+ self, entity: str, attribute: str, value: Any, value_type: str,
370
+ frame: str, valid_from: float | None,
371
+ ) -> str | None:
372
+ """The reason a single structural edge is invalid (containment cycle /
373
+ self-edge / lateral self-loop), or None (INGEST-HARDENING-V1 Part B).
374
+ Only structurally-invalid SINGLE edges are skippable; every other gate
375
+ failure still raises."""
376
+ if value_type != "entity" or not isinstance(value, str):
377
+ return None
378
+ if self._semantics.is_containment(attribute):
379
+ return self._cycle_reason(entity, value, frame, valid_from)
380
+ if self._semantics.is_lateral(attribute) and entity == value:
381
+ # A lateral self-loop (X connects_to X) is extraction noise — it
382
+ # adds no edge any walk can use (#19).
383
+ return f"lateral self-loop: {entity!r} cannot {attribute} itself"
384
+ return None
385
+
386
+ def _record_skip(self, entity: str, attribute: str, value: Any, reason: str) -> None:
387
+ logger.warning("ingest skipped edge: %s · %s · %r — %s",
388
+ entity, attribute, value, reason)
389
+ if self._skipped is not None:
390
+ self._skipped.append(SkipRecord(entity, attribute, value, reason))
391
+
392
+ def _ingest_item(self, item: dict[str, Any]) -> list[Assertion]:
393
+ out: list[Assertion] = []
394
+ attribute, receipt = self._canonicalize(item["attribute"])
395
+ # RAW ids first (Cx final): validation must see what the author wrote,
396
+ # not what resolution mapped it to — resolve happens AFTER the
397
+ # malformed-id gate below.
398
+ entity = item["entity"]
399
+ # Exact-decimal symmetry: a JSON-origin host passes the tag form
400
+ # ({"$decimal": "12.50"}), an in-process host a real Decimal — both
401
+ # normalize to Decimal here (EXACT-DECIMAL-QUANTITIES-V1).
402
+ value = decode_value(item["value"])
403
+ # Entity inference requires the full id grammar, not a bare ":" —
404
+ # a prose value with a colon ("repaired: the rival arrives") is a
405
+ # literal, never a phantom entity reference (SHAPE-FIX-V1 4a).
406
+ value_type = item.get("value_type") or (
407
+ "entity" if isinstance(value, str) and _ID_RE.fullmatch(value)
408
+ else "literal"
409
+ )
410
+ timeless = bool(item.get("timeless", False))
411
+ valid_from = item.get("valid_from")
412
+ # INGEST-LATENCY-V2 Win 3: in cursor-authoritative ingest (bible
413
+ # source-build) the CURSOR governs the story-time axis — the per-item
414
+ # valid_from is overridden and DEMOTED to a `source_valid_from` meta
415
+ # (lossless), so a diegetic year ("612") can't invert the timeline.
416
+ # Computed before the edge guard (which reads valid_from). Timeless rows
417
+ # carry no story-time, so they are unaffected and never demote.
418
+ demoted_vf = None
419
+ if self._cursor_authoritative and not timeless:
420
+ demoted_vf = valid_from # may be None (nothing to demote)
421
+ valid_from = self.cursor.position
422
+ elif valid_from is None and not timeless:
423
+ valid_from = self.cursor.position # the pose stamps the row
424
+
425
+ # Authority gate FIRST (INGEST-HARDENING-V1 Cx final): an authority
426
+ # violation (generated-into-canon/knows:) must RAISE even if the row is
427
+ # also a structurally-invalid edge — the skip must never swallow it.
428
+ status = item.get("status", "stated")
429
+ write_role = self._role
430
+ if status == "generated":
431
+ frame_target = item.get("frame", CANON)
432
+ if frame_target == CANON or frame_target.startswith("knows:"):
433
+ raise ValueError(
434
+ "generated provenance through the gate is permitted only "
435
+ "into host-owned named frames (e.g. plot:*) — never canon "
436
+ "or knows:* (letter 029 guard)"
437
+ )
438
+ if self._resolver_role is None:
439
+ raise ValueError("no resolver authority wired for generated rows")
440
+ write_role = self._resolver_role
441
+
442
+ # Malformed-id gate (SHAPE-FIX-V1 4a): AFTER the authority gate (an
443
+ # authority violation must still raise, never be swallowed by a skip —
444
+ # the INGEST-HARDENING ordering), BEFORE the edge guard — and on the
445
+ # RAW ids, before resolution touches them (Cx final).
446
+ if not _ID_RE.fullmatch(entity) or (
447
+ value_type == "entity" and isinstance(value, str)
448
+ and not _ID_RE.fullmatch(value)
449
+ ):
450
+ self._record_skip(entity, attribute, value, "malformed_id")
451
+ return out
452
+ entity = self._registry.resolve(entity)
453
+ if value_type == "entity" and isinstance(value, str):
454
+ value = self._registry.resolve(value)
455
+
456
+ # Edge-granular structural guard (Part B): a single invalid edge
457
+ # (containment cycle / self-edge / lateral self-loop) is SKIPPED with a
458
+ # typed receipt — the invariant holds (it never enters) and the rest of
459
+ # the chunk still ingests. (Authority failures already raised above.)
460
+ skip = self._edge_skip_reason(
461
+ entity, attribute, value, value_type,
462
+ item.get("frame", CANON), None if timeless else valid_from,
463
+ )
464
+ if skip is not None:
465
+ self._record_skip(entity, attribute, value, skip)
466
+ return out # nothing appended
467
+
468
+ is_manual_semantics_row = (
469
+ entity.startswith(ATTR_PREFIX) and attribute in SEMANTICS_PREDICATES
470
+ )
471
+ if not is_manual_semantics_row:
472
+ out.extend(self._maybe_declare_attribute(attribute, item))
473
+
474
+ row = self._buffer.append(
475
+ entity=entity,
476
+ attribute=attribute,
477
+ value=value,
478
+ value_type=value_type,
479
+ valid_from=None if timeless else valid_from,
480
+ valid_to=item.get("valid_to"),
481
+ frame=item.get("frame", CANON),
482
+ status=status,
483
+ confidence=item.get("confidence"),
484
+ role=write_role,
485
+ )
486
+ out.append(row)
487
+ if is_manual_semantics_row:
488
+ self._semantics.rebuild()
489
+ if demoted_vf is not None:
490
+ # The per-item story-time coordinate the cursor overrode — preserved
491
+ # losslessly (META_ATTRIBUTES-hidden) for host promotion to a typed
492
+ # content fact (year/era) if wanted (INGEST-LATENCY-V2 Win 3).
493
+ out.append(
494
+ self._buffer.append(
495
+ entity=row.id, attribute="source_valid_from", value=demoted_vf,
496
+ status="inferred", role=self._role,
497
+ )
498
+ )
499
+ if receipt:
500
+ out.append(
501
+ self._buffer.append(
502
+ entity=row.id, attribute="canonicalized_from", value=receipt,
503
+ status="inferred", role=self._role,
504
+ )
505
+ )
506
+ if item.get("source_doc"):
507
+ out.append(
508
+ self._buffer.append(
509
+ entity=row.id, attribute="source", value=str(item["source_doc"]),
510
+ status=item.get("status", "stated"), role=self._role,
511
+ )
512
+ )
513
+ if item.get("caused_by"):
514
+ # Side-channel entity edge: same malformed-id gate as the main row
515
+ # (SHAPE-FIX-V1 4a, Cx final) — a phantom cause never enters.
516
+ caused_by = str(item["caused_by"])
517
+ if not _ID_RE.fullmatch(caused_by):
518
+ self._record_skip(entity, "caused_by", caused_by, "malformed_id")
519
+ else:
520
+ out.append(
521
+ self._buffer.append(
522
+ entity=row.id, attribute="caused_by", value=caused_by,
523
+ value_type="entity", status="inferred", role=self._role,
524
+ # The effect-edge rides in its effect's frame: a non-canon
525
+ # effect's cause must be reachable from a frame-scoped read
526
+ # (else the situation lens false-deads it — Codex post-impl).
527
+ frame=item.get("frame", CANON),
528
+ )
529
+ )
530
+ if self._observe_mode and not timeless:
531
+ # The A2 rider: wall-clock learned-at is a gate invariant here.
532
+ out.append(
533
+ self._buffer.append(
534
+ entity=row.id, attribute="learned_at_wallclock",
535
+ value=self._clock(), status="observed", role=self._role,
536
+ )
537
+ )
538
+ for alias in item.get("aliases", []):
539
+ self._registry.add_alias(entity, alias, status=item.get("status", "stated"))
540
+ if item.get("correction"):
541
+ # The proposal is itself logged (auditable; the promotion's
542
+ # receipts chain ends here, at the utterance's chunk).
543
+ out.append(
544
+ self._buffer.append(
545
+ entity=row.id, attribute="correction_proposal", value=True,
546
+ status="inferred", role=self._role,
547
+ )
548
+ )
549
+ if item.get("same_as"):
550
+ # 036/019: an extractor holds single-call context — identity
551
+ # merges are PROPOSED here, promoted where the whole world is
552
+ # in view (promote_identity_proposals / self-check / tier-2).
553
+ # Same malformed-id gate as the main row (SHAPE-FIX-V1 4a).
554
+ same_as = str(item["same_as"])
555
+ if not _ID_RE.fullmatch(same_as):
556
+ self._record_skip(entity, "same_as", same_as, "malformed_id")
557
+ else:
558
+ self._registry.maybe_same_as(entity, same_as,
559
+ evidence="extractor late binding")
560
+ if self.classify_inline:
561
+ self._classifier.classify(row)
562
+ elif self._classify_collect is not None:
563
+ self._classify_collect.append(row) # batched at end of the call
564
+ return out
565
+
566
+ # ---------------------------------------------------------- extracted
567
+
568
+ def extract(self, text: str, context: str = "",
569
+ extract: str = "full", pov: str | None = None) -> list[dict[str, Any]]:
570
+ """READ-ONLY extraction (INGEST-LATENCY-V2 Win 2): build the prompt, call
571
+ the model, return the raw extracted item dicts. NO buffer write, no
572
+ canonicalization/cursor/resolution (those happen in
573
+ `ingest_structured`/`_ingest_item`). Stateless → safe to call
574
+ concurrently: the host parallelizes N `extract()` calls in its own
575
+ runtime (with its concurrency cap) then `ingest_structured()`s the
576
+ results SERIALLY (the append-only writes stay serial). `extract` selects
577
+ the full|lean rules block. ``pov`` (SHAPE-FIX-V1 4c): the viewpoint
578
+ entity id — deixis pronouns bind to it instead of minting phantoms.
579
+ Id-validated BEFORE prompt interpolation (never ride an unvalidated
580
+ string into the model)."""
581
+ if self._model is None:
582
+ raise RuntimeError("no model callable injected; use ingest_structured")
583
+ rules = _EXTRACT_RULES_LEAN if extract == "lean" else _EXTRACT_RULES_FULL
584
+ if pov is not None:
585
+ if not _ID_RE.fullmatch(pov):
586
+ raise ValueError(f"pov {pov!r} is not a valid entity id")
587
+ rules += (
588
+ f"- First/second-person pronouns (I, you, we) referring to the "
589
+ f"viewpoint character are {pov} — never mint a new entity for "
590
+ f"them.\n"
591
+ )
592
+ prompt = f"{rules}{context}\n\nPASSAGE:\n{text}"
593
+ # RAW output, verbatim (INGEST-LATENCY-V2; Cx 545): extract() returns the
594
+ # model's item dicts untouched — including an explicit frame="canon"
595
+ # stamp. Canon-vs-absent is equivalent at the INGEST GATE only; a raw
596
+ # consumer auditing/persisting extraction output must see what the model
597
+ # said. Re-targeting a batch (staging/quarantine) is host policy over a
598
+ # COPY — see ADOPTION's strip idiom and the Receipt warning.
599
+ return self._model(prompt, _EXTRACT_SCHEMA)["items"]
600
+
601
+ def ingest(self, text: str, context: str = "", frame: str | None = None,
602
+ classify: str = "inline", extract: str = "full",
603
+ cursor_authoritative: bool = False,
604
+ pov: str | None = None) -> list[Assertion]:
605
+ """Model-backed extraction through the same gate (= `extract` then
606
+ `ingest_structured`, behavior-identical). ``frame`` is the DEFAULT frame for
607
+ extracted rows that carry none (letter 028) — extracted items stamping their
608
+ own frame (incl. an explicit canon) keep it; see ingest_structured. ``classify`` (HD 079): inline|batch|
609
+ defer|rules durability. ``extract`` (HD 082): full|lean rules.
610
+ ``cursor_authoritative`` (HD 084): the cursor governs valid_from (bible
611
+ source-ingest); see ingest_structured. ``pov`` (SHAPE-FIX-V1 4c): the
612
+ viewpoint entity id for deixis binding."""
613
+ items = self.extract(text, context, extract, pov=pov)
614
+ return self.ingest_structured(
615
+ items, frame=frame, classify=classify,
616
+ cursor_authoritative=cursor_authoritative,
617
+ )