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/mcp.py ADDED
@@ -0,0 +1,303 @@
1
+ """The MCP wrapper (MCP-WRAPPER-V1): the frozen porcelain served over MCP.
2
+
3
+ Any MCP client drives a world with zero Python — the engine-independence claim
4
+ (whitepaper §17.2) demonstrated beyond the Python host. Pure adapter: an
5
+ explicit registry of the porcelain's deterministic verbs, a dispatch layer, and
6
+ one mechanical wire envelope over `world.porcelain.*` — no engine surface, no
7
+ reflection, no model callable (the server's World has none, and the exposed
8
+ parameter domains are the genuinely model-free subset).
9
+
10
+ The engine core never imports this module; the `mcp` SDK is imported lazily and
11
+ ships in the `pbuffer[mcp]` extra (distribution name; import stays patternbuffer). Trust boundary: a connected client is a
12
+ FULLY TRUSTED world principal (it can name any frame; annotations are hints,
13
+ never authorization) — untrusted consumers go through a host-mediated surface.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import dataclasses
20
+ import inspect
21
+ import json
22
+ import logging
23
+ import os
24
+ import threading
25
+ import types as _pytypes
26
+ import typing
27
+ from typing import Any
28
+
29
+ from patternbuffer import World
30
+ from patternbuffer.codec import encode_out
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # ------------------------------------------------------------- the registry
35
+ # Explicit and exhaustive (Cx 521 #2): the 37 deterministic tools. A porcelain
36
+ # addition CANNOT appear here by reflection — it must be classified V1/V1.1 and
37
+ # added deliberately (the registry-completeness test enforces this).
38
+
39
+ V1_READS = (
40
+ "snapshot", "state", "state_union", "where", "aggregate", "confidence",
41
+ "locate", "contents", "composition", "features", "path", "route",
42
+ "neighborhood", "salience", "frame_diff", "who_knows", "events",
43
+ "entities", "facts", "fidelity_audit", "axis_heads", "proposals",
44
+ "correlations", "correlation_conflicts", "typing_conflicts",
45
+ )
46
+ V1_MUTATIONS = (
47
+ "ingest_structured", "retract", "reconcile", "adjudicate_deferred",
48
+ "confirm", "merge", "reject", "correlate", "retype",
49
+ "begin_build", "seal_build", "abort_build",
50
+ )
51
+ V1_TOOLS = V1_READS + V1_MUTATIONS
52
+
53
+ # V1.1 (MCP sampling), deliberately absent: extract, ingest, ask, resolve.
54
+
55
+ # Conservatively destructive (Cx 521 #4): retracts, collapses identity, or
56
+ # supersedes the effective view. The complement of this set within mutations —
57
+ # including begin_build/abort_build — is explicitly destructiveHint=False.
58
+ DESTRUCTIVE = frozenset({
59
+ "retract", "merge", "confirm", "retype", "adjudicate_deferred",
60
+ "reconcile", "seal_build",
61
+ })
62
+ # Retry-safe mutations only (reads are all idempotent).
63
+ IDEMPOTENT_MUTATIONS = frozenset({"abort_build", "reject", "correlate"})
64
+
65
+ # Declared schema overrides (Cx 521 #2): exactly the parameters whose frozen
66
+ # signatures don't carry a sufficient annotation, plus the model-free domain
67
+ # narrowings (Cx 521 #1).
68
+ _STR_OR_LIST = {"anyOf": [{"type": "string"},
69
+ {"type": "array", "items": {"type": "string"}}]}
70
+ SCHEMA_OVERRIDES: dict[tuple[str, str], dict] = {
71
+ ("snapshot", "scope"): _STR_OR_LIST,
72
+ ("frame_diff", "scope"): _STR_OR_LIST,
73
+ ("where", "value"): {"anyOf": [
74
+ {"type": "number"},
75
+ {"type": "object",
76
+ "properties": {"$decimal": {"type": "string"}},
77
+ "required": ["$decimal"], "additionalProperties": False},
78
+ ]},
79
+ # The genuinely model-free subset: inline/batch REACH the model path even
80
+ # on a no-model World (the _no_model sentinel fires; the classifier falls
81
+ # back) — so the tool exposes only rules|defer, defaulting to rules.
82
+ ("ingest_structured", "classify"): {"type": "string",
83
+ "enum": ["rules", "defer"],
84
+ "default": "rules"},
85
+ }
86
+ # Parameters omitted from the tool surface entirely (the server always uses
87
+ # the safe value): seal_build(model=) would reach the batch model path.
88
+ OMITTED_PARAMS: frozenset[tuple[str, str]] = frozenset({("seal_build", "model")})
89
+
90
+ _OUTPUT_SCHEMA = {
91
+ "type": "object",
92
+ "properties": {"result": {}}, # the envelope: {"result": <JSON value>}
93
+ "required": ["result"],
94
+ }
95
+
96
+
97
+ def _annotation_to_schema(ann: Any) -> dict:
98
+ """A frozen-signature annotation → JSON schema (explicit map, no guessing)."""
99
+ if ann is inspect.Parameter.empty or ann is Any:
100
+ return {}
101
+ origin = typing.get_origin(ann)
102
+ if origin in (typing.Union, _pytypes.UnionType):
103
+ args = typing.get_args(ann)
104
+ parts = [_annotation_to_schema(a) for a in args]
105
+ if type(None) in args and len(args) == 2:
106
+ other = next(a for a in args if a is not type(None))
107
+ inner = _annotation_to_schema(other)
108
+ if list(inner.keys()) == ["type"]:
109
+ return {"type": [inner["type"], "null"]}
110
+ return {"anyOf": parts}
111
+ if origin is list:
112
+ (item,) = typing.get_args(ann) or (Any,)
113
+ return {"type": "array", "items": _annotation_to_schema(item)}
114
+ return {
115
+ str: {"type": "string"}, bool: {"type": "boolean"},
116
+ int: {"type": "integer"}, float: {"type": "number"},
117
+ dict: {"type": "object"}, type(None): {"type": "null"},
118
+ }.get(ann, {})
119
+
120
+
121
+ def _input_schema(name: str) -> dict:
122
+ from patternbuffer.porcelain import Porcelain
123
+ method = getattr(Porcelain, name)
124
+ sig = inspect.signature(method)
125
+ # Porcelain uses postponed annotations (`from __future__ import
126
+ # annotations`), so signature annotations are STRINGS — resolve them to
127
+ # runtime types first (Cx 533 #1: comparing strings against types silently
128
+ # emitted `{}` for 83 params, letting typed garbage cross SDK validation).
129
+ hints = typing.get_type_hints(method)
130
+ props: dict[str, dict] = {}
131
+ required: list[str] = []
132
+ for p in list(sig.parameters.values())[1:]: # skip self
133
+ if (name, p.name) in OMITTED_PARAMS:
134
+ continue
135
+ schema = SCHEMA_OVERRIDES.get((name, p.name))
136
+ if schema is None:
137
+ ann = hints.get(p.name, inspect.Parameter.empty)
138
+ schema = _annotation_to_schema(ann)
139
+ if not schema and ann not in (inspect.Parameter.empty, Any):
140
+ # an annotated param MUST map to a real schema — a silent {}
141
+ # is exactly the 533 failure mode; fail at registry build.
142
+ raise TypeError(f"{name}.{p.name}: unmapped annotation {ann!r}")
143
+ if p.default is not inspect.Parameter.empty and p.default is not None:
144
+ schema = {**schema, "default": p.default}
145
+ props[p.name] = schema
146
+ if p.default is inspect.Parameter.empty:
147
+ required.append(p.name)
148
+ out = {"type": "object", "properties": props, "additionalProperties": False}
149
+ if required:
150
+ out["required"] = required
151
+ return out
152
+
153
+
154
+ def _annotations(name: str) -> dict:
155
+ """The exact ToolAnnotations table (Cx 521 #4) — explicit complement."""
156
+ read = name in V1_READS
157
+ out = {
158
+ "readOnlyHint": read,
159
+ "idempotentHint": read or name in IDEMPOTENT_MUTATIONS,
160
+ "openWorldHint": False, # a bound local world is a closed domain
161
+ }
162
+ if not read:
163
+ out["destructiveHint"] = name in DESTRUCTIVE
164
+ return out
165
+
166
+
167
+ def build_registry() -> dict[str, dict]:
168
+ """name -> {description, inputSchema, outputSchema, annotations}."""
169
+ from patternbuffer.porcelain import Porcelain
170
+ reg: dict[str, dict] = {}
171
+ for name in V1_TOOLS:
172
+ doc = (inspect.getdoc(getattr(Porcelain, name)) or name).strip()
173
+ reg[name] = {
174
+ "description": doc.split("\n\n")[0],
175
+ "inputSchema": _input_schema(name),
176
+ "outputSchema": _OUTPUT_SCHEMA,
177
+ "annotations": _annotations(name),
178
+ }
179
+ return reg
180
+
181
+
182
+ # ------------------------------------------------------------- dispatch
183
+
184
+ def _normalize(ret: Any) -> Any:
185
+ """Porcelain return → plain JSON value (Receipt/any dataclass → dict)."""
186
+ if hasattr(ret, "to_dict"):
187
+ return ret.to_dict()
188
+ if dataclasses.is_dataclass(ret) and not isinstance(ret, type):
189
+ return dataclasses.asdict(ret)
190
+ return ret
191
+
192
+
193
+ def dispatch(world: World, name: str, arguments: dict[str, Any],
194
+ lock: threading.Lock | None = None) -> dict:
195
+ """Validate, call the porcelain verb, wrap in the wire envelope.
196
+
197
+ Runtime validation (Cx 521 #1) rejects the model-reaching modes BEFORE
198
+ dispatch — enforcement, not description. Mutations serialize under `lock`
199
+ (build-session state is server/world state, not per-request state)."""
200
+ if name not in V1_TOOLS:
201
+ raise ValueError(f"unknown tool {name!r}")
202
+ verb = getattr(world.porcelain, name)
203
+ allowed = set(inspect.signature(verb).parameters)
204
+ args = dict(arguments or {})
205
+ unknown = set(args) - allowed
206
+ if unknown:
207
+ raise ValueError(f"{name}: unknown argument(s) {sorted(unknown)}")
208
+ if name == "ingest_structured":
209
+ classify = args.setdefault("classify", "rules")
210
+ if classify not in ("rules", "defer"):
211
+ raise ValueError(
212
+ "ingest_structured over MCP accepts classify='rules'|'defer' "
213
+ "only — 'inline'/'batch' reach the model path, which this "
214
+ "no-model server does not carry (MCP-WRAPPER-V1)")
215
+ if name == "seal_build":
216
+ if "model" in args:
217
+ raise ValueError(
218
+ "seal_build over MCP does not accept 'model' — the batch "
219
+ "model path is not carried by this no-model server; the seal "
220
+ "always runs rules-only (MCP-WRAPPER-V1)")
221
+ args["model"] = False
222
+ if name in V1_MUTATIONS and lock is not None:
223
+ with lock:
224
+ ret = verb(**args)
225
+ else:
226
+ ret = verb(**args)
227
+ return {"result": encode_out(_normalize(ret))}
228
+
229
+
230
+ # ------------------------------------------------------------- the server
231
+
232
+ def build_server(world: World):
233
+ """Bind the registry + dispatch to an MCP low-level Server (lazy SDK)."""
234
+ from mcp.server.lowlevel import Server # the [mcp] extra
235
+ from mcp import types
236
+
237
+ registry = build_registry()
238
+ lock = threading.Lock()
239
+ server = Server("patternbuffer")
240
+
241
+ @server.list_tools()
242
+ async def _list_tools() -> list:
243
+ return [
244
+ types.Tool(
245
+ name=name,
246
+ description=spec["description"],
247
+ inputSchema=spec["inputSchema"],
248
+ outputSchema=spec["outputSchema"],
249
+ annotations=types.ToolAnnotations(**spec["annotations"]),
250
+ )
251
+ for name, spec in registry.items()
252
+ ]
253
+
254
+ @server.call_tool() # SDK validates input, wraps dict as structuredContent
255
+ async def _call_tool(name: str, arguments: dict) -> dict:
256
+ # Returning the envelope dict: the SDK places it in structuredContent
257
+ # AND serializes the same object into one TextContent block — the
258
+ # spec's wire convention, natively.
259
+ return dispatch(world, name, arguments, lock=lock)
260
+
261
+ return server
262
+
263
+
264
+ def main(argv: list[str] | None = None) -> int:
265
+ """`patternbuffer-mcp --world PATH --world-id ID` (env fallbacks)."""
266
+ parser = argparse.ArgumentParser(
267
+ prog="patternbuffer-mcp",
268
+ description="Serve one pattern-buffer world over MCP (stdio). "
269
+ "One server, one world (the 1:1 invariant).")
270
+ parser.add_argument("--world", default=os.environ.get("PATTERNBUFFER_WORLD"),
271
+ help="path to the .world file (env PATTERNBUFFER_WORLD)")
272
+ parser.add_argument("--world-id",
273
+ default=os.environ.get("PATTERNBUFFER_WORLD_ID"),
274
+ help="the world's id (env PATTERNBUFFER_WORLD_ID); "
275
+ "required — a mismatch against an existing file "
276
+ "fails loudly (WorldMismatch)")
277
+ args = parser.parse_args(argv)
278
+ if not args.world or not args.world_id:
279
+ parser.error("--world and --world-id are both required "
280
+ "(or PATTERNBUFFER_WORLD / PATTERNBUFFER_WORLD_ID)")
281
+ try:
282
+ from mcp.server.stdio import stdio_server
283
+ except ImportError:
284
+ parser.error("the MCP SDK is not installed — pip install 'pbuffer[mcp]'")
285
+
286
+ world = World(args.world, world_id=args.world_id) # no model callable
287
+ server = build_server(world)
288
+
289
+ import anyio
290
+
291
+ async def _run() -> None:
292
+ async with stdio_server() as (read, write):
293
+ await server.run(read, write, server.create_initialization_options())
294
+
295
+ try:
296
+ anyio.run(_run)
297
+ finally:
298
+ world.close() # aborts any open build session; buffer closed
299
+ return 0
300
+
301
+
302
+ if __name__ == "__main__":
303
+ raise SystemExit(main())
patternbuffer/model.py ADDED
@@ -0,0 +1,100 @@
1
+ """The assertion row shape and the engine's fixed vocabularies.
2
+
3
+ The model is triples-on-triples: assertions are addressable entities,
4
+ so everything *about* an assertion is itself an assertion. Hot fields
5
+ are denormalized into columns (whitepaper §3.2); nothing is ever
6
+ special-cased.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ # Provenance vocabulary (whitepaper §7). `default` appears only in
15
+ # materialization payloads; it is included here because the renderer-
16
+ # facing payload reuses the vocabulary, but no role may append it.
17
+ STATUSES = frozenset(
18
+ {"stated", "observed", "inferred", "assumed", "generated", "default", "retracted"}
19
+ )
20
+
21
+ # The containment family folds as ONE logical key (spec §7, letter 002
22
+ # Q1): a new family edge supersedes the prior one as a single operation.
23
+ CONTAINMENT_FAMILY = frozenset({"in", "within", "held_by", "worn_by", "carried_by"})
24
+
25
+ # The compositional axis (PLACE-FEATURE-ABSTRACTION-V1): structural part-of-the-
26
+ # whole, distinct from movable containment. A burrow is `part_of` a hillside;
27
+ # an actor in the burrow is NOT thereby located in the hillside. Read by
28
+ # composition()/features(), never by locate()/contents()/route().
29
+ COMPOSITION_FAMILY = frozenset({"part_of"})
30
+
31
+ # Fixed structural predicates (spec §4.3). Domain vocabulary emerges
32
+ # freely through the canonicalization gate; these never do.
33
+ STRUCTURAL_PREDICATES = (
34
+ frozenset({"kind", "connects_to", "adjacent_to", "caused_by"})
35
+ | CONTAINMENT_FAMILY | COMPOSITION_FAMILY
36
+ )
37
+
38
+ # Attribute-semantics declarations are assertions about attr:<name> entities.
39
+ # These low-level names live here so the buffer guard can enforce authority
40
+ # without importing the semantics service.
41
+ ATTR_PREFIX = "attr:"
42
+ SEMANTICS_PREDICATES = frozenset(
43
+ {"arity", "relation_family", "fold_policy", "structural"}
44
+ )
45
+ INVIOLABLE_CORE = STRUCTURAL_PREDICATES | {"same_as", "maybe_same_as", "distinct_from", "aka"}
46
+
47
+ # The engine's own meta-attributes (subjects of these rows are assertion
48
+ # ids or carry engine semantics).
49
+ META_ATTRIBUTES = frozenset(
50
+ {
51
+ "superseded_by",
52
+ "retracts",
53
+ "source",
54
+ "same_as",
55
+ "maybe_same_as",
56
+ "distinct_from",
57
+ "aka",
58
+ "canonicalized_from",
59
+ "resolved_by",
60
+ "justified_by",
61
+ "world_defining",
62
+ "correction_proposal",
63
+ "source_valid_from", # INGEST-LATENCY-V2: demoted cursor-authoritative coord
64
+ }
65
+ ) | SEMANTICS_PREDICATES
66
+
67
+ # Set-valued attributes: multiple coexisting values are data, never a
68
+ # contradiction — a conflict requires a functional key (run-4 finding:
69
+ # 62 of 77 flags were names/aliases/edges misread as disputes).
70
+ SET_VALUED_ATTRIBUTES = frozenset({"name", "alias", "connects_to", "adjacent_to",
71
+ "maybe_same_as", "same_as", "distinct_from", "aka"})
72
+
73
+ VALUE_TYPES = frozenset({"entity", "literal", "unresolved", "delta"})
74
+
75
+ CANON = "canon"
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class Assertion:
80
+ """One fact. Append-only; never edited (whitepaper §3.2).
81
+
82
+ ``valid_from``/``valid_to`` are world time (None = timeless, legal
83
+ only for CONSTITUTIVE/DISPOSITIONAL rows — enforced at the gate, not
84
+ here). ``asserted_at`` is transaction time: the log sequence number,
85
+ permanently (spec §4.2).
86
+ """
87
+
88
+ seq: int
89
+ id: str
90
+ world_id: str
91
+ entity: str
92
+ attribute: str
93
+ value_type: str
94
+ value: Any
95
+ valid_from: float | None
96
+ valid_to: float | None
97
+ frame: str
98
+ status: str
99
+ confidence: float | None
100
+ asserted_at: int