feat(schema): persist and instantiate scoped multi-role relation schemas
This commit is contained in:
+59
-10
@@ -1,4 +1,4 @@
|
||||
"""Roles and simplices: the parameters of a declaration as a small typed schema.
|
||||
"""Semantic multi-role relation schemas, not geometric simplices.
|
||||
|
||||
A Lean declaration is a bundle of roles: the objects it is about (``(G : Type*)``),
|
||||
the facts it assumes (``(h : a ∣ b)``), and the goal it asserts or the thing it
|
||||
@@ -8,8 +8,8 @@ be read off the prose of a book, so the two can be compared role by role.
|
||||
Design notes (from the archived ``schema-simplex`` prototype):
|
||||
|
||||
* a role may be *constrained* to a closed set of allowed values (``choices``);
|
||||
* a simplex is itself a role, so simplices nest (a lemma used by another is a role
|
||||
of it), which is how a dependency structure appears;
|
||||
* a simplex is itself a role, so explicitly supplied schemas can be composed;
|
||||
nesting does not discover a proof dependency;
|
||||
* validation lives in ``__init__``. The prototype put it in a dataclass
|
||||
``__post_init__`` next to a hand-written ``__init__``, where it never ran.
|
||||
"""
|
||||
@@ -41,7 +41,7 @@ class Role:
|
||||
choices: Sequence[Any] | None = None,
|
||||
attrs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
if not name:
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("a role needs a name")
|
||||
if kind not in KINDS:
|
||||
raise ValueError(f"unknown role kind {kind!r}; expected one of {KINDS}")
|
||||
@@ -49,6 +49,8 @@ class Role:
|
||||
self.kind = kind
|
||||
self.type_text = type_text
|
||||
self.context = context
|
||||
if isinstance(choices, (str, bytes)):
|
||||
raise ValueError("choices must be a value sequence, not a string")
|
||||
self.choices: list[Any] | None = list(choices) if choices is not None else None
|
||||
self.attrs: dict[str, Any] = dict(attrs or {})
|
||||
|
||||
@@ -83,12 +85,21 @@ class Role:
|
||||
class Simplex(Role):
|
||||
"""A reusable bundle of at least two roles, keyed by alias. Itself a role."""
|
||||
|
||||
def __init__(self, name: str, roles: dict[str, Role], *, context: str = "") -> None:
|
||||
super().__init__(name, KIND_SIMPLEX, context=context)
|
||||
def __init__(self, name: str, roles: dict[str, Role], *, context: str = "",
|
||||
attrs: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(name, KIND_SIMPLEX, context=context, attrs=attrs)
|
||||
if len(roles) < MIN_ROLES:
|
||||
raise ValueError(
|
||||
f"simplex {name!r} needs at least {MIN_ROLES} roles, got {len(roles)}"
|
||||
)
|
||||
if any(not isinstance(alias, str) or not alias or not isinstance(role, Role)
|
||||
for alias, role in roles.items()):
|
||||
raise ValueError("simplex slots need nonempty aliases and Role values")
|
||||
for role in roles.values():
|
||||
references = role.attrs.get("references", [])
|
||||
if (not isinstance(references, list)
|
||||
or any(not isinstance(alias, str) or alias not in roles for alias in references)):
|
||||
raise ValueError("role references must name slots in this simplex")
|
||||
self.roles = dict(roles)
|
||||
|
||||
def by_kind(self, kind: str) -> list[Role]:
|
||||
@@ -105,16 +116,16 @@ class Simplex(Role):
|
||||
return out
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"roles": {alias: role.as_dict() for alias, role in self.roles.items()},
|
||||
}
|
||||
return {**super().as_dict(),
|
||||
"roles": {alias: role.as_dict() for alias, role in self.roles.items()}}
|
||||
|
||||
|
||||
class State:
|
||||
"""A role together with a value; unresolved until it has one."""
|
||||
|
||||
def __init__(self, role: Role, value: Any = None) -> None:
|
||||
if value is not None:
|
||||
role.check_value(value)
|
||||
self.role = role
|
||||
self.value = value
|
||||
|
||||
@@ -133,3 +144,41 @@ class Fact(State):
|
||||
|
||||
class Goal(State):
|
||||
"""To be resolved."""
|
||||
|
||||
|
||||
def role_from_dict(data: dict[str, Any]) -> Role:
|
||||
"""Restore JSON role/schema data without executing code or loading pickle."""
|
||||
kind = data.get("kind", KIND_SIMPLEX if "roles" in data else KIND_OBJECT)
|
||||
if kind == KIND_SIMPLEX:
|
||||
return Simplex(data["name"], {alias: role_from_dict(role)
|
||||
for alias, role in data["roles"].items()},
|
||||
context=data.get("context", ""), attrs=data.get("attrs"))
|
||||
return Role(data["name"], kind, type_text=data.get("type", ""),
|
||||
context=data.get("context", ""), choices=data.get("choices"),
|
||||
attrs=data.get("attrs"))
|
||||
|
||||
|
||||
class SimplexInstance:
|
||||
"""Values bind local slot aliases; a resolved goal is supplied, not proved here."""
|
||||
|
||||
def __init__(self, schema: Simplex, facts: dict[str, Any] | None = None,
|
||||
goals: Sequence[str] | None = None) -> None:
|
||||
self.schema = schema
|
||||
facts = facts or {}
|
||||
goal_aliases = list(goals) if goals is not None else [
|
||||
alias for alias, role in schema.roles.items() if role.kind in (KIND_GOAL, KIND_OWN)]
|
||||
if (len(set(goal_aliases)) != len(goal_aliases)
|
||||
or not (set(facts) | set(goal_aliases)) <= set(schema.roles)
|
||||
or set(facts) & set(goal_aliases)):
|
||||
raise ValueError("facts/goals need distinct, declared slot aliases")
|
||||
self.facts = {alias: Fact(schema.roles[alias], value) for alias, value in facts.items()}
|
||||
self.goals = {alias: Goal(schema.roles[alias]) for alias in goal_aliases}
|
||||
|
||||
def resolve(self, alias: str, value: Any) -> None:
|
||||
if alias not in self.goals or value is None:
|
||||
raise ValueError("resolve needs a declared goal and a non-None value")
|
||||
self.goals[alias].role.check_value(value)
|
||||
self.goals[alias].value = value
|
||||
|
||||
def is_completed(self) -> bool:
|
||||
return bool(self.goals) and all(goal.is_resolved() for goal in self.goals.values())
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""JSON/SQLite registry of semantic relation schemas and scoped slot occurrences.
|
||||
|
||||
A role definition may be reused deliberately. Occurrences remain local to a
|
||||
simplex: sharing a role definition never equates the objects bound to its aliases.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Iterable, Mapping
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from .schema import KIND_GOAL, KIND_OWN, KIND_SIMPLEX, Role, Simplex, role_from_dict
|
||||
|
||||
|
||||
def slot_key(parent: str, alias: str) -> str:
|
||||
"""An occurrence identity, distinct even when two slots reuse one definition."""
|
||||
return f"{parent}/slot/{quote(alias, safe='')}"
|
||||
|
||||
|
||||
class SchemaRegistry:
|
||||
"""Immutable versioned records; reusing a key with changed content is rejected."""
|
||||
|
||||
def __init__(self, path: str | Path = ":memory:") -> None:
|
||||
self.connection = sqlite3.connect(str(path))
|
||||
self.connection.execute("PRAGMA foreign_keys = ON")
|
||||
self.connection.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS schema_nodes (
|
||||
key TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('role', 'simplex')),
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS schema_slots (
|
||||
parent TEXT NOT NULL REFERENCES schema_nodes(key) ON DELETE CASCADE,
|
||||
alias TEXT NOT NULL,
|
||||
child TEXT NOT NULL REFERENCES schema_nodes(key) ON DELETE RESTRICT,
|
||||
position INTEGER NOT NULL,
|
||||
PRIMARY KEY(parent, alias),
|
||||
UNIQUE(parent, position)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS schema_slots_child ON schema_slots(child);
|
||||
""")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
def _insert(self, key: str, kind: str, payload: dict) -> None:
|
||||
if not isinstance(key, str) or not key:
|
||||
raise ValueError("a registry key must be nonempty")
|
||||
if (kind == "simplex") != (payload.get("kind") == KIND_SIMPLEX):
|
||||
raise ValueError("nested simplex roles must use a Simplex object")
|
||||
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False, allow_nan=False)
|
||||
existing = self.connection.execute(
|
||||
"SELECT kind, payload FROM schema_nodes WHERE key = ?", (key,)).fetchone()
|
||||
if existing is not None and existing != (kind, encoded):
|
||||
raise ValueError(f"key {key!r} already has different content; use a new version key")
|
||||
self.connection.execute("INSERT OR IGNORE INTO schema_nodes VALUES (?, ?, ?)",
|
||||
(key, kind, encoded))
|
||||
|
||||
def set_role(self, role: Role, key: str | None = None) -> str:
|
||||
if isinstance(role, Simplex):
|
||||
raise ValueError("use set_simplex for nested schemas")
|
||||
key = key or f"role:{role.name}"
|
||||
with self.connection:
|
||||
self._insert(key, "role", role.as_dict())
|
||||
return key
|
||||
|
||||
def set_simplex(self, simplex: Simplex, key: str | None = None,
|
||||
role_keys: Mapping[str, str] | None = None) -> str:
|
||||
"""Default children are scoped. role_keys explicitly opts into definition reuse."""
|
||||
key = key or f"schema:{simplex.name}"
|
||||
role_keys = dict(role_keys or {})
|
||||
if not set(role_keys) <= set(simplex.roles):
|
||||
raise ValueError("reuse map has an unknown alias")
|
||||
with self.connection:
|
||||
self._put_simplex(simplex, key, role_keys, set())
|
||||
return key
|
||||
|
||||
def _put_simplex(self, simplex: Simplex, key: str, reuse: dict[str, str],
|
||||
active: set[int]) -> None:
|
||||
if id(simplex) in active:
|
||||
raise ValueError("cyclic simplex nesting")
|
||||
active = active | {id(simplex)}
|
||||
# Metadata only; children live in a relational slot table, not pickle blobs.
|
||||
payload = Role.as_dict(simplex)
|
||||
existing = self.connection.execute(
|
||||
"SELECT key FROM schema_nodes WHERE key = ?", (key,)).fetchone()
|
||||
if existing:
|
||||
restored = self.get_simplex(key)
|
||||
if restored is None or restored.as_dict() != simplex.as_dict():
|
||||
raise ValueError(f"key {key!r} already has different content; use a new version key")
|
||||
expected = {alias: reuse.get(alias, slot_key(key, alias)) for alias in simplex.roles}
|
||||
if self.slots(key) != expected:
|
||||
raise ValueError("same schema key cannot change its role-definition references")
|
||||
return
|
||||
self._insert(key, "simplex", payload)
|
||||
for position, (alias, child) in enumerate(simplex.roles.items()):
|
||||
child_key = reuse.get(alias, slot_key(key, alias))
|
||||
if alias in reuse:
|
||||
restored = self.get(child_key)
|
||||
if restored is None or restored.as_dict() != child.as_dict():
|
||||
raise ValueError(f"reused role definition does not match slot {alias!r}")
|
||||
elif isinstance(child, Simplex):
|
||||
self._put_simplex(child, child_key, {}, active)
|
||||
else:
|
||||
self._insert(child_key, "role", child.as_dict())
|
||||
self.connection.execute("INSERT INTO schema_slots VALUES (?, ?, ?, ?)",
|
||||
(key, alias, child_key, position))
|
||||
|
||||
def get(self, key: str) -> Role | None:
|
||||
return self._get(key, set())
|
||||
|
||||
def _get(self, key: str, active: set[str]) -> Role | None:
|
||||
if key in active:
|
||||
raise ValueError("cyclic schema references in registry")
|
||||
row = self.connection.execute(
|
||||
"SELECT kind, payload FROM schema_nodes WHERE key = ?", (key,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
kind, encoded = row
|
||||
payload = json.loads(encoded)
|
||||
if kind == "role":
|
||||
return role_from_dict(payload)
|
||||
slots = self.slots(key)
|
||||
children = {alias: self._get(child, active | {key}) for alias, child in slots.items()}
|
||||
return Simplex(payload["name"], children, context=payload.get("context", ""),
|
||||
attrs=payload.get("attrs"))
|
||||
|
||||
def get_role(self, key: str) -> Role | None:
|
||||
value = self.get(key)
|
||||
return value if not isinstance(value, Simplex) else None
|
||||
|
||||
def get_simplex(self, key: str) -> Simplex | None:
|
||||
value = self.get(key)
|
||||
return value if isinstance(value, Simplex) else None
|
||||
|
||||
def slots(self, key: str) -> dict[str, str]:
|
||||
return dict(self.connection.execute(
|
||||
"SELECT alias, child FROM schema_slots WHERE parent = ? ORDER BY position", (key,)))
|
||||
|
||||
def keys(self, pattern: str = "*") -> list[str]:
|
||||
return [key for (key,) in self.connection.execute("SELECT key FROM schema_nodes ORDER BY key")
|
||||
if fnmatch.fnmatchcase(key, pattern)]
|
||||
|
||||
def search_roles(self, query: str, limit: int = 10) -> list[dict]:
|
||||
"""Literal substring candidates; search does not authorize semantic reuse."""
|
||||
if not query.strip() or limit < 1:
|
||||
return []
|
||||
found = []
|
||||
for key, payload in self.connection.execute(
|
||||
"SELECT key, payload FROM schema_nodes WHERE kind = 'role' ORDER BY key"):
|
||||
if query.casefold() in (key + payload).casefold():
|
||||
found.append({"key": key, "role": json.loads(payload)})
|
||||
if len(found) == limit:
|
||||
break
|
||||
return found
|
||||
|
||||
def find_by_roles(self, role_keys: Iterable[str], match_all: bool = False) -> list[str]:
|
||||
"""Find schema keys containing definition keys, including nested descendants."""
|
||||
wanted = set(role_keys)
|
||||
if not wanted:
|
||||
return []
|
||||
found = []
|
||||
for key in self._simplex_keys():
|
||||
descendants = {row[0] for row in self.connection.execute("""
|
||||
WITH RECURSIVE descendants(child) AS (
|
||||
SELECT child FROM schema_slots WHERE parent = ?
|
||||
UNION
|
||||
SELECT slots.child FROM schema_slots slots JOIN descendants d ON slots.parent = d.child
|
||||
) SELECT child FROM descendants
|
||||
""", (key,))}
|
||||
matches = wanted <= descendants if match_all else bool(wanted & descendants)
|
||||
if matches:
|
||||
found.append(key)
|
||||
return found
|
||||
|
||||
def find_solvable(self, known_slots: Iterable[str]) -> list[dict]:
|
||||
"""Structural readiness by scoped occurrences, not proof or value unification."""
|
||||
known = set(known_slots)
|
||||
candidates = []
|
||||
for key in self._simplex_keys():
|
||||
schema = self.get_simplex(key)
|
||||
inputs, outputs = [], []
|
||||
for alias, role in schema.roles.items():
|
||||
target = outputs if role.kind in (KIND_GOAL, KIND_OWN) else inputs
|
||||
target.append(slot_key(key, alias))
|
||||
missing_outputs = [output for output in outputs if output not in known]
|
||||
if missing_outputs and set(inputs) <= known:
|
||||
candidates.append({"schema_key": key, "input_slots": inputs,
|
||||
"target_slots": missing_outputs, "structural_candidate_only": True})
|
||||
return candidates
|
||||
|
||||
def _simplex_keys(self) -> list[str]:
|
||||
return [row[0] for row in self.connection.execute(
|
||||
"SELECT key FROM schema_nodes WHERE kind = 'simplex' ORDER BY key")]
|
||||
|
||||
def info(self) -> dict[str, int]:
|
||||
counts = dict(self.connection.execute("SELECT kind, count(*) FROM schema_nodes GROUP BY kind"))
|
||||
return {"roles": counts.get("role", 0), "simplices": counts.get("simplex", 0),
|
||||
"slots": self.connection.execute("SELECT count(*) FROM schema_slots").fetchone()[0]}
|
||||
Reference in New Issue
Block a user