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/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
|