diff --git a/src/shard_wiki/engine/__init__.py b/src/shard_wiki/engine/__init__.py index 353e7a5..6ea84e8 100644 --- a/src/shard_wiki/engine/__init__.py +++ b/src/shard_wiki/engine/__init__.py @@ -5,7 +5,22 @@ is *one shard*: it is consumed by the orchestrator only via its `EngineShardAdap imports the derived tier (`union`/`projection`). """ +from shard_wiki.engine.extension import ( + ActiveExtensions, + Extension, + ExtensionError, + ExtensionRuntime, + Hook, +) from shard_wiki.engine.kernel import EngineKernel from shard_wiki.engine.links import extract_wikilinks -__all__ = ["EngineKernel", "extract_wikilinks"] +__all__ = [ + "EngineKernel", + "extract_wikilinks", + "Hook", + "Extension", + "ExtensionError", + "ExtensionRuntime", + "ActiveExtensions", +] diff --git a/src/shard_wiki/engine/extension.py b/src/shard_wiki/engine/extension.py new file mode 100644 index 0000000..ed719b8 --- /dev/null +++ b/src/shard_wiki/engine/extension.py @@ -0,0 +1,161 @@ +"""Typed-extension runtime — the engine framework (WikiEngineCoreArchitecture §4, E-3/E-9). + +Everything beyond the kernel's c2-minimum is an :class:`Extension`: it declares a typed +contract (id, provided capabilities, declared types, bound hooks, dependencies, conflicts) and +the runtime **composes** an activation set deterministically, **rejecting impossible profiles** +(unmet deps / conflicts / type collisions) — the §6.5 capability-as-data discipline applied to +extensions. Extension structure is **verified at registration** (mirrors §6.6 conformance): +bad ids or non-callable hook handlers are refused, so the framework acts on verified data. + +Hooks are dispatched in a declared, deterministic order (dependency-topological, ties by id): +*transform* hooks chain a payload through handlers; *collect* hooks gather contributions. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from enum import Enum +from typing import Any, ClassVar + +__all__ = ["Hook", "Extension", "ExtensionError", "ExtensionRuntime", "ActiveExtensions"] + + +class ExtensionError(ValueError): + """Raised when an extension is malformed or an activation set is impossible (§6.5).""" + + +class Hook(Enum): + # transform hooks: each handler takes (payload, ctx) and returns the next payload + ON_WRITE = "on_write" # transform a draft before persist + ON_READ = "on_read" # transform a page on read + ON_RESOLVE = "on_resolve" # transform a name resolution + ON_RENDER = "on_render" # produce a derived representation + # collect hooks: each handler takes (payload, ctx) and returns a contribution + ON_LINK = "on_link" # contribute link/transclusion edges + ON_QUERY = "on_query" # answer a query + ON_PROFILE = "on_profile" # contribute capability-profile positions (E-5) + + +_TRANSFORM = frozenset({Hook.ON_WRITE, Hook.ON_READ, Hook.ON_RESOLVE, Hook.ON_RENDER}) +_COLLECT = frozenset({Hook.ON_LINK, Hook.ON_QUERY, Hook.ON_PROFILE}) + + +class Extension: + """Base class for a typed extension. Subclasses set the class vars and override + :meth:`hooks` to bind handlers (signature ``handler(payload, ctx) -> result``).""" + + id: ClassVar[str] = "" + provides: ClassVar[tuple[str, ...]] = () + declares_types: ClassVar[tuple[str, ...]] = () + depends_on: ClassVar[tuple[str, ...]] = () + conflicts_with: ClassVar[tuple[str, ...]] = () + + def hooks(self) -> Mapping[Hook, Callable[[Any, Any], Any]]: + return {} + + +class ActiveExtensions: + """A composed, ordered activation set with deterministic hook dispatch.""" + + def __init__(self, ordered: list[Extension]) -> None: + self._ordered = ordered + self.ids: tuple[str, ...] = tuple(e.id for e in ordered) + self._tables: dict[Hook, list[tuple[str, Callable[[Any, Any], Any]]]] = {} + for ext in ordered: + for hook, fn in ext.hooks().items(): + self._tables.setdefault(hook, []).append((ext.id, fn)) + + def handlers(self, hook: Hook) -> tuple[str, ...]: + """The extension ids bound to ``hook``, in dispatch order (for introspection).""" + return tuple(eid for eid, _ in self._tables.get(hook, ())) + + def dispatch_transform(self, hook: Hook, payload: Any, ctx: Any = None) -> Any: + if hook not in _TRANSFORM: + raise ExtensionError(f"{hook} is not a transform hook") + for _eid, fn in self._tables.get(hook, ()): + payload = fn(payload, ctx) + return payload + + def dispatch_collect(self, hook: Hook, payload: Any = None, ctx: Any = None) -> list[Any]: + if hook not in _COLLECT: + raise ExtensionError(f"{hook} is not a collect hook") + return [fn(payload, ctx) for _eid, fn in self._tables.get(hook, ())] + + +class ExtensionRuntime: + def __init__(self) -> None: + self._registered: dict[str, Extension] = {} + + def register(self, ext: Extension) -> Extension: + """Register an extension after structural verification (mirrors §6.6).""" + if not ext.id or not ext.id.startswith("ext."): + raise ExtensionError(f"extension id must be 'ext.', got {ext.id!r}") + if ext.id in self._registered: + raise ExtensionError(f"duplicate extension id: {ext.id}") + bound = ext.hooks() + for hook, fn in bound.items(): + if not isinstance(hook, Hook): + raise ExtensionError(f"{ext.id}: hook key {hook!r} is not a Hook") + if not callable(fn): + raise ExtensionError(f"{ext.id}: handler for {hook} is not callable") + self._registered[ext.id] = ext + return ext + + def activate(self, ids: Iterable[str]) -> ActiveExtensions: + """Compose an activation set: dependency closure → conflict/type checks → deterministic + order. Raises :class:`ExtensionError` on an impossible profile.""" + requested = set(ids) + unknown = requested - self._registered.keys() + if unknown: + raise ExtensionError(f"unknown extensions: {sorted(unknown)}") + + # dependency closure + active: set[str] = set() + frontier = list(requested) + while frontier: + eid = frontier.pop() + if eid in active: + continue + ext = self._registered.get(eid) + if ext is None: + raise ExtensionError(f"unmet dependency: {eid}") + active.add(eid) + frontier.extend(d for d in ext.depends_on if d not in active) + + exts = [self._registered[e] for e in active] + + # conflicts + for ext in exts: + clash = active & set(ext.conflicts_with) + if clash: + raise ExtensionError(f"{ext.id} conflicts with active {sorted(clash)}") + + # type collisions (two active extensions claiming the same type id) + owner: dict[str, str] = {} + for ext in exts: + for t in ext.declares_types: + if t in owner: + raise ExtensionError( + f"type collision on {t!r}: {owner[t]} and {ext.id}" + ) + owner[t] = ext.id + + return ActiveExtensions(self._topo_order(exts)) + + def _topo_order(self, exts: list[Extension]) -> list[Extension]: + """Dependencies before dependents; ties broken by id (deterministic).""" + by_id = {e.id: e for e in exts} + ordered: list[Extension] = [] + placed: set[str] = set() + + def visit(ext: Extension) -> None: + if ext.id in placed: + return + for dep in sorted(d for d in ext.depends_on if d in by_id): + visit(by_id[dep]) + placed.add(ext.id) + ordered.append(ext) + + for ext in sorted(exts, key=lambda e: e.id): + visit(ext) + return ordered diff --git a/tests/test_engine_extension.py b/tests/test_engine_extension.py new file mode 100644 index 0000000..59bfc5c --- /dev/null +++ b/tests/test_engine_extension.py @@ -0,0 +1,109 @@ +"""Tests for the typed-extension runtime (SHARD-WP-0014 T2).""" + +import pytest + +from shard_wiki.engine import Extension, ExtensionError, ExtensionRuntime, Hook + + +class Upper(Extension): + id = "ext.upper" + declares_types = ("upper",) + + def hooks(self): + return {Hook.ON_WRITE: lambda body, ctx: body.upper()} + + +class Bang(Extension): + id = "ext.bang" + depends_on = ("ext.upper",) + + def hooks(self): + return {Hook.ON_WRITE: lambda body, ctx: body + "!"} + + +class Profiler(Extension): + id = "ext.profiler" + + def hooks(self): + return {Hook.ON_PROFILE: lambda payload, ctx: {"structure": "typed"}} + + +def _runtime(*exts): + rt = ExtensionRuntime() + for e in exts: + rt.register(e) + return rt + + +def test_register_rejects_bad_id_and_noncallable_hook(): + rt = ExtensionRuntime() + class BadId(Extension): + id = "upper" # missing 'ext.' prefix + with pytest.raises(ExtensionError, match="ext."): + rt.register(BadId()) + + class Liar(Extension): + id = "ext.liar" + def hooks(self): + return {Hook.ON_WRITE: "not-callable"} # type: ignore[dict-item] + with pytest.raises(ExtensionError, match="not callable"): + rt.register(Liar()) + + +def test_transform_hook_chains_in_dependency_order(): + rt = _runtime(Upper(), Bang()) + active = rt.activate({"ext.bang"}) # pulls ext.upper via depends_on + assert set(active.ids) == {"ext.upper", "ext.bang"} + # upper (dependency) runs before bang (dependent): "hi" -> "HI" -> "HI!" + assert active.handlers(Hook.ON_WRITE) == ("ext.upper", "ext.bang") + assert active.dispatch_transform(Hook.ON_WRITE, "hi") == "HI!" + + +def test_dependency_closure_is_automatic(): + rt = _runtime(Upper(), Bang()) + assert set(rt.activate({"ext.bang"}).ids) == {"ext.upper", "ext.bang"} + + +def test_unknown_extension_rejected(): + with pytest.raises(ExtensionError, match="unknown"): + ExtensionRuntime().activate({"ext.ghost"}) + + +def test_conflict_rejected(): + class A(Extension): + id = "ext.a" + conflicts_with = ("ext.b",) + class B(Extension): + id = "ext.b" + with pytest.raises(ExtensionError, match="conflicts"): + _runtime(A(), B()).activate({"ext.a", "ext.b"}) + + +def test_type_collision_rejected(): + class S1(Extension): + id = "ext.s1" + declares_types = ("record",) + class S2(Extension): + id = "ext.s2" + declares_types = ("record",) + with pytest.raises(ExtensionError, match="type collision"): + _runtime(S1(), S2()).activate({"ext.s1", "ext.s2"}) + + +def test_collect_hook_gathers_contributions(): + active = _runtime(Profiler()).activate({"ext.profiler"}) + assert active.dispatch_collect(Hook.ON_PROFILE) == [{"structure": "typed"}] + + +def test_wrong_dispatch_kind_errors(): + active = _runtime(Profiler()).activate({"ext.profiler"}) + with pytest.raises(ExtensionError, match="not a transform hook"): + active.dispatch_transform(Hook.ON_PROFILE, "x") + + +def test_unmet_dependency_rejected(): + # Bang depends on ext.upper, but only Bang is registered. + rt = ExtensionRuntime() + rt.register(Bang()) + with pytest.raises(ExtensionError, match="unmet dependency|unknown"): + rt.activate({"ext.bang"}) diff --git a/workplans/SHARD-WP-0014-engine-implementation.md b/workplans/SHARD-WP-0014-engine-implementation.md index f36cb28..ca825c7 100644 --- a/workplans/SHARD-WP-0014-engine-implementation.md +++ b/workplans/SHARD-WP-0014-engine-implementation.md @@ -63,7 +63,7 @@ helper). No extensions yet. Tests: page CRUD-as-history; kernel-only shard works ```task id: SHARD-WP-0014-T2 -status: todo +status: done priority: high state_hub_task_id: "8ae8e58a-f081-432b-b2c5-b6435fbf3843" ```