pbuffer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patternbuffer/__init__.py +317 -0
- patternbuffer/buffer.py +312 -0
- patternbuffer/classify.py +359 -0
- patternbuffer/codec.py +122 -0
- patternbuffer/dump.py +113 -0
- patternbuffer/identity.py +1271 -0
- patternbuffer/indexes.py +1642 -0
- patternbuffer/ingest.py +617 -0
- patternbuffer/mcp.py +303 -0
- patternbuffer/model.py +100 -0
- patternbuffer/porcelain.py +943 -0
- patternbuffer/project.py +436 -0
- patternbuffer/refer.py +256 -0
- patternbuffer/roles.py +77 -0
- patternbuffer/salience.py +170 -0
- patternbuffer/semantics.py +192 -0
- patternbuffer/testing.py +92 -0
- patternbuffer/thunks.py +245 -0
- patternbuffer/tmaint.py +122 -0
- pbuffer-0.1.0.dist-info/METADATA +77 -0
- pbuffer-0.1.0.dist-info/RECORD +25 -0
- pbuffer-0.1.0.dist-info/WHEEL +5 -0
- pbuffer-0.1.0.dist-info/entry_points.txt +2 -0
- pbuffer-0.1.0.dist-info/licenses/LICENSE +21 -0
- pbuffer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""The durability classifier and its rebuildable sidecar (whitepaper §5).
|
|
2
|
+
|
|
3
|
+
Durability is an index, not truth (P1): the class is a judgment about a
|
|
4
|
+
fact, keyed by assertion id, living in a sidecar table that can be
|
|
5
|
+
dropped and re-derived from the untouched log at any time. Deterministic
|
|
6
|
+
guardrails run first and short-circuit; the injected model judges only
|
|
7
|
+
what they leave ambiguous, under asymmetric-cost defaults.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
from patternbuffer.buffer import PatternBuffer
|
|
18
|
+
from patternbuffer.codec import json_default
|
|
19
|
+
from patternbuffer.model import ATTR_PREFIX, META_ATTRIBUTES, Assertion
|
|
20
|
+
from patternbuffer.semantics import AttributeSemantics
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
CONSTITUTIVE = "CONSTITUTIVE"
|
|
25
|
+
DISPOSITIONAL = "DISPOSITIONAL"
|
|
26
|
+
STATE = "STATE"
|
|
27
|
+
EVENT = "EVENT"
|
|
28
|
+
DURABILITIES = frozenset({CONSTITUTIVE, DISPOSITIONAL, STATE, EVENT})
|
|
29
|
+
# The classes the MODEL may assign (CLASSIFIER-EVENT-SAFETY-V1). EVENT is
|
|
30
|
+
# excluded: it is the one verdict that ERASES a row from every fold
|
|
31
|
+
# (indexes drops EVENT before folding), so a model flip STATE->EVENT is a
|
|
32
|
+
# silent read-completeness failure. Occurrence-ness is structural — assigned
|
|
33
|
+
# only by the deterministic guardrails (event:/caused_by/META) or host
|
|
34
|
+
# declaration — never sampled per-row by the model. The model judges only the
|
|
35
|
+
# standing-durability spectrum; ambiguity defaults to STATE (visible,
|
|
36
|
+
# correctable), never erased.
|
|
37
|
+
_STANDING_DURABILITIES = frozenset({CONSTITUTIVE, DISPOSITIONAL, STATE})
|
|
38
|
+
|
|
39
|
+
# Review threshold: low-confidence CONSTITUTIVE verdicts silently corrupt
|
|
40
|
+
# every future materialization (whitepaper §5.1), so they are flagged.
|
|
41
|
+
_REVIEW_FLOOR = 0.6
|
|
42
|
+
|
|
43
|
+
_SIDECAR_SCHEMA = """
|
|
44
|
+
CREATE TABLE IF NOT EXISTS sidecar_classification (
|
|
45
|
+
assertion_id TEXT PRIMARY KEY,
|
|
46
|
+
durability TEXT NOT NULL,
|
|
47
|
+
class_confidence REAL NOT NULL,
|
|
48
|
+
needs_review INTEGER NOT NULL DEFAULT 0
|
|
49
|
+
);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS sidecar_classifier_meta (
|
|
51
|
+
key TEXT PRIMARY KEY,
|
|
52
|
+
value INTEGER NOT NULL
|
|
53
|
+
);
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_MODEL_SCHEMA = {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"properties": {
|
|
59
|
+
"durability": {"enum": sorted(_STANDING_DURABILITIES)},
|
|
60
|
+
"class_confidence": {"type": "number"},
|
|
61
|
+
},
|
|
62
|
+
"required": ["durability", "class_confidence"],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class Classification:
|
|
68
|
+
assertion_id: str
|
|
69
|
+
durability: str
|
|
70
|
+
class_confidence: float
|
|
71
|
+
needs_review: bool
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Classifier:
|
|
75
|
+
"""classify(assertion, world_context) -> {durability, class_confidence}.
|
|
76
|
+
|
|
77
|
+
Writes the sidecar only — never the log (role matrix §12).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
buffer: PatternBuffer,
|
|
83
|
+
model: Callable[[str, dict], Any],
|
|
84
|
+
semantics: AttributeSemantics | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self._buffer = buffer
|
|
87
|
+
self._model = model
|
|
88
|
+
self._semantics = semantics or AttributeSemantics(buffer)
|
|
89
|
+
conn = buffer.raw_connection()
|
|
90
|
+
conn.executescript(_SIDECAR_SCHEMA)
|
|
91
|
+
conn.execute(
|
|
92
|
+
"INSERT OR IGNORE INTO sidecar_classifier_meta (key, value)"
|
|
93
|
+
" VALUES ('version', 0)"
|
|
94
|
+
)
|
|
95
|
+
conn.commit()
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------ judgment
|
|
98
|
+
|
|
99
|
+
def _guardrails(self, row: Assertion) -> tuple[str, float] | None:
|
|
100
|
+
"""Deterministic short-circuits. Return None to defer to the model."""
|
|
101
|
+
if row.value_type == "delta":
|
|
102
|
+
return STATE, 1.0
|
|
103
|
+
if row.entity.startswith("event:") or row.attribute == "caused_by":
|
|
104
|
+
return EVENT, 1.0
|
|
105
|
+
if row.attribute in {"name", "alias"}:
|
|
106
|
+
return CONSTITUTIVE, 0.95 # identity anchors
|
|
107
|
+
if row.attribute in META_ATTRIBUTES:
|
|
108
|
+
# Meta-assertions ride with their subject; they never fold as
|
|
109
|
+
# world facts. Classified EVENT-like for lens purposes: immutable.
|
|
110
|
+
return EVENT, 1.0
|
|
111
|
+
if row.value_type == "unresolved":
|
|
112
|
+
return STATE, 1.0 # a thunk occupies its key like mutable state
|
|
113
|
+
if self._semantics.is_containment(row.attribute):
|
|
114
|
+
if row.attribute in {"held_by", "worn_by", "carried_by"}:
|
|
115
|
+
return STATE, 0.95 # things held by agents are movable
|
|
116
|
+
if row.entity.startswith("place:"):
|
|
117
|
+
# A place's containment is part of what the place IS — the
|
|
118
|
+
# study does not move out of the home (fixture sub-rule).
|
|
119
|
+
return CONSTITUTIVE, 0.9
|
|
120
|
+
return None # objects: fixture vs movable is a judgment
|
|
121
|
+
if self._semantics.is_structural(row.attribute):
|
|
122
|
+
return CONSTITUTIVE, 1.0
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def classify(self, row: Assertion) -> Classification:
|
|
126
|
+
verdict = self._guardrails(row)
|
|
127
|
+
if verdict is not None:
|
|
128
|
+
durability, confidence = verdict
|
|
129
|
+
else:
|
|
130
|
+
durability, confidence = self._ask_model(row)
|
|
131
|
+
needs_review = durability == CONSTITUTIVE and confidence < _REVIEW_FLOOR
|
|
132
|
+
c = Classification(row.id, durability, confidence, needs_review)
|
|
133
|
+
self._store(c)
|
|
134
|
+
return c
|
|
135
|
+
|
|
136
|
+
def _ask_model(self, row: Assertion) -> tuple[str, float]:
|
|
137
|
+
prompt = (
|
|
138
|
+
"Classify the lifetime of this fact about a world.\n"
|
|
139
|
+
f"Subject: {row.entity}\nAttribute: {row.attribute}\n"
|
|
140
|
+
f"Value: {json.dumps(row.value, default=json_default)}\n\n"
|
|
141
|
+
"CONSTITUTIVE: what the thing IS (identity, structure, fixtures, era). "
|
|
142
|
+
"True at every moment unless the world is re-authored.\n"
|
|
143
|
+
"DISPOSITIONAL: what it TENDS to be (habits, roles, recurring behavior). "
|
|
144
|
+
"Generally true but defeasible.\n"
|
|
145
|
+
"STATE: what it is RIGHT NOW (positions of movables, moods, conditions). "
|
|
146
|
+
"One event could flip it.\n\n"
|
|
147
|
+
"The mutability test resolves most ambiguity: could one event flip this "
|
|
148
|
+
"without re-authoring the world? -> STATE. Asymmetric defaults: an "
|
|
149
|
+
"ambiguous property is STATE; ambiguous fixture containment is "
|
|
150
|
+
"CONSTITUTIVE."
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
out = self._model(prompt, _MODEL_SCHEMA)
|
|
154
|
+
durability = out["durability"]
|
|
155
|
+
confidence = float(out["class_confidence"])
|
|
156
|
+
if durability not in _STANDING_DURABILITIES:
|
|
157
|
+
raise ValueError(durability)
|
|
158
|
+
except Exception:
|
|
159
|
+
logger.exception("classifier model call failed for %s; defaulting", row.id)
|
|
160
|
+
# Asymmetric default: ambiguous property -> STATE, except
|
|
161
|
+
# in/within containment, which defaults CONSTITUTIVE.
|
|
162
|
+
if self._semantics.is_containment(row.attribute):
|
|
163
|
+
return CONSTITUTIVE, 0.5
|
|
164
|
+
return STATE, 0.5
|
|
165
|
+
return durability, confidence
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------- sidecar
|
|
168
|
+
|
|
169
|
+
def _store(self, c: Classification) -> None:
|
|
170
|
+
conn = self._buffer.raw_connection()
|
|
171
|
+
conn.execute(
|
|
172
|
+
"INSERT OR REPLACE INTO sidecar_classification"
|
|
173
|
+
" (assertion_id, durability, class_confidence, needs_review)"
|
|
174
|
+
" VALUES (?, ?, ?, ?)",
|
|
175
|
+
(c.assertion_id, c.durability, c.class_confidence, int(c.needs_review)),
|
|
176
|
+
)
|
|
177
|
+
conn.execute(
|
|
178
|
+
"UPDATE sidecar_classifier_meta SET value = value + 1"
|
|
179
|
+
" WHERE key = 'version'"
|
|
180
|
+
)
|
|
181
|
+
conn.commit()
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def version(self) -> int:
|
|
185
|
+
row = self._buffer.raw_connection().execute(
|
|
186
|
+
"SELECT value FROM sidecar_classifier_meta WHERE key = 'version'"
|
|
187
|
+
).fetchone()
|
|
188
|
+
return int(row[0]) if row else 0
|
|
189
|
+
|
|
190
|
+
def set(self, assertion_id: str, durability: str, confidence: float = 1.0) -> None:
|
|
191
|
+
"""Judgment injection for components that hold the world context
|
|
192
|
+
(e.g. the resolver knows invented contents are movables). Sidecar
|
|
193
|
+
only — the log is untouched, and rebuild() re-derives."""
|
|
194
|
+
if durability not in DURABILITIES:
|
|
195
|
+
raise ValueError(durability)
|
|
196
|
+
self._store(Classification(assertion_id, durability, confidence, False))
|
|
197
|
+
|
|
198
|
+
def get(self, assertion_id: str) -> Classification | None:
|
|
199
|
+
r = self._buffer.raw_connection().execute(
|
|
200
|
+
"SELECT assertion_id, durability, class_confidence, needs_review"
|
|
201
|
+
" FROM sidecar_classification WHERE assertion_id = ?",
|
|
202
|
+
(assertion_id,),
|
|
203
|
+
).fetchone()
|
|
204
|
+
return Classification(r[0], r[1], r[2], bool(r[3])) if r else None
|
|
205
|
+
|
|
206
|
+
def durability(self, assertion_id: str) -> str:
|
|
207
|
+
"""The fold's view. Unclassified rows read as STATE (conservative:
|
|
208
|
+
a transient mistaken for structure decays; structure mistaken for
|
|
209
|
+
transient gets flagged on contradiction) — classify_all() should
|
|
210
|
+
leave none."""
|
|
211
|
+
c = self.get(assertion_id)
|
|
212
|
+
return c.durability if c else STATE
|
|
213
|
+
|
|
214
|
+
def classify_all(self, batch_size: int | None = None) -> int:
|
|
215
|
+
"""Classify every row not yet in the sidecar. Returns count.
|
|
216
|
+
|
|
217
|
+
With ``batch_size``, model-judged rows go to the model in batches
|
|
218
|
+
(one call per N rows) — same judgments, fewer round trips."""
|
|
219
|
+
pending = [r for r in self._buffer.all_rows() if self.get(r.id) is None]
|
|
220
|
+
if not batch_size:
|
|
221
|
+
for row in pending:
|
|
222
|
+
self.classify(row)
|
|
223
|
+
return len(pending)
|
|
224
|
+
return self.classify_rows(pending, batch_size)
|
|
225
|
+
|
|
226
|
+
def classify_rows(self, rows: list[Assertion], batch_size: int | None = None,
|
|
227
|
+
model: bool = True) -> int:
|
|
228
|
+
"""Classify a SPECIFIC set of rows (INGEST-HARDENING-V1): guardrails
|
|
229
|
+
resolve immediately with zero model calls; model-needing rows go to one
|
|
230
|
+
batch call (or `batch_size` chunks). The shared core of `classify_all`'s
|
|
231
|
+
batch path and the opt-in batched ingest. Already-classified rows skip.
|
|
232
|
+
|
|
233
|
+
``model=False`` (INGEST-LATENCY-V2 `classify="rules"`): the ambiguous
|
|
234
|
+
remainder gets the doctrine's primary asymmetric default STATE with NO
|
|
235
|
+
LM call — fast and deterministic. Deliberately STATE (not the
|
|
236
|
+
containment→CONSTITUTIVE except sub-rule) so a movable's containment
|
|
237
|
+
recency-supersedes; `needs_review` is never raised (STATE isn't flagged).
|
|
238
|
+
The standing-spectrum default is safe: durability is rebuildable and the
|
|
239
|
+
erasing EVENT is barred (CLASSIFIER-EVENT-SAFETY)."""
|
|
240
|
+
rows = [r for r in rows if self.get(r.id) is None]
|
|
241
|
+
deferred: list[Assertion] = []
|
|
242
|
+
for row in rows:
|
|
243
|
+
verdict = self._guardrails(row)
|
|
244
|
+
if verdict is not None:
|
|
245
|
+
durability, confidence = verdict
|
|
246
|
+
self._store(
|
|
247
|
+
Classification(
|
|
248
|
+
row.id, durability, confidence,
|
|
249
|
+
durability == CONSTITUTIVE and confidence < _REVIEW_FLOOR,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
deferred.append(row)
|
|
254
|
+
if deferred:
|
|
255
|
+
if not model:
|
|
256
|
+
for row in deferred:
|
|
257
|
+
self._store(Classification(row.id, STATE, 0.5, False))
|
|
258
|
+
else:
|
|
259
|
+
step = batch_size or len(deferred)
|
|
260
|
+
for start in range(0, len(deferred), step):
|
|
261
|
+
self._classify_batch(deferred[start : start + step])
|
|
262
|
+
return len(rows)
|
|
263
|
+
|
|
264
|
+
def _classify_batch(self, rows: list[Assertion]) -> None:
|
|
265
|
+
listing = "\n".join(
|
|
266
|
+
f"{i}. {r.entity} · {r.attribute} · {json.dumps(r.value, default=json_default)}"
|
|
267
|
+
for i, r in enumerate(rows)
|
|
268
|
+
)
|
|
269
|
+
schema = {
|
|
270
|
+
"type": "object",
|
|
271
|
+
"properties": {
|
|
272
|
+
"verdicts": {
|
|
273
|
+
"type": "array",
|
|
274
|
+
"items": {
|
|
275
|
+
"type": "object",
|
|
276
|
+
"properties": {
|
|
277
|
+
"index": {"type": "integer"},
|
|
278
|
+
"durability": {"enum": sorted(_STANDING_DURABILITIES)},
|
|
279
|
+
"class_confidence": {"type": "number"},
|
|
280
|
+
},
|
|
281
|
+
"required": ["index", "durability", "class_confidence"],
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
"required": ["verdicts"],
|
|
286
|
+
}
|
|
287
|
+
prompt = (
|
|
288
|
+
"Classify the lifetime of each fact about a world. Classes:\n"
|
|
289
|
+
"CONSTITUTIVE: what the thing IS (identity, structure, fixtures, era); "
|
|
290
|
+
"true at every moment unless the world is re-authored.\n"
|
|
291
|
+
"DISPOSITIONAL: what it TENDS to be (habits, roles); defeasible.\n"
|
|
292
|
+
"STATE: what it is RIGHT NOW (positions of movables, moods); one event "
|
|
293
|
+
"could flip it.\n"
|
|
294
|
+
"Mutability test: could one event flip it without re-authoring the "
|
|
295
|
+
"world? -> STATE. Ambiguous property -> STATE; ambiguous fixture "
|
|
296
|
+
"containment -> CONSTITUTIVE.\n\nFacts:\n" + listing
|
|
297
|
+
)
|
|
298
|
+
verdicts: dict[int, tuple[str, float]] = {}
|
|
299
|
+
try:
|
|
300
|
+
out = self._model(prompt, schema)
|
|
301
|
+
for v in out["verdicts"]:
|
|
302
|
+
# EVENT (or any non-standing verdict) is rejected -> the row
|
|
303
|
+
# falls to the asymmetric default below, never erased.
|
|
304
|
+
if v["durability"] in _STANDING_DURABILITIES:
|
|
305
|
+
verdicts[int(v["index"])] = (v["durability"], float(v["class_confidence"]))
|
|
306
|
+
except Exception:
|
|
307
|
+
logger.exception("batch classification failed; defaulting batch")
|
|
308
|
+
for i, row in enumerate(rows):
|
|
309
|
+
durability, confidence = verdicts.get(
|
|
310
|
+
i,
|
|
311
|
+
(CONSTITUTIVE, 0.5)
|
|
312
|
+
if self._semantics.is_containment(row.attribute)
|
|
313
|
+
else (STATE, 0.5),
|
|
314
|
+
)
|
|
315
|
+
self._store(
|
|
316
|
+
Classification(
|
|
317
|
+
row.id, durability, confidence,
|
|
318
|
+
durability == CONSTITUTIVE and confidence < _REVIEW_FLOOR,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def promote_accruals(self, threshold: int = 3) -> int:
|
|
323
|
+
"""Accrual promotion (whitepaper §5.1): repeated STATE observations
|
|
324
|
+
of the same (entity, attribute, value) — at distinct valid times —
|
|
325
|
+
promote to DISPOSITIONAL in the sidecar. The world learns its
|
|
326
|
+
habits; the log is untouched; rebuild() re-derives. Returns the
|
|
327
|
+
number of rows promoted. Promotion frequency doubles as the
|
|
328
|
+
salience baseline's input."""
|
|
329
|
+
groups: dict[tuple, set] = {}
|
|
330
|
+
rows_by_group: dict[tuple, list] = {}
|
|
331
|
+
for row in self._buffer.visible():
|
|
332
|
+
if (
|
|
333
|
+
row.entity.startswith("a:")
|
|
334
|
+
or row.entity.startswith(ATTR_PREFIX)
|
|
335
|
+
or row.value_type in ("unresolved", "delta")
|
|
336
|
+
or self._semantics.is_accrue(row.attribute)
|
|
337
|
+
):
|
|
338
|
+
continue # deltas / accrue quantities are summed, never a habit
|
|
339
|
+
if self.durability(row.id) != STATE:
|
|
340
|
+
continue
|
|
341
|
+
key = (row.entity, row.attribute, row.frame, repr(row.value))
|
|
342
|
+
groups.setdefault(key, set()).add(row.valid_from)
|
|
343
|
+
rows_by_group.setdefault(key, []).append(row)
|
|
344
|
+
promoted = 0
|
|
345
|
+
for key, valid_times in groups.items():
|
|
346
|
+
if len(valid_times) >= threshold:
|
|
347
|
+
for row in rows_by_group[key]:
|
|
348
|
+
self.set(row.id, DISPOSITIONAL, 0.8)
|
|
349
|
+
promoted += 1
|
|
350
|
+
if promoted:
|
|
351
|
+
logger.info("accrual promotion: %d row(s) -> DISPOSITIONAL", promoted)
|
|
352
|
+
return promoted
|
|
353
|
+
|
|
354
|
+
def rebuild(self) -> int:
|
|
355
|
+
"""Drop the sidecar and re-derive it from the untouched log."""
|
|
356
|
+
conn = self._buffer.raw_connection()
|
|
357
|
+
conn.execute("DELETE FROM sidecar_classification")
|
|
358
|
+
conn.commit()
|
|
359
|
+
return self.classify_all()
|
patternbuffer/codec.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Value codec: exact-decimal quantities as tagged JSON scalars.
|
|
2
|
+
|
|
3
|
+
EXACT-DECIMAL-QUANTITIES-V1: a quantity that must fold exactly (money, any
|
|
4
|
+
real ledger) is a Python ``Decimal`` in memory and the reserved tag dict
|
|
5
|
+
``{"$decimal": "12.50"}`` on every JSON boundary — storage, dump, prompts,
|
|
6
|
+
host payloads. ``str(Decimal)`` preserves authored scale (``12.50`` stays
|
|
7
|
+
``12.50``); no float ever touches the number. Fiction floats stay floats:
|
|
8
|
+
the Decimal path is entered only by values that are Decimal.
|
|
9
|
+
|
|
10
|
+
Fold arithmetic (`exact_sum`/`exact_div`) runs under one explicit, fixed
|
|
11
|
+
context (`MONEY_CTX`) so builds are byte-deterministic across platforms
|
|
12
|
+
(P7) regardless of the process's ambient decimal context. Mixing exact
|
|
13
|
+
Decimal with float in one fold is an authoring smell and raises; Decimal
|
|
14
|
+
with int promotes losslessly.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import decimal
|
|
20
|
+
from decimal import Decimal, localcontext
|
|
21
|
+
from typing import Any, Iterable
|
|
22
|
+
|
|
23
|
+
DEC_TAG = "$decimal"
|
|
24
|
+
"""Reserved key in value-space (LEXICON). Only an exact one-key dict whose
|
|
25
|
+
value parses as a finite Decimal is reconstructed; anything else passes
|
|
26
|
+
through untouched (the collision guard)."""
|
|
27
|
+
|
|
28
|
+
MONEY_CTX = decimal.Context(prec=50, rounding=decimal.ROUND_HALF_EVEN)
|
|
29
|
+
"""The one context all Decimal fold arithmetic runs under. Addition is
|
|
30
|
+
exact for any realistic ledger within prec=50; division (avg) rounds
|
|
31
|
+
HALF_EVEN — named and deterministic."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_finite_dec_str(s: object) -> bool:
|
|
35
|
+
if not isinstance(s, str):
|
|
36
|
+
return False
|
|
37
|
+
try:
|
|
38
|
+
return Decimal(s).is_finite()
|
|
39
|
+
except decimal.InvalidOperation:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def encode_value(v: Any) -> Any:
|
|
44
|
+
"""One in-memory scalar -> its JSON-safe form (Decimal -> tag dict)."""
|
|
45
|
+
if isinstance(v, Decimal):
|
|
46
|
+
return {DEC_TAG: str(v)}
|
|
47
|
+
return v
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def decode_value(v: Any) -> Any:
|
|
51
|
+
"""One JSON-safe scalar -> in-memory (exact tag dict -> Decimal)."""
|
|
52
|
+
if isinstance(v, dict) and set(v) == {DEC_TAG} and _is_finite_dec_str(v[DEC_TAG]):
|
|
53
|
+
return Decimal(v[DEC_TAG])
|
|
54
|
+
return v
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def json_default(o: Any) -> Any:
|
|
58
|
+
"""``json.dumps(..., default=json_default)`` for internal serialization."""
|
|
59
|
+
if isinstance(o, Decimal):
|
|
60
|
+
return {DEC_TAG: str(o)}
|
|
61
|
+
raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def decode_hook(d: dict) -> Any:
|
|
65
|
+
"""``json.loads(..., object_hook=decode_hook)``: reconstruct tag dicts."""
|
|
66
|
+
return decode_value(d)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def encode_out(obj: Any) -> Any:
|
|
70
|
+
"""Recursively encode Decimal leaves in a host-facing payload.
|
|
71
|
+
|
|
72
|
+
Porcelain/neighborhood public returns wrap in this at the return
|
|
73
|
+
statement (one wrapper per verb) so no field — present or future —
|
|
74
|
+
can leak a raw Decimal into the plain-JSON contract.
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(obj, Decimal):
|
|
77
|
+
return {DEC_TAG: str(obj)}
|
|
78
|
+
if isinstance(obj, dict):
|
|
79
|
+
return {k: encode_out(v) for k, v in obj.items()}
|
|
80
|
+
if isinstance(obj, (list, tuple)):
|
|
81
|
+
return [encode_out(v) for v in obj]
|
|
82
|
+
return obj
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_no_mix(values: Iterable[Any], *, ids: Iterable[str] = ()) -> None:
|
|
86
|
+
"""Raise if exact Decimal and float coexist in one fold's value set.
|
|
87
|
+
|
|
88
|
+
Runs before ANY rollup op (sum/avg/min/max — count validates with the
|
|
89
|
+
rest): a mixed representation on one attribute is an authoring smell
|
|
90
|
+
to surface, never to silently promote."""
|
|
91
|
+
vals = list(values)
|
|
92
|
+
if any(isinstance(v, Decimal) for v in vals) and any(
|
|
93
|
+
isinstance(v, float) for v in vals
|
|
94
|
+
):
|
|
95
|
+
where = ", ".join(ids)
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"exact-decimal and float quantities mixed in one fold"
|
|
98
|
+
+ (f" ({where})" if where else "")
|
|
99
|
+
+ "; author one representation per attribute"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def exact_sum(values: Iterable[Any], *, ids: Iterable[str] = ()) -> Any:
|
|
104
|
+
"""Sum a fold's values: any-Decimal => Decimal-exact under MONEY_CTX
|
|
105
|
+
(ints promote losslessly); all-float/int => the native sum, unchanged."""
|
|
106
|
+
vals = list(values)
|
|
107
|
+
check_no_mix(vals, ids=ids)
|
|
108
|
+
if any(isinstance(v, Decimal) for v in vals):
|
|
109
|
+
with localcontext(MONEY_CTX):
|
|
110
|
+
return sum(
|
|
111
|
+
(v if isinstance(v, Decimal) else Decimal(v) for v in vals),
|
|
112
|
+
Decimal(0),
|
|
113
|
+
)
|
|
114
|
+
return sum(vals)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def exact_div(total: Any, count: int) -> Any:
|
|
118
|
+
"""Division for avg: Decimal path under MONEY_CTX (HALF_EVEN), else native."""
|
|
119
|
+
if isinstance(total, Decimal):
|
|
120
|
+
with localcontext(MONEY_CTX):
|
|
121
|
+
return total / Decimal(count)
|
|
122
|
+
return total / count
|
patternbuffer/dump.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""JSONL dump + deterministic builder (spec §10, the letter-005 seam).
|
|
2
|
+
|
|
3
|
+
The dump is the canonical, diffable artifact; the SQLite file is
|
|
4
|
+
disposable. ``build`` replays a dump through a builder-privileged append
|
|
5
|
+
that preserves ``seq``/``id``/``asserted_at`` byte-for-byte — and it is
|
|
6
|
+
not a second write authority: every constraint below aborts the build.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from patternbuffer.buffer import PatternBuffer
|
|
16
|
+
from patternbuffer.codec import decode_hook, json_default
|
|
17
|
+
from patternbuffer.model import STATUSES, VALUE_TYPES, Assertion
|
|
18
|
+
from patternbuffer.roles import _make_builder_role
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_FIELDS = (
|
|
23
|
+
"seq",
|
|
24
|
+
"id",
|
|
25
|
+
"world_id",
|
|
26
|
+
"entity",
|
|
27
|
+
"attribute",
|
|
28
|
+
"value_type",
|
|
29
|
+
"value",
|
|
30
|
+
"valid_from",
|
|
31
|
+
"valid_to",
|
|
32
|
+
"frame",
|
|
33
|
+
"status",
|
|
34
|
+
"confidence",
|
|
35
|
+
"asserted_at",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DumpError(ValueError):
|
|
40
|
+
"""A dump failed the builder's validation; nothing was built."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def dump(buffer: PatternBuffer) -> str:
|
|
44
|
+
"""The full log as JSONL, one assertion per line, in append order."""
|
|
45
|
+
lines = []
|
|
46
|
+
for row in buffer.all_rows():
|
|
47
|
+
lines.append(
|
|
48
|
+
json.dumps({f: getattr(row, f) for f in _FIELDS}, sort_keys=True,
|
|
49
|
+
default=json_default)
|
|
50
|
+
)
|
|
51
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build(jsonl: str, path: str | Path) -> PatternBuffer:
|
|
55
|
+
"""Materialize a buffer from a dump. Refuses anything but a clean replay.
|
|
56
|
+
|
|
57
|
+
Constraints (spec §10): target absent or empty; exactly one
|
|
58
|
+
``world_id`` across all rows; ``seq`` contiguous from 1;
|
|
59
|
+
``id == "a:<seq>"`` and ``asserted_at == seq``; statuses and value
|
|
60
|
+
types in vocabulary. No judgment runs — the dump already carries the
|
|
61
|
+
log as it was; sidecars are rebuilt by their own ``rebuild()``.
|
|
62
|
+
"""
|
|
63
|
+
path = Path(path)
|
|
64
|
+
if path.exists() and path.stat().st_size > 0:
|
|
65
|
+
raise DumpError(f"build target {path} already exists; restore is not a write path")
|
|
66
|
+
|
|
67
|
+
rows = []
|
|
68
|
+
for n, line in enumerate(jsonl.splitlines(), start=1):
|
|
69
|
+
if not line.strip():
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
raw = json.loads(line, object_hook=decode_hook)
|
|
73
|
+
except json.JSONDecodeError as exc:
|
|
74
|
+
raise DumpError(f"line {n}: not valid JSON: {exc}") from exc
|
|
75
|
+
if not isinstance(raw, dict):
|
|
76
|
+
# A bare tag line ({"$decimal": ...}) decodes to a Decimal, and any
|
|
77
|
+
# other non-object line is equally not a row — abort as validation,
|
|
78
|
+
# never an AttributeError.
|
|
79
|
+
raise DumpError(f"line {n}: not an assertion row object")
|
|
80
|
+
missing = set(_FIELDS) - raw.keys()
|
|
81
|
+
if missing:
|
|
82
|
+
raise DumpError(f"line {n}: missing fields {sorted(missing)}")
|
|
83
|
+
rows.append(Assertion(**{f: raw[f] for f in _FIELDS}))
|
|
84
|
+
|
|
85
|
+
world_ids = {r.world_id for r in rows}
|
|
86
|
+
if len(world_ids) > 1:
|
|
87
|
+
raise DumpError(f"dump spans worlds {sorted(world_ids)}; a buffer holds exactly one")
|
|
88
|
+
if not rows:
|
|
89
|
+
raise DumpError("empty dump; nothing to build")
|
|
90
|
+
builder = _make_builder_role()
|
|
91
|
+
for i, row in enumerate(rows, start=1):
|
|
92
|
+
if row.seq != i:
|
|
93
|
+
raise DumpError(f"seq not contiguous from 1: expected {i}, got {row.seq}")
|
|
94
|
+
if row.id != f"a:{i}":
|
|
95
|
+
raise DumpError(f"row {i}: id {row.id!r} != 'a:{i}'")
|
|
96
|
+
if row.asserted_at != i:
|
|
97
|
+
raise DumpError(f"row {i}: asserted_at {row.asserted_at} != seq {i}")
|
|
98
|
+
if row.status not in STATUSES or row.status == "default":
|
|
99
|
+
raise DumpError(f"row {i}: status {row.status!r} cannot appear in a log")
|
|
100
|
+
if row.value_type not in VALUE_TYPES:
|
|
101
|
+
raise DumpError(f"row {i}: unknown value_type {row.value_type!r}")
|
|
102
|
+
builder.check(row.status)
|
|
103
|
+
|
|
104
|
+
buffer = PatternBuffer(path, world_id=rows[0].world_id)
|
|
105
|
+
try:
|
|
106
|
+
for row in rows:
|
|
107
|
+
buffer._insert(row)
|
|
108
|
+
except Exception:
|
|
109
|
+
buffer.close()
|
|
110
|
+
path.unlink(missing_ok=True)
|
|
111
|
+
raise
|
|
112
|
+
logger.info("built %s: %d assertions, world %s", path, len(rows), rows[0].world_id)
|
|
113
|
+
return buffer
|