From 2100e956aa2abe681e25bed2350b4bdd3ac8bbfa Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 15 Jun 2026 23:57:31 +0200 Subject: [PATCH] feat(engine): page-store kernel skeleton (WP-0014 T1) engine/ package: EngineKernel (in-process page store with per-page version history; create/edit-as-version, recoverable delete-tombstone, keys, current_rev) + wikilink extraction + in-shard link resolution / red-link detection (EC-1..EC-4). Reuses model/provenance; git-IS-store backing slots in later. 6 tests green, pyflakes clean, full suite green. Marks T1 done. Co-Authored-By: Claude Opus 4.8 --- src/shard_wiki/engine/__init__.py | 11 +++ src/shard_wiki/engine/kernel.py | 87 +++++++++++++++++++ src/shard_wiki/engine/links.py | 25 ++++++ tests/test_engine_kernel.py | 59 +++++++++++++ .../SHARD-WP-0014-engine-implementation.md | 9 +- 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/shard_wiki/engine/__init__.py create mode 100644 src/shard_wiki/engine/kernel.py create mode 100644 src/shard_wiki/engine/links.py create mode 100644 tests/test_engine_kernel.py diff --git a/src/shard_wiki/engine/__init__.py b/src/shard_wiki/engine/__init__.py new file mode 100644 index 0000000..353e7a5 --- /dev/null +++ b/src/shard_wiki/engine/__init__.py @@ -0,0 +1,11 @@ +"""engine/ — shard-wiki's native, headless wiki engine (a canonical-mode shard backend). + +A small page-store kernel + a typed-extension runtime (WikiEngineCoreArchitecture). The engine +is *one shard*: it is consumed by the orchestrator only via its `EngineShardAdapter`; it never +imports the derived tier (`union`/`projection`). +""" + +from shard_wiki.engine.kernel import EngineKernel +from shard_wiki.engine.links import extract_wikilinks + +__all__ = ["EngineKernel", "extract_wikilinks"] diff --git a/src/shard_wiki/engine/kernel.py b/src/shard_wiki/engine/kernel.py new file mode 100644 index 0000000..742c607 --- /dev/null +++ b/src/shard_wiki/engine/kernel.py @@ -0,0 +1,87 @@ +"""Engine kernel — the small page-store core (WikiEngineCoreArchitecture §3, EC-1…EC-4). + +The irreducible engine: author/read/edit pages (edit = a new version; delete = a recoverable +tombstone — history is the floor, I-10), enumerate keys, and resolve `[[wikilinks]]` (red-link = +an unresolved target). No feature beyond this c2-minimum lives in the kernel; everything else is +a typed extension (E-3). + +Storage is intentionally simple here (in-memory version history); the git-IS-store backing +(SHARD-WP-0009/0012) slots in behind the same API later. The kernel reuses the page model and +provenance leaf; it does not redefine them. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime, timezone + +from shard_wiki.engine.links import extract_wikilinks +from shard_wiki.model import Identity, Page, Placement +from shard_wiki.provenance import Liveness, ProvenanceEnvelope, Staleness + +__all__ = ["EngineKernel"] + + +class EngineKernel: + """An in-process page store with per-page version history for one engine shard.""" + + def __init__(self, shard_id: str) -> None: + self.shard_id = shard_id + self._versions: dict[str, list[Page]] = {} + self._deleted: set[str] = set() + + # --- write path (create/edit are one operation; both append a version) --- + def write(self, key: str, body: str) -> Page: + versions = self._versions.setdefault(key, []) + rev = str(len(versions) + 1) + page = Page( + identity=Identity(self.shard_id, key), + body=body, + envelope=ProvenanceEnvelope( + source_shard=self.shard_id, + liveness=Liveness.STATIC, + staleness=Staleness.FRESH, + source_rev=rev, + observed_at=datetime.now(tz=timezone.utc), + ), + placements=(Placement(self.shard_id, key),), + ) + versions.append(page) + self._deleted.discard(key) + return page + + # --- read path --- + def exists(self, key: str) -> bool: + return key in self._versions and key not in self._deleted + + def read(self, key: str) -> Page: + """Latest version of a live page. Raises ``KeyError`` if absent or deleted.""" + if not self.exists(key): + raise KeyError(key) + return self._versions[key][-1] + + def keys(self) -> Iterable[str]: + return (k for k in sorted(self._versions) if k not in self._deleted) + + def current_rev(self, key: str) -> str | None: + return self._versions[key][-1].envelope.source_rev if self.exists(key) else None + + # --- history & recoverability (I-10): versions are retained across delete --- + def history(self, key: str) -> tuple[Page, ...]: + """All versions ever written for ``key`` (oldest→newest), even after delete.""" + return tuple(self._versions.get(key, ())) + + def delete(self, key: str) -> None: + """Tombstone a page (history retained; restore by writing again).""" + if key not in self._versions: + raise KeyError(key) + self._deleted.add(key) + + # --- links (EC-4): resolution + red-link detection within this shard --- + def links(self, key: str) -> list[str]: + """Wikilink targets in a page's current body.""" + return extract_wikilinks(self.read(key).body) + + def resolve_link(self, target: str) -> Identity | None: + """Resolve a wikilink target to a live page identity, or ``None`` (a **red-link**).""" + return self.read(target).identity if self.exists(target) else None diff --git a/src/shard_wiki/engine/links.py b/src/shard_wiki/engine/links.py new file mode 100644 index 0000000..ff27238 --- /dev/null +++ b/src/shard_wiki/engine/links.py @@ -0,0 +1,25 @@ +"""Wikilink extraction — the kernel's link primitive (WikiEngineCoreArchitecture EC-4). + +`[[Target]]` and `[[Target|label]]`. CamelCase auto-linking is intentionally NOT here (it is an +opt-in concern per FederationRequirements ADR-06); the kernel only knows explicit wikilinks. +Link *resolution* (and red-link detection) is the kernel's job (it knows which keys exist); +*rendering* is a consumer concern (headless engine, no UI). +""" + +from __future__ import annotations + +import re + +__all__ = ["extract_wikilinks"] + +_WIKILINK = re.compile(r"\[\[([^\]|]+?)(?:\|[^\]]*)?\]\]") + + +def extract_wikilinks(body: str) -> list[str]: + """Return the ordered, de-duplicated wikilink targets in ``body`` (label part dropped).""" + seen: dict[str, None] = {} + for m in _WIKILINK.finditer(body): + target = m.group(1).strip() + if target: + seen.setdefault(target, None) + return list(seen) diff --git a/tests/test_engine_kernel.py b/tests/test_engine_kernel.py new file mode 100644 index 0000000..dc49fba --- /dev/null +++ b/tests/test_engine_kernel.py @@ -0,0 +1,59 @@ +"""Tests for the engine kernel (SHARD-WP-0014 T1).""" + +import pytest + +from shard_wiki.engine import EngineKernel, extract_wikilinks +from shard_wiki.model import Identity + + +def test_write_creates_then_edits_as_history(): + k = EngineKernel("eng") + p1 = k.write("Home", "first") + assert p1.identity == Identity("eng", "Home") + assert p1.envelope.source_rev == "1" + p2 = k.write("Home", "second") + assert p2.envelope.source_rev == "2" + assert k.read("Home").body == "second" # latest + assert [v.body for v in k.history("Home")] == ["first", "second"] # recoverable history + + +def test_read_missing_raises(): + k = EngineKernel("eng") + with pytest.raises(KeyError): + k.read("Nope") + + +def test_delete_is_recoverable(): + k = EngineKernel("eng") + k.write("Doc", "v1") + k.delete("Doc") + assert not k.exists("Doc") + with pytest.raises(KeyError): + k.read("Doc") + assert [v.body for v in k.history("Doc")] == ["v1"] # history retained + k.write("Doc", "v2") # restore by writing + assert k.exists("Doc") and k.read("Doc").body == "v2" + + +def test_keys_and_current_rev(): + k = EngineKernel("eng") + k.write("A", "a") + k.write("B", "b") + k.write("A", "a2") + assert set(k.keys()) == {"A", "B"} + assert k.current_rev("A") == "2" + assert k.current_rev("Missing") is None + + +def test_links_and_red_link_resolution(): + k = EngineKernel("eng") + k.write("Home", "see [[Target]] and [[Other|labelled]] and [[Target]] again") + k.write("Target", "exists") + assert k.links("Home") == ["Target", "Other"] # ordered, de-duped, label dropped + assert k.resolve_link("Target") == Identity("eng", "Target") + assert k.resolve_link("Other") is None # red-link (not yet created) + + +def test_extract_wikilinks_helper(): + assert extract_wikilinks("none here") == [] + assert extract_wikilinks("[[A]] [[B|x]] [[A]]") == ["A", "B"] diff --git a/workplans/SHARD-WP-0014-engine-implementation.md b/workplans/SHARD-WP-0014-engine-implementation.md index f7df2c4..f36cb28 100644 --- a/workplans/SHARD-WP-0014-engine-implementation.md +++ b/workplans/SHARD-WP-0014-engine-implementation.md @@ -12,6 +12,7 @@ updated: "2026-06-15" depends_on: - SHARD-WP-0007 - SHARD-WP-0013 +state_hub_workstream_id: "bfce1644-d93d-44c7-af2c-6b0cb50cedd4" --- # SHARD-WP-0014 — Wiki-engine implementation @@ -47,8 +48,9 @@ computational extensions; the full feature-control control plane. Build the fram ```task id: SHARD-WP-0014-T1 -status: todo +status: done priority: high +state_hub_task_id: "e81ba881-7e92-4581-99ff-b12ad2bcabb3" ``` `src/shard_wiki/engine/kernel.py`: the minimal kernel — a page store + lifecycle over existing @@ -63,6 +65,7 @@ helper). No extensions yet. Tests: page CRUD-as-history; kernel-only shard works id: SHARD-WP-0014-T2 status: todo priority: high +state_hub_task_id: "8ae8e58a-f081-432b-b2c5-b6435fbf3843" ``` `src/shard_wiki/engine/extension.py`: the `Extension` contract (id, provides, types, hooks, @@ -79,6 +82,7 @@ conformance catches a lying extension. id: SHARD-WP-0014-T3 status: todo priority: high +state_hub_task_id: "c4fe9df4-e6a8-4b7d-891b-59ceec6aebac" ``` `src/shard_wiki/engine/activation.py` (ADR-0001): resolve a shard's **activation profile** @@ -94,6 +98,7 @@ subset; absent-provider falls back to defaults; context scoping works. id: SHARD-WP-0014-T4 status: todo priority: high +state_hub_task_id: "15fc8db7-cd80-4675-b387-81aa9bc7d308" ``` `src/shard_wiki/engine/profile.py`: fold the active extensions' `on_profile` contributions into a @@ -107,6 +112,7 @@ profile; the derived profile is valid and conformance-passes. id: SHARD-WP-0014-T5 status: todo priority: high +state_hub_task_id: "2fbf498c-efe9-400a-8a13-7f1b521b3534" ``` `src/shard_wiki/engine/adapter.py`: `EngineShardAdapter` implements `adapters.ShardAdapter`, @@ -120,6 +126,7 @@ integration: engine shard passes `assert_conformant`; attach → resolve → edi id: SHARD-WP-0014-T6 status: todo priority: medium +state_hub_task_id: "b88d1640-9afa-4957-aec3-a7264b09494c" ``` Implement one real extension end-to-end — **`ext.views` (BackLinks)** or **`ext.struct`