pbuffer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patternbuffer/__init__.py +317 -0
- patternbuffer/buffer.py +312 -0
- patternbuffer/classify.py +359 -0
- patternbuffer/codec.py +122 -0
- patternbuffer/dump.py +113 -0
- patternbuffer/identity.py +1271 -0
- patternbuffer/indexes.py +1642 -0
- patternbuffer/ingest.py +617 -0
- patternbuffer/mcp.py +303 -0
- patternbuffer/model.py +100 -0
- patternbuffer/porcelain.py +943 -0
- patternbuffer/project.py +436 -0
- patternbuffer/refer.py +256 -0
- patternbuffer/roles.py +77 -0
- patternbuffer/salience.py +170 -0
- patternbuffer/semantics.py +192 -0
- patternbuffer/testing.py +92 -0
- patternbuffer/thunks.py +245 -0
- patternbuffer/tmaint.py +122 -0
- pbuffer-0.1.0.dist-info/METADATA +77 -0
- pbuffer-0.1.0.dist-info/RECORD +25 -0
- pbuffer-0.1.0.dist-info/WHEEL +5 -0
- pbuffer-0.1.0.dist-info/entry_points.txt +2 -0
- pbuffer-0.1.0.dist-info/licenses/LICENSE +21 -0
- pbuffer-0.1.0.dist-info/top_level.txt +1 -0
patternbuffer/ingest.py
ADDED
|
@@ -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
|
+
)
|