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/refer.py ADDED
@@ -0,0 +1,256 @@
1
+ """refer(): reference resolution, the fourth boundary operation
2
+ (whitepaper §9; spec §9.3). Three-tier cascade, cheapest first; tier 1
3
+ is deterministic and makes no model call. Low confidence never guesses.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Callable
13
+
14
+ from patternbuffer.buffer import PatternBuffer
15
+ from patternbuffer.identity import IdentityRegistry
16
+ from patternbuffer.indexes import Indexes
17
+ from patternbuffer.model import ATTR_PREFIX, CANON
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ RESOLVED = "resolved"
22
+ CANDIDATES = "candidates"
23
+ UNDERDETERMINED = "underdetermined"
24
+
25
+ _TIER2_SCHEMA = {
26
+ "type": "object",
27
+ "properties": {
28
+ "entity_id": {"type": ["string", "null"]},
29
+ "confidence": {"type": "number"},
30
+ "signals": {"type": "array", "items": {"type": "string"}},
31
+ },
32
+ # No required fields (HD 081): "no match" is a FIRST-CLASS outcome — a
33
+ # genuinely unresolvable reference (off-script/willy-nilly play) lets the
34
+ # model omit `entity_id` entirely rather than violate a required field and
35
+ # burn a re-ask. The consumer is fully `.get`-defended and treats a
36
+ # missing/null entity_id (or sub-floor confidence) as UNDERDETERMINED
37
+ # ("nothing here" — the host's ask), never an error.
38
+ "required": [],
39
+ }
40
+ _TIER2_FLOOR = 0.75
41
+
42
+ # Leading determiners stripped before reference resolution (HD 003): a
43
+ # possessive or article in front of a referring expression is surface
44
+ # grammar, not identity ("my brass measuring spoon" is the spoon).
45
+ _DETERMINERS = frozenset(
46
+ {"the", "a", "an", "my", "your", "his", "her", "its", "their", "our"}
47
+ )
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class Resolution:
52
+ status: str
53
+ entity_id: str | None = None
54
+ candidates: tuple[str, ...] = ()
55
+ receipt: dict = field(default_factory=dict) # tier, signals, confidence
56
+
57
+
58
+ class Refer:
59
+ def __init__(
60
+ self,
61
+ buffer: PatternBuffer,
62
+ indexes: Indexes,
63
+ registry: IdentityRegistry,
64
+ model: Callable[[str, dict], Any] | None = None,
65
+ ingestor: "Any | None" = None,
66
+ ) -> None:
67
+ self._buffer = buffer
68
+ self._indexes = indexes
69
+ self._registry = registry
70
+ self._model = model
71
+ # Alias accrual (letter 018, mechanic 2) appends through the gate's
72
+ # role; without an ingestor wired, resolution still works but the
73
+ # world does not learn its users' words.
74
+ self._ingestor = ingestor
75
+
76
+ def _accrue_alias(self, description: str, entity_id: str, receipt: dict) -> None:
77
+ """Memoize a tier-2 resolution as an alias assertion carrying the
78
+ resolution receipt — each synonym costs one tier-2 call once, then
79
+ is tier-1a forever. A learned alias never outranks an exact name
80
+ (by_alias hits both; exact-name uniqueness still wins tier 1a, and
81
+ a later collision is ordinary ambiguity -> tier 2)."""
82
+ if self._ingestor is None:
83
+ return
84
+ rows = self._ingestor.ingest_structured([
85
+ {"entity": entity_id, "attribute": "alias",
86
+ "value": description.strip().lower(), "timeless": True,
87
+ "status": "inferred"},
88
+ ])
89
+ self._buffer.append(
90
+ entity=rows[0].id, attribute="source",
91
+ value=f"refer:tier2:{json.dumps(receipt.get('signals', []))[:80]}",
92
+ status="inferred", role=self._ingestor._role,
93
+ )
94
+ logger.info("alias accrued: %r -> %s", description, entity_id)
95
+
96
+ def __call__(
97
+ self,
98
+ description: str,
99
+ scope: str | list[str] | None = None,
100
+ frame: str = CANON,
101
+ constraints: list[tuple[str, str]] | None = None,
102
+ as_of: float | None = None,
103
+ asserted_as_of: int | None = None,
104
+ ) -> Resolution:
105
+ # ---- Tier 1a: exact name/alias hit through the identity registry.
106
+ # Try the raw expression and its determiner-stripped core (HD 003):
107
+ # "my brass measuring spoon" misses the exact name otherwise.
108
+ core = self._strip_determiner(description)
109
+ hits = self._registry.by_alias(description)
110
+ if core != description.strip().lower():
111
+ hits = hits | self._registry.by_alias(core)
112
+ if len(hits) == 1:
113
+ return Resolution(RESOLVED, next(iter(hits)),
114
+ receipt={"tier": 1, "signals": ["alias_exact"]})
115
+
116
+ # ---- Tier 1b: constraint inversion — resolve the container by the
117
+ # contained, the owner by the possession. Flip the lookup before
118
+ # any linguistic judgment.
119
+ if constraints:
120
+ inverted = self._invert(constraints, frame, as_of, asserted_as_of)
121
+ if inverted is not None:
122
+ return inverted
123
+
124
+ # ---- Tier 1c: unique-kind-in-scope ("the drawer" where the scene
125
+ # holds exactly one drawer).
126
+ kind = self._kind_word(description)
127
+ if kind is not None:
128
+ members = self._scope_members(scope, frame, as_of, asserted_as_of)
129
+ of_kind = [
130
+ e for e in members
131
+ if self._entity_kind(e, frame, as_of, asserted_as_of) == kind
132
+ ]
133
+ if len(of_kind) == 1:
134
+ return Resolution(RESOLVED, of_kind[0],
135
+ receipt={"tier": 1, "signals": ["unique_kind_in_scope"]})
136
+ if len(of_kind) > 1:
137
+ return self._resolve_tier2(description, tuple(sorted(of_kind)))
138
+ if len(hits) > 1:
139
+ return self._resolve_tier2(description, tuple(sorted(hits)))
140
+
141
+ # ---- Zero-candidate escalation (letter 018, mechanic 1): a synonym
142
+ # yields zero tier-1 matches; with a scope provided, that is exactly
143
+ # tier 2's judgment — vocabulary miss must not masquerade as absence.
144
+ # Scope-bounded ONLY: never world-scope for this path.
145
+ if scope is not None:
146
+ members = self._scope_members(scope, frame, as_of, asserted_as_of)
147
+ if members:
148
+ return self._resolve_tier2(description, tuple(sorted(members)))
149
+
150
+ # Nothing deterministic and no candidates: underdetermined.
151
+ return Resolution(UNDERDETERMINED, receipt={"tier": 3, "signals": []})
152
+
153
+ # ------------------------------------------------------------- tier 1
154
+
155
+ def _invert(self, constraints, frame, as_of, asserted_as_of) -> Resolution | None:
156
+ for relation, anchor in constraints:
157
+ if relation == "contains":
158
+ chain = self._indexes.locate(anchor, frame, as_of, asserted_as_of)
159
+ if chain:
160
+ return Resolution(
161
+ RESOLVED, chain[0],
162
+ receipt={"tier": 1, "signals": [f"constraint_inversion:contains({anchor})"]},
163
+ )
164
+ elif relation == "owned_by" or relation == "held_by":
165
+ folded = self._indexes.fold_key(anchor, "in", frame, as_of, asserted_as_of)
166
+ if folded.winner is not None and folded.winner.value_type == "entity":
167
+ return Resolution(
168
+ RESOLVED, self._indexes.resolve_entity(folded.winner.value),
169
+ receipt={"tier": 1, "signals": [f"constraint_inversion:{relation}({anchor})"]},
170
+ )
171
+ return None
172
+
173
+ @staticmethod
174
+ def _strip_determiner(description: str) -> str:
175
+ """Lowercase and drop a single leading article/possessive token
176
+ (HD 003). A no-determiner phrase passes through unchanged."""
177
+ text = description.strip().lower()
178
+ head, _, rest = text.partition(" ")
179
+ if head in _DETERMINERS and rest:
180
+ return rest.strip()
181
+ return text
182
+
183
+ def _kind_word(self, description: str) -> str | None:
184
+ """A bare kind reference ("the drawer", "my spoon") → the kind
185
+ token. Accepts an optional leading article or possessive (HD 003);
186
+ broadening only adds matches, so "the X" still parses identically."""
187
+ det = "|".join(sorted(_DETERMINERS))
188
+ m = re.match(rf"^(?:{det})\s+([a-z][a-z_ ]*)$", description.strip().lower())
189
+ return m.group(1).replace(" ", "_") if m else None
190
+
191
+ def _scope_members(self, scope, frame, as_of, asserted_as_of) -> list[str]:
192
+ if scope is None:
193
+ # World scope: every entity with a kind row.
194
+ return sorted(
195
+ {
196
+ self._indexes.resolve_entity(r.entity)
197
+ for r in self._buffer.visible(
198
+ attribute="kind", frame=frame,
199
+ valid_as_of=as_of, asserted_as_of=asserted_as_of,
200
+ )
201
+ if not r.entity.startswith("a:")
202
+ and not r.entity.startswith(ATTR_PREFIX)
203
+ }
204
+ )
205
+ roots = [scope] if isinstance(scope, str) else list(scope)
206
+ members: set[str] = set()
207
+ frontier = [self._indexes.resolve_entity(r) for r in roots]
208
+ seen: set[str] = set()
209
+ while frontier:
210
+ e = frontier.pop(0)
211
+ if e in seen:
212
+ continue
213
+ seen.add(e)
214
+ members.add(e)
215
+ frontier.extend(self._indexes.contents(e, frame, as_of, asserted_as_of))
216
+ return sorted(members)
217
+
218
+ def _entity_kind(self, entity, frame, as_of, asserted_as_of) -> str | None:
219
+ result = self._indexes.fold_key(entity, "kind", frame, as_of, asserted_as_of)
220
+ return result.winner.value if result.winner else None
221
+
222
+ # ------------------------------------------------------------- tier 2
223
+
224
+ def _resolve_tier2(self, description: str, candidates: tuple[str, ...]) -> Resolution:
225
+ """Strict-contract cheap call judging candidates; returns a
226
+ resolution receipt. Below the floor -> tier 3: never guess."""
227
+ if self._model is None:
228
+ return Resolution(CANDIDATES, candidates=candidates,
229
+ receipt={"tier": 2, "signals": ["no_model"]})
230
+ prompt = (
231
+ f"A reference must resolve to exactly one entity or none.\n"
232
+ f"Reference: {description!r}\nCandidates: {list(candidates)}\n"
233
+ "Judge by name match, recency, possession, and discourse context. "
234
+ "If genuinely ambiguous, return entity_id=null with low confidence. "
235
+ "If NO candidate matches at all (the referenced thing does not exist "
236
+ "in this world), return entity_id=null — a genuine no-match is a "
237
+ "valid, expected outcome, never an error."
238
+ )
239
+ try:
240
+ out = self._model(prompt, _TIER2_SCHEMA)
241
+ except Exception:
242
+ logger.exception("refer tier-2 model call failed")
243
+ return Resolution(CANDIDATES, candidates=candidates,
244
+ receipt={"tier": 2, "signals": ["model_error"]})
245
+ receipt = {
246
+ "tier": 2,
247
+ "candidates": list(candidates),
248
+ "signals": out.get("signals", []),
249
+ "confidence": out.get("confidence", 0.0),
250
+ }
251
+ if out.get("entity_id") in candidates and out.get("confidence", 0.0) >= _TIER2_FLOOR:
252
+ resolution = Resolution(RESOLVED, out["entity_id"], receipt=receipt)
253
+ self._accrue_alias(description, out["entity_id"], receipt)
254
+ return resolution
255
+ # Tier 3 contract: the ask is the host's to deliver.
256
+ return Resolution(UNDERDETERMINED, candidates=candidates, receipt=receipt)
patternbuffer/roles.py ADDED
@@ -0,0 +1,77 @@
1
+ """The role-authority matrix, enforced in code (whitepaper §12, spec §6).
2
+
3
+ Every append to the buffer requires a ``WriterRole`` capability whose
4
+ ``allowed_statuses`` admits the row's provenance status. Capabilities
5
+ cannot be minted by application code: the constructor demands a
6
+ module-private token reachable only through the factory functions below,
7
+ which are called from ``World`` wiring (and, for the builder role, from
8
+ ``dump.build`` alone).
9
+
10
+ The classifier, projector, and renderer hold no capability at all —
11
+ there is nothing they can call that writes the log.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from patternbuffer.model import STATUSES
17
+
18
+ _TOKEN = object()
19
+
20
+
21
+ class RoleViolation(PermissionError):
22
+ """An append was attempted outside the role-authority matrix."""
23
+
24
+
25
+ class WriterRole:
26
+ """A write capability: a name plus the statuses it may append."""
27
+
28
+ __slots__ = ("name", "allowed_statuses")
29
+
30
+ def __init__(self, name: str, allowed_statuses: frozenset[str], *, _token: object = None) -> None:
31
+ if _token is not _TOKEN:
32
+ raise RoleViolation(
33
+ "WriterRole cannot be constructed by application code; "
34
+ "capabilities are minted only in World wiring"
35
+ )
36
+ bad = allowed_statuses - STATUSES
37
+ if bad:
38
+ raise ValueError(f"unknown statuses: {sorted(bad)}")
39
+ self.name = name
40
+ self.allowed_statuses = allowed_statuses
41
+
42
+ def check(self, status: str) -> None:
43
+ if status not in self.allowed_statuses:
44
+ raise RoleViolation(
45
+ f"role {self.name!r} may not append status {status!r} "
46
+ f"(allowed: {sorted(self.allowed_statuses)})"
47
+ )
48
+
49
+ def __repr__(self) -> str: # pragma: no cover
50
+ return f"WriterRole({self.name!r})"
51
+
52
+
53
+ def _make_engine_roles() -> dict[str, WriterRole]:
54
+ """Mint the engine's writer roles. Called only from World wiring.
55
+
56
+ Note what is absent: no role may append ``default`` — it exists only
57
+ in materialization payloads, never in the log (whitepaper §7).
58
+ """
59
+ return {
60
+ "ingestor": WriterRole(
61
+ "ingestor", frozenset({"stated", "observed", "inferred", "assumed"}), _token=_TOKEN
62
+ ),
63
+ "resolver": WriterRole("resolver", frozenset({"generated"}), _token=_TOKEN),
64
+ "truth_maintenance": WriterRole(
65
+ "truth_maintenance", frozenset({"retracted", "inferred"}), _token=_TOKEN
66
+ ),
67
+ }
68
+
69
+
70
+ def _make_builder_role() -> WriterRole:
71
+ """Mint the dump-replay capability. Called only from ``dump.build``.
72
+
73
+ The builder may replay any logged status (the dump already carries
74
+ the log as it was) except ``default``, which never appears in a log
75
+ and therefore never in a dump.
76
+ """
77
+ return WriterRole("builder", STATUSES - {"default"}, _token=_TOKEN)
@@ -0,0 +1,170 @@
1
+ """Projection-time salience: a rebuildable ranking sidecar.
2
+
3
+ Salience is derived from the assertion log and classifier sidecar. It is
4
+ cached for retrieval speed, but never authored into the log.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+
12
+ from patternbuffer.buffer import PatternBuffer
13
+ from patternbuffer.classify import STATE, Classifier
14
+ from patternbuffer.indexes import Indexes
15
+ from patternbuffer.model import ATTR_PREFIX, CANON, Assertion
16
+
17
+ SALIENCE_PARAMS = {
18
+ "weights": {
19
+ "recency": 0.40,
20
+ "reference_frequency": 0.25,
21
+ "reinforcement": 0.20,
22
+ "delta_from_baseline": 0.15,
23
+ },
24
+ "ref_scale": 8.0,
25
+ "reinf_scale": 8.0,
26
+ }
27
+
28
+ _SCHEMA = """
29
+ CREATE TABLE IF NOT EXISTS sidecar_salience (
30
+ entity TEXT NOT NULL,
31
+ frame TEXT NOT NULL,
32
+ as_of_key TEXT NOT NULL,
33
+ score REAL NOT NULL,
34
+ head INTEGER NOT NULL,
35
+ classifier_version INTEGER NOT NULL,
36
+ PRIMARY KEY (entity, frame, as_of_key)
37
+ );
38
+ """
39
+
40
+
41
+ class SalienceIndex:
42
+ """Derived, disposable salience scores over the current log."""
43
+
44
+ def __init__(
45
+ self,
46
+ buffer: PatternBuffer,
47
+ classifier: Classifier,
48
+ indexes: Indexes,
49
+ ) -> None:
50
+ self._buffer = buffer
51
+ self._classifier = classifier
52
+ self._indexes = indexes
53
+ self._ensure_schema()
54
+
55
+ def _ensure_schema(self) -> None:
56
+ self._buffer.raw_connection().executescript(_SCHEMA)
57
+ self._buffer.raw_connection().commit()
58
+
59
+ @staticmethod
60
+ def _as_of_key(as_of: float | None) -> str:
61
+ return json.dumps(as_of, sort_keys=True)
62
+
63
+ def rebuild(self) -> None:
64
+ """Drop cached scores; they will be recomputed on demand."""
65
+ self._ensure_schema()
66
+ self._buffer.raw_connection().execute("DELETE FROM sidecar_salience")
67
+ self._buffer.raw_connection().commit()
68
+
69
+ def salience(
70
+ self,
71
+ entity: str,
72
+ frame: str = CANON,
73
+ as_of: float | None = None,
74
+ ) -> float:
75
+ entity = self._indexes.resolve_entity(entity)
76
+ head = self._buffer.head()
77
+ version = self._classifier.version
78
+ as_of_key = self._as_of_key(as_of)
79
+ self._ensure_schema()
80
+ row = self._buffer.raw_connection().execute(
81
+ "SELECT score, head, classifier_version FROM sidecar_salience"
82
+ " WHERE entity = ? AND frame = ? AND as_of_key = ?",
83
+ (entity, frame, as_of_key),
84
+ ).fetchone()
85
+ if row is not None and int(row[1]) == head and int(row[2]) == version:
86
+ return float(row[0])
87
+ score = self._compute(entity, frame, as_of)
88
+ self._buffer.raw_connection().execute(
89
+ "INSERT OR REPLACE INTO sidecar_salience"
90
+ " (entity, frame, as_of_key, score, head, classifier_version)"
91
+ " VALUES (?, ?, ?, ?, ?, ?)",
92
+ (entity, frame, as_of_key, score, head, version),
93
+ )
94
+ self._buffer.raw_connection().commit()
95
+ return score
96
+
97
+ def _visible_entity_rows(
98
+ self, entity: str, frame: str, as_of: float | None
99
+ ) -> list[Assertion]:
100
+ closure = sorted(self._indexes._closure_of(entity))
101
+ if not closure:
102
+ return []
103
+ return [
104
+ row
105
+ for row in self._buffer.visible(
106
+ entity_in=closure, frame=frame, valid_as_of=as_of
107
+ )
108
+ if not row.entity.startswith(ATTR_PREFIX)
109
+ ]
110
+
111
+ def _compute(self, entity: str, frame: str, as_of: float | None) -> float:
112
+ rows = self._visible_entity_rows(entity, frame, as_of)
113
+ head = self._buffer.head()
114
+ max_asserted = max((r.asserted_at for r in rows), default=0)
115
+ recency = (max_asserted / head) if head else 0.0
116
+
117
+ incoming = self._indexes.incoming_refs(entity, frame, as_of)
118
+ ref_scale = SALIENCE_PARAMS["ref_scale"]
119
+ reference_frequency = min(1.0, math.log1p(len(incoming)) / math.log1p(ref_scale))
120
+
121
+ valid_times = {r.valid_from for r in rows}
122
+ reinf_scale = SALIENCE_PARAMS["reinf_scale"]
123
+ reinforcement = min(1.0, math.log1p(len(valid_times)) / math.log1p(reinf_scale))
124
+
125
+ delta = self._delta_from_baseline(entity, rows, frame, as_of)
126
+
127
+ weights = SALIENCE_PARAMS["weights"]
128
+ return (
129
+ weights["recency"] * recency
130
+ + weights["reference_frequency"] * reference_frequency
131
+ + weights["reinforcement"] * reinforcement
132
+ + weights["delta_from_baseline"] * delta
133
+ )
134
+
135
+ def _delta_from_baseline(
136
+ self,
137
+ entity: str,
138
+ rows: list[Assertion],
139
+ frame: str,
140
+ as_of: float | None,
141
+ ) -> float:
142
+ by_fold_attr: dict[str, list[Assertion]] = {}
143
+ for row in rows:
144
+ if (
145
+ row.entity.startswith("a:")
146
+ or row.value_type == "unresolved"
147
+ or self._classifier.durability(row.id) != STATE
148
+ ):
149
+ continue
150
+ by_fold_attr.setdefault(self._indexes.fold_attribute(row.attribute), []).append(row)
151
+ if not by_fold_attr:
152
+ return 0.0
153
+ current = self._indexes.current_state(entity, frame, as_of)
154
+
155
+ def recency_key(row: Assertion) -> tuple[float, int]:
156
+ return (
157
+ row.valid_from if row.valid_from is not None else float("-inf"),
158
+ row.asserted_at,
159
+ )
160
+
161
+ for attr, attr_rows in by_fold_attr.items():
162
+ result = current.get(attr)
163
+ if result is None or result.winner is None:
164
+ continue
165
+ if self._classifier.durability(result.winner.id) != STATE:
166
+ continue
167
+ baseline = min(attr_rows, key=recency_key)
168
+ if result.winner.id != baseline.id and recency_key(result.winner) > recency_key(baseline):
169
+ return 1.0
170
+ return 0.0
@@ -0,0 +1,192 @@
1
+ """Attribute semantics as data (ATTRIBUTE-SEMANTICS-V1).
2
+
3
+ Attribute-level *behavior* — arity (functional vs set-valued), relation
4
+ family (containment tree / lateral graph / none), fold policy (last-write
5
+ vs move-supersession), and structural-ness — lifted out of the engine's
6
+ code constants into per-world, rebuildable, *declared* semantics that every
7
+ consumer reads through this one service. Domain vocabulary carries its own
8
+ fold behavior without engine edits.
9
+
10
+ Declarations are ordinary assertions about an ``attr:<name>`` entity (the
11
+ canonicalization-as-receipts pattern generalized); the sidecar here is a
12
+ rebuildable view over them, never truth (P2). Unspecified attributes return
13
+ the built-in defaults, so a world with zero declarations behaves exactly as
14
+ the pre-RFC engine did.
15
+
16
+ Two invariants this module owns (whitepaper guardrail; spec §5):
17
+ - **Inviolable core.** The engine's constitutional predicates (the
18
+ containment family, ``kind``/``connects_to``/``adjacent_to``/``caused_by``,
19
+ the identity predicates) can never be redeclared. A host adds domain
20
+ semantics; it never redefines a primitive.
21
+ - **Declared semantics never reject a fact.** They govern how a row *folds*,
22
+ never whether it is *admitted* (the Kernos rejection-test). The only
23
+ rejection is authority-on-vocabulary (a forbidden ``attr:*`` write), never
24
+ schema-on-world-facts.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from dataclasses import dataclass
31
+
32
+ from patternbuffer.buffer import PatternBuffer
33
+ from patternbuffer.model import (
34
+ ATTR_PREFIX,
35
+ CONTAINMENT_FAMILY,
36
+ INVIOLABLE_CORE,
37
+ SEMANTICS_PREDICATES,
38
+ SET_VALUED_ATTRIBUTES,
39
+ STRUCTURAL_PREDICATES,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # Axis values (fixed enums).
45
+ FUNCTIONAL, SET_VALUED = "functional", "set_valued"
46
+ CONTAINMENT, LATERAL, NONE = "containment", "lateral", "none"
47
+ LAST_WRITE, MOVE, ACCRUE = "last_write", "move", "accrue"
48
+
49
+ ARITIES = frozenset({FUNCTIONAL, SET_VALUED})
50
+ RELATION_FAMILIES = frozenset({CONTAINMENT, LATERAL, NONE})
51
+ FOLD_POLICIES = frozenset({LAST_WRITE, MOVE, ACCRUE})
52
+
53
+ # Built-in lateral graph attributes; everything set-valued that is not a graph
54
+ # edge is plain set data.
55
+ _LATERAL = frozenset({"connects_to", "adjacent_to"})
56
+
57
+ # Names with non-default built-in semantics. Family helpers below derive their
58
+ # seed sets through builtin_default() so behavioral constants are read in one
59
+ # place.
60
+ _BUILTIN_DEFAULT_ATTRIBUTES = frozenset(
61
+ {
62
+ "in",
63
+ "within",
64
+ "held_by",
65
+ "worn_by",
66
+ "carried_by",
67
+ "name",
68
+ "alias",
69
+ "connects_to",
70
+ "adjacent_to",
71
+ "maybe_same_as",
72
+ "same_as",
73
+ "kind",
74
+ "caused_by",
75
+ }
76
+ )
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class Semantics:
81
+ """The four orthogonal attribute-level axes."""
82
+
83
+ arity: str
84
+ relation_family: str
85
+ fold_policy: str
86
+ structural: bool
87
+
88
+
89
+ def builtin_default(attribute: str) -> Semantics:
90
+ """Today's behavior, as data — the default for any undeclared attribute."""
91
+ arity = SET_VALUED if attribute in SET_VALUED_ATTRIBUTES else FUNCTIONAL
92
+ if attribute in CONTAINMENT_FAMILY:
93
+ family, policy = CONTAINMENT, MOVE
94
+ elif attribute in _LATERAL:
95
+ family, policy = LATERAL, LAST_WRITE
96
+ else:
97
+ family, policy = NONE, LAST_WRITE
98
+ return Semantics(arity, family, policy, attribute in STRUCTURAL_PREDICATES)
99
+
100
+
101
+ class AttributeSemantics:
102
+ """Per-world, rebuildable attribute-semantics view. Holds no truth (P2):
103
+ the declarations live in the log as ``attr:*`` rows; this is their fold."""
104
+
105
+ def __init__(self, buffer: PatternBuffer) -> None:
106
+ self._buffer = buffer
107
+ self._declared: dict[str, dict[str, object]] = {}
108
+ self._rebuilt_at = -1
109
+ self.rebuild()
110
+
111
+ def rebuild(self) -> None:
112
+ """Scan visible ``attr:*`` declarations into the sidecar (parity with
113
+ the canonicalization map and the durability sidecar)."""
114
+ declared: dict[str, dict[str, object]] = {}
115
+ for row in self._buffer.visible(entity_prefix=ATTR_PREFIX):
116
+ if row.attribute in SEMANTICS_PREDICATES:
117
+ name = row.entity[len(ATTR_PREFIX):]
118
+ declared.setdefault(name, {})[row.attribute] = row.value
119
+ self._declared = declared
120
+ self._rebuilt_at = self._buffer.head()
121
+
122
+ def _refresh(self) -> None:
123
+ if self._buffer.head() != self._rebuilt_at:
124
+ self.rebuild()
125
+
126
+ # ----------------------------------------------------------------- read
127
+
128
+ def semantics(self, attribute: str) -> Semantics:
129
+ """Declared semantics over the built-in default for one attribute."""
130
+ self._refresh()
131
+ base = builtin_default(attribute)
132
+ d = self._declared.get(attribute)
133
+ if not d:
134
+ return base
135
+ return Semantics(
136
+ arity=str(d.get("arity", base.arity)),
137
+ relation_family=str(d.get("relation_family", base.relation_family)),
138
+ fold_policy=str(d.get("fold_policy", base.fold_policy)),
139
+ structural=bool(d.get("structural", base.structural)),
140
+ )
141
+
142
+ def is_set_valued(self, attribute: str) -> bool:
143
+ return self.semantics(attribute).arity == SET_VALUED
144
+
145
+ def is_containment(self, attribute: str) -> bool:
146
+ return self.semantics(attribute).relation_family == CONTAINMENT
147
+
148
+ def is_lateral(self, attribute: str) -> bool:
149
+ return self.semantics(attribute).relation_family == LATERAL
150
+
151
+ def is_accrue(self, attribute: str) -> bool:
152
+ return self.semantics(attribute).fold_policy == ACCRUE
153
+
154
+ def is_structural(self, attribute: str) -> bool:
155
+ return self.semantics(attribute).structural
156
+
157
+ def is_declared(self, attribute: str) -> bool:
158
+ """Whether an explicit ``attr:*`` declaration exists (vs. defaulting)."""
159
+ self._refresh()
160
+ return attribute in self._declared
161
+
162
+ def containment_family(self) -> set[str]:
163
+ """All attributes that fold as the single containment key — the
164
+ built-in family plus any declared ``relation_family=containment``."""
165
+ self._refresh()
166
+ out = {
167
+ name for name in _BUILTIN_DEFAULT_ATTRIBUTES
168
+ if builtin_default(name).relation_family == CONTAINMENT
169
+ }
170
+ for name in self._declared:
171
+ if self.semantics(name).relation_family == CONTAINMENT:
172
+ out.add(name)
173
+ return out
174
+
175
+ def lateral_family(self) -> set[str]:
176
+ """All attributes that form the lateral graph (``path``)."""
177
+ self._refresh()
178
+ out = {
179
+ name for name in _BUILTIN_DEFAULT_ATTRIBUTES
180
+ if builtin_default(name).relation_family == LATERAL
181
+ }
182
+ for name in self._declared:
183
+ if self.semantics(name).relation_family == LATERAL:
184
+ out.add(name)
185
+ return out
186
+
187
+ # ------------------------------------------------------------- authority
188
+
189
+ @staticmethod
190
+ def is_core(attribute: str) -> bool:
191
+ """A constitutional predicate that can never be redeclared (spec §5)."""
192
+ return attribute in INVIOLABLE_CORE