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,436 @@
1
+ """The projector: materialize() (whitepaper §13; spec §9.1).
2
+
3
+ The projector writes nothing durable. Out-of-frame rows are absent from
4
+ the payload — filtering happens at source, in the row selection, never
5
+ as a marking pass. The CONSTITUTIVE spine is budget-exempt. Closed-world
6
+ render commitments are payload-only `default` marks that can never
7
+ harden into canon.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from dataclasses import dataclass, field
14
+ from decimal import Decimal
15
+ from typing import Callable
16
+
17
+ from patternbuffer.buffer import PatternBuffer
18
+ from patternbuffer.classify import CONSTITUTIVE, DISPOSITIONAL, EVENT, STATE, Classifier
19
+ from patternbuffer.indexes import Indexes
20
+ from patternbuffer.model import ATTR_PREFIX, CANON, Assertion
21
+ from patternbuffer.semantics import AttributeSemantics
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ LENSES = frozenset(
26
+ {"establishing_set", "current_state", "what_happened", "character_sheet", "situation"}
27
+ )
28
+
29
+ # Kind defaults: render-coherence fills, never fact claims (whitepaper
30
+ # §3.1). Deliberately tiny in the spike.
31
+ KIND_DEFAULTS: dict[str, dict[str, object]] = {
32
+ "room": {"lighting": "unremarkable", "walls": "plain"},
33
+ "person": {"build": "average"},
34
+ }
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class DefaultFill:
39
+ entity: str
40
+ attribute: str
41
+ value: object
42
+ status: str = "default" # payload-only; no role can append this
43
+
44
+
45
+ @dataclass
46
+ class Materialization:
47
+ scope: tuple[str, ...]
48
+ frame: str
49
+ as_of: float | None
50
+ asserted_as_of: int | None
51
+ lens: str
52
+ assertions: list[Assertion] = field(default_factory=list)
53
+ defaults: list[DefaultFill] = field(default_factory=list)
54
+ conflicted_keys: list[tuple[str, str]] = field(default_factory=list)
55
+ unresolved: list[tuple[str, str]] = field(default_factory=list) # the visible frontier
56
+ quantities: list[tuple[str, str, int | float | Decimal]] = field(default_factory=list)
57
+ truncated: int = 0 # rows dropped to budget (never CONSTITUTIVE)
58
+
59
+
60
+ class Projector:
61
+ def __init__(
62
+ self,
63
+ buffer: PatternBuffer,
64
+ classifier: Classifier,
65
+ indexes: Indexes,
66
+ semantics: AttributeSemantics | None = None,
67
+ salience: Callable[[str, str, float | None], float] | None = None,
68
+ ) -> None:
69
+ self._buffer = buffer
70
+ self._classifier = classifier
71
+ self._indexes = indexes
72
+ self._semantics = semantics or AttributeSemantics(buffer)
73
+ self._salience = salience or (lambda entity, frame, as_of: 0.0)
74
+
75
+ # ------------------------------------------------------------ scoping
76
+
77
+ def _scope_entities(self, scope: str | list[str], frame: str,
78
+ valid_as_of: float | None, asserted_as_of: int | None) -> list[str]:
79
+ """A scope is an entity list, or a container whose subtree it is."""
80
+ roots = [scope] if isinstance(scope, str) else list(scope)
81
+ out: list[str] = []
82
+ seen: set[str] = set()
83
+ frontier = [self._indexes.resolve_entity(r) for r in roots]
84
+ while frontier:
85
+ e = frontier.pop(0)
86
+ if e in seen:
87
+ continue
88
+ seen.add(e)
89
+ out.append(e)
90
+ frontier.extend(
91
+ self._indexes.contents(e, frame, valid_as_of, asserted_as_of)
92
+ )
93
+ return out
94
+
95
+ def _establishing_scope(self, scope: str | list[str], frame: str,
96
+ valid_as_of: float | None, asserted_as_of: int | None) -> list[str]:
97
+ """Subtree membership by establishing containment edges."""
98
+ roots = {self._indexes.resolve_entity(r)
99
+ for r in ([scope] if isinstance(scope, str) else scope)}
100
+ by_entity: dict[str, list[Assertion]] = {}
101
+ for row in self._buffer.visible(
102
+ frame=frame, valid_as_of=valid_as_of, asserted_as_of=asserted_as_of
103
+ ):
104
+ if (
105
+ row.attribute in self._semantics.containment_family()
106
+ and not row.entity.startswith("a:")
107
+ and not row.entity.startswith(ATTR_PREFIX)
108
+ ):
109
+ by_entity.setdefault(self._indexes.resolve_entity(row.entity), []).append(row)
110
+ parent: dict[str, str] = {}
111
+ for entity, rows in by_entity.items():
112
+ qualifying = [
113
+ r for r in rows
114
+ if r.value_type == "entity"
115
+ and (
116
+ self._classifier.durability(r.id) != STATE
117
+ or not self._indexes._is_event_effect(r, asserted_as_of)
118
+ )
119
+ ]
120
+ if qualifying:
121
+ first = min(qualifying, key=lambda r: (r.valid_from or float("-inf"), r.asserted_at))
122
+ parent[entity] = self._indexes.resolve_entity(first.value)
123
+ out, ordering = set(roots), list(roots)
124
+ changed = True
125
+ while changed:
126
+ changed = False
127
+ for entity, p in parent.items():
128
+ if p in out and entity not in out:
129
+ out.add(entity)
130
+ ordering.append(entity)
131
+ changed = True
132
+ return ordering
133
+
134
+ # -------------------------------------------------------------- lenses
135
+
136
+ def materialize(
137
+ self,
138
+ scope: str | list[str],
139
+ as_of: float | None = None,
140
+ frame: str = CANON,
141
+ lens: str = "current_state",
142
+ budget: int | None = None,
143
+ asserted_as_of: int | None = None,
144
+ since: float | None = None,
145
+ correlated: bool = False,
146
+ features: bool = False,
147
+ ) -> Materialization:
148
+ """`since` (letter 029): lower bound for the what_happened lens —
149
+ with `as_of` it gives occurred(window=[since, as_of]) matching.
150
+
151
+ `correlated`/`features` (AWARENESS-READS-V1.1, opt-in): fold each entity
152
+ over its `aka` correlation union (the scene-wide form of `state_union`),
153
+ and/or augment the scope with each place's `part_of`-feature children
154
+ (the place-with-features view). Default off = unchanged projection."""
155
+ if lens not in LENSES:
156
+ raise ValueError(f"unknown lens {lens!r}")
157
+ if correlated and lens == "establishing_set":
158
+ # The establishing view is the world AT CREATION — before any identity
159
+ # reveal — so there is nothing to correlate. The combination is
160
+ # incoherent; guard it rather than thread the flag through the
161
+ # specialized first-state path (elegance over forced orthogonality).
162
+ raise ValueError(
163
+ "correlated=True is not supported with lens='establishing_set' "
164
+ "(the world-at-creation view predates identity reveals)"
165
+ )
166
+ if lens == "establishing_set":
167
+ # The world at rest: the scope walk itself uses establishing
168
+ # containment, or entities the plot moved away vanish from the
169
+ # very lens meant to show where they started.
170
+ entities = self._establishing_scope(scope, frame, as_of, asserted_as_of)
171
+ else:
172
+ entities = self._scope_entities(scope, frame, as_of, asserted_as_of)
173
+ if features:
174
+ # AWARENESS-READS-V1.1 Win 2: inline each scope place's part_of
175
+ # children (one level), pre-projection — a clean entity-list add
176
+ # (composition is a separate axis from the containment scope walk).
177
+ seen = set(entities)
178
+ for place in list(entities):
179
+ for child in self._indexes.features(place, frame, as_of, asserted_as_of):
180
+ if child not in seen:
181
+ seen.add(child)
182
+ entities.append(child)
183
+ m = Materialization(
184
+ scope=tuple(entities), frame=frame, as_of=as_of,
185
+ asserted_as_of=asserted_as_of, lens=lens,
186
+ )
187
+ if lens == "what_happened":
188
+ self._lens_events(m, entities, since)
189
+ elif lens == "character_sheet":
190
+ self._lens_sheet(m, entities, correlated=correlated)
191
+ elif lens == "situation":
192
+ self._lens_situation(m, entities, budget, correlated=correlated)
193
+ else:
194
+ self._lens_state(m, entities, establishing=(lens == "establishing_set"),
195
+ correlated=correlated)
196
+ self._fill_defaults(m)
197
+ if lens != "situation":
198
+ # situation budgets its live-events bucket internally and never
199
+ # truncates the standing-truth floor (SITUATION-LENS-V1 §Overflow).
200
+ self._shape_to_budget(m, budget)
201
+ return m
202
+
203
+ def _lens_state(
204
+ self,
205
+ m: Materialization,
206
+ entities: list[str],
207
+ establishing: bool,
208
+ served: dict[str, str] | None = None,
209
+ correlated: bool = False,
210
+ ) -> None:
211
+ """Standing truth. When `served` is supplied (situation lens), it
212
+ collects the ids of the rows the fold actually serves now, tagged
213
+ ``"concrete"`` (a surviving effect) or ``"open"`` (an unresolved
214
+ winner = an open thread) — the set the live-event walk reaches back
215
+ from (SITUATION-LENS-V1). ``correlated`` folds over the `aka` union."""
216
+ for entity in entities:
217
+ folded = self._indexes.current_state(
218
+ entity, m.frame, m.as_of, m.asserted_as_of, correlated=correlated
219
+ )
220
+ for attr, result in sorted(folded.items()):
221
+ if result.quantity is not None:
222
+ if result.conflicted:
223
+ m.conflicted_keys.append((entity, attr))
224
+ m.quantities.append((entity, attr, result.quantity))
225
+ if served is not None:
226
+ # accrue serves the total via quantities; the ledger
227
+ # rows are its live effects.
228
+ for r in result._ledger_rows:
229
+ served[r.id] = "concrete"
230
+ continue
231
+ rows = result._value_rows or (
232
+ (result.winner,) if result.winner is not None else ()
233
+ )
234
+ if not rows:
235
+ continue
236
+ if result.conflicted:
237
+ m.conflicted_keys.append((entity, attr))
238
+ if served is not None:
239
+ # both sides are standing truth; both are served.
240
+ for cid in result.conflicting:
241
+ served[cid] = "concrete"
242
+ for row in rows:
243
+ if row.value_type == "unresolved":
244
+ m.unresolved.append((entity, attr))
245
+ if served is not None:
246
+ # an unresolved winner with no resolved_by is the
247
+ # open-thread state (fold_key already drops spent
248
+ # thunks): mark it open.
249
+ served[row.id] = "open"
250
+ continue # the frontier is named, never painted (§10)
251
+ durability = self._classifier.durability(row.id)
252
+ if establishing and durability == STATE and not result._value_rows:
253
+ row = self._establishing_state(entity, attr, m) or None
254
+ if row is None:
255
+ continue
256
+ if served is not None:
257
+ served[row.id] = "concrete"
258
+ m.assertions.append(row)
259
+
260
+ def _establishing_state(
261
+ self, entity: str, attr: str, m: Materialization
262
+ ) -> Assertion | None:
263
+ """First STATE by (valid_from, asserted_at) with no caused_by edge
264
+ to an EVENT — the world at rest (spec §9.1); honors world_defining."""
265
+ rows = [
266
+ r
267
+ for r in self._buffer.visible(
268
+ frame=m.frame,
269
+ valid_as_of=m.as_of, asserted_as_of=m.asserted_as_of,
270
+ )
271
+ if self._indexes.resolve_entity(r.entity) == entity
272
+ and not r.entity.startswith("a:")
273
+ and not r.entity.startswith(ATTR_PREFIX)
274
+ and self._indexes.fold_attribute(r.attribute) == attr # already fold-keyed
275
+ and r.value_type != "unresolved"
276
+ and self._classifier.durability(r.id) == STATE
277
+ ]
278
+ qualifying = []
279
+ for r in rows:
280
+ pinned = bool(self._buffer.visible(entity=r.id, attribute="world_defining"))
281
+ if pinned:
282
+ return r
283
+ if not self._indexes._is_event_effect(r, m.asserted_as_of):
284
+ qualifying.append(r)
285
+ if not qualifying:
286
+ return None
287
+ return min(qualifying, key=lambda r: (r.valid_from or float("-inf"), r.asserted_at))
288
+
289
+ def _lens_situation(
290
+ self, m: Materialization, entities: list[str], budget: int | None,
291
+ correlated: bool = False,
292
+ ) -> None:
293
+ """Re-entry retrieval (SITUATION-LENS-V1): the standing-truth floor
294
+ plus only the *live* events anchored to the scope; closed history is
295
+ dropped. Liveness is derived every read from present facts — an event
296
+ is live iff it has an open thread (a) or a still-served effect (b) —
297
+ and nothing is stored (the membrane, A6). ``correlated`` is threaded so
298
+ the boolean stays lens-orthogonal (Cx final)."""
299
+ served: dict[str, str] = {}
300
+ self._lens_state(m, entities, establishing=False, served=served,
301
+ correlated=correlated)
302
+ floor = len(m.assertions)
303
+ if not served:
304
+ return
305
+ # One batched walk: served effect/thread rows -> their causing events.
306
+ # caused_by sits on the EFFECT row (entity = effect-row id, value =
307
+ # event entity); a row in `served` carrying one means its event has a
308
+ # surviving effect (b) or, for an "open" row, an open thread (a).
309
+ # Anchoring is therefore implicit: an event surfaces only because it
310
+ # produced a still-served fact ABOUT a scope entity — never because a
311
+ # mobile participant merely stands in scope.
312
+ live_events: set[str] = set()
313
+ for meta in self._buffer.visible(
314
+ entity_in=sorted(served),
315
+ attribute="caused_by",
316
+ value_type="entity",
317
+ frame=m.frame,
318
+ asserted_as_of=m.asserted_as_of,
319
+ ):
320
+ if isinstance(meta.value, str):
321
+ live_events.add(self._indexes.resolve_entity(meta.value))
322
+ if not live_events:
323
+ return
324
+ # Emit the EVENT-durability rows for the live events, recency-ordered.
325
+ event_rows: list[Assertion] = []
326
+ for row in self._buffer.visible(
327
+ frame=m.frame, valid_as_of=None, asserted_as_of=m.asserted_as_of
328
+ ):
329
+ if self._classifier.durability(row.id) != EVENT:
330
+ continue
331
+ if row.entity.startswith("a:") or row.entity.startswith(ATTR_PREFIX):
332
+ continue
333
+ if self._indexes.resolve_entity(row.entity) not in live_events:
334
+ continue
335
+ if m.as_of is not None and row.valid_from is not None and row.valid_from > m.as_of:
336
+ continue
337
+ event_rows.append(row)
338
+ event_rows.sort(
339
+ key=lambda r: (r.valid_from if r.valid_from is not None else float("-inf"), r.seq)
340
+ )
341
+ if budget is not None:
342
+ # The floor is never truncated; the live-events bucket yields to
343
+ # budget, keeping the most-recent (recency, not popularity).
344
+ keep = max(0, budget - floor)
345
+ if len(event_rows) > keep:
346
+ m.truncated += len(event_rows) - keep
347
+ event_rows = event_rows[len(event_rows) - keep:]
348
+ m.assertions.extend(event_rows)
349
+
350
+ def _lens_events(self, m: Materialization, entities: list[str],
351
+ since: float | None = None) -> None:
352
+ in_scope = set(entities)
353
+ events = []
354
+ for row in self._buffer.visible(
355
+ frame=m.frame, valid_as_of=None, asserted_as_of=m.asserted_as_of
356
+ ):
357
+ if self._classifier.durability(row.id) != EVENT:
358
+ continue
359
+ if row.entity.startswith("a:") or row.entity.startswith(ATTR_PREFIX):
360
+ continue # meta rows ride with their subjects
361
+ subject = self._indexes.resolve_entity(row.entity)
362
+ object_ = (
363
+ self._indexes.resolve_entity(row.value)
364
+ if row.value_type == "entity" and isinstance(row.value, str)
365
+ else None
366
+ )
367
+ if subject in in_scope or object_ in in_scope or not in_scope:
368
+ if m.as_of is not None and row.valid_from is not None and row.valid_from > m.as_of:
369
+ continue
370
+ if since is not None and (row.valid_from is None or row.valid_from < since):
371
+ continue
372
+ events.append(row)
373
+ events.sort(key=lambda r: (r.valid_from if r.valid_from is not None else float("-inf"), r.seq))
374
+ m.assertions.extend(events)
375
+
376
+ def _lens_sheet(self, m: Materialization, entities: list[str],
377
+ correlated: bool = False) -> None:
378
+ """One entity's accumulated card, frame-respecting. ``correlated`` folds
379
+ over the `aka` union (a dual-persona sheet) — lens-orthogonal (Cx final)."""
380
+ for entity in entities[:1]:
381
+ folded = self._indexes.current_state(entity, m.frame, m.as_of,
382
+ m.asserted_as_of, correlated=correlated)
383
+ for attr, result in sorted(folded.items()):
384
+ if result.winner is None:
385
+ continue
386
+ if result.quantity is not None:
387
+ if result.conflicted:
388
+ m.conflicted_keys.append((entity, attr))
389
+ m.quantities.append((entity, attr, result.quantity))
390
+ continue
391
+ if result.conflicted:
392
+ m.conflicted_keys.append((entity, attr))
393
+ for row in result._value_rows or (result.winner,):
394
+ if row.value_type == "unresolved":
395
+ m.unresolved.append((entity, attr))
396
+ continue
397
+ m.assertions.append(row)
398
+
399
+ # --------------------------------------------------- defaults + budget
400
+
401
+ def _fill_defaults(self, m: Materialization) -> None:
402
+ """Closed-world render commitments, marked `default` in the payload
403
+ only (open-world store / closed-world projection, §13)."""
404
+ if m.lens == "what_happened":
405
+ return
406
+ present: dict[str, set[str]] = {}
407
+ kinds: dict[str, str] = {}
408
+ for row in m.assertions:
409
+ e = self._indexes.resolve_entity(row.entity)
410
+ present.setdefault(e, set()).add(row.attribute)
411
+ if row.attribute == "kind" and isinstance(row.value, str):
412
+ kinds[e] = row.value
413
+ for entity, kind in kinds.items():
414
+ for attr, value in KIND_DEFAULTS.get(kind, {}).items():
415
+ if attr not in present.get(entity, set()):
416
+ m.defaults.append(DefaultFill(entity, attr, value))
417
+
418
+ def _shape_to_budget(self, m: Materialization, budget: int | None) -> None:
419
+ """The budget invariant: the CONSTITUTIVE spine is exempt; compress
420
+ the rest by salience, never drop identity and structure (§13)."""
421
+ if budget is None or len(m.assertions) <= budget:
422
+ return
423
+ spine = [
424
+ r for r in m.assertions if self._classifier.durability(r.id) == CONSTITUTIVE
425
+ ]
426
+ rest = [r for r in m.assertions if r not in spine]
427
+ rest.sort( # salience: projection-time ranking, never stored (P1)
428
+ key=lambda r: (
429
+ self._salience(self._indexes.resolve_entity(r.entity), m.frame, m.as_of),
430
+ r.asserted_at,
431
+ ),
432
+ reverse=True,
433
+ )
434
+ keep = max(0, budget - len(spine))
435
+ m.truncated = len(rest) - keep
436
+ m.assertions = spine + rest[:keep]