diff --git a/src/shard_wiki/coordination/__init__.py b/src/shard_wiki/coordination/__init__.py new file mode 100644 index 0000000..d4b8512 --- /dev/null +++ b/src/shard_wiki/coordination/__init__.py @@ -0,0 +1,10 @@ +"""coordination/ — the event-sourced decision log (L3, coordination-canonical state).""" + +from shard_wiki.coordination.decision_log import ( + CoordinationState, + DecisionEvent, + DecisionLog, + EventType, +) + +__all__ = ["DecisionLog", "DecisionEvent", "EventType", "CoordinationState"] diff --git a/src/shard_wiki/coordination/decision_log.py b/src/shard_wiki/coordination/decision_log.py new file mode 100644 index 0000000..b894231 --- /dev/null +++ b/src/shard_wiki/coordination/decision_log.py @@ -0,0 +1,124 @@ +"""The event-sourced coordination decision log — the keystone (CoreArchitectureBlueprint §8.1). + +Coordination-canonical state (overlays, equivalence bindings, aliases, merges, forks) is an +**append-only decision log**, not a mutable file; the queryable *current* state is a **derived +fold** of the log (tier-3 disposable). The log is **totally ordered per space** via a single +**append authority** — here an in-process counter; a git-backed, lease-held authority is a later +binding. That total order is what gives read-your-writes across readers (§8.6). + +`derived = f(canonical)`: :class:`CoordinationState` is always reproducible by replaying the log. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from types import MappingProxyType +from typing import Any + +__all__ = ["EventType", "DecisionEvent", "CoordinationState", "DecisionLog"] + + +class EventType(Enum): + OVERLAY_CREATED = "overlay-created" + BINDING_MADE = "binding-made" + ALIAS_SET = "alias-set" + MERGE_DECIDED = "merge-decided" + PAGE_FORKED = "page-forked" + + +@dataclass(frozen=True, slots=True) +class DecisionEvent: + """One immutable, ordered decision. ``seq`` is the per-space total order.""" + + seq: int + space: str + type: EventType + payload: Mapping[str, Any] + actor: str | None = None + timestamp: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc)) + + +@dataclass(frozen=True, slots=True) +class CoordinationState: + """The derived fold of a space's log: current aliases + equivalence groups + open overlays. + + Disposable (tier-3): always recomputable from the log via :meth:`DecisionLog.fold`. + """ + + aliases: Mapping[str, str] + equivalence_groups: tuple[frozenset[str], ...] + open_overlays: Mapping[str, Mapping[str, Any]] + + def resolve_alias(self, name: str) -> str | None: + return self.aliases.get(name) + + def equivalent_to(self, identity: str) -> frozenset[str]: + """All identities equivalent to ``identity`` (including itself if bound), else just it.""" + for group in self.equivalence_groups: + if identity in group: + return group + return frozenset({identity}) + + +class DecisionLog: + """In-memory append-only log, totally ordered per space (the append authority for a process). + + A later binding swaps the storage for git + a per-space lease without changing this API. + """ + + def __init__(self) -> None: + self._events: dict[str, list[DecisionEvent]] = {} + + def append( + self, + space: str, + type: EventType, + payload: Mapping[str, Any], + actor: str | None = None, + ) -> DecisionEvent: + seq = len(self._events.get(space, ())) + event = DecisionEvent(seq=seq, space=space, type=type, payload=dict(payload), actor=actor) + self._events.setdefault(space, []).append(event) + return event + + def events(self, space: str) -> tuple[DecisionEvent, ...]: + """The space's events in append (total) order. Read-your-writes: a just-appended event + is present immediately.""" + return tuple(self._events.get(space, ())) + + def fold(self, space: str) -> CoordinationState: + """Replay the log into current coordination state (derived = f(log)).""" + aliases: dict[str, str] = {} + overlays: dict[str, dict[str, Any]] = {} + groups: list[set[str]] = [] + + for event in self.events(space): + if event.type is EventType.ALIAS_SET: + aliases[event.payload["alias"]] = event.payload["target"] + elif event.type is EventType.BINDING_MADE: + _merge_group(groups, {str(m) for m in event.payload["members"]}) + elif event.type is EventType.OVERLAY_CREATED: + overlays[event.payload["overlay_id"]] = dict(event.payload) + elif event.type is EventType.MERGE_DECIDED: + # A merge resolution may collapse an overlay; minimal handling for the slice. + overlays.pop(event.payload.get("overlay_id", ""), None) + elif event.type is EventType.PAGE_FORKED: + _merge_group(groups, {str(event.payload["source"]), str(event.payload["fork"])}) + + return CoordinationState( + aliases=MappingProxyType(dict(aliases)), + equivalence_groups=tuple(frozenset(g) for g in groups), + open_overlays=MappingProxyType({k: MappingProxyType(v) for k, v in overlays.items()}), + ) + + +def _merge_group(groups: list[set[str]], members: set[str]) -> None: + """Union-merge ``members`` into ``groups`` (any existing group sharing a member absorbs it).""" + touching = [g for g in groups if g & members] + for g in touching: + groups.remove(g) + members |= g + groups.append(members) diff --git a/tests/test_decision_log.py b/tests/test_decision_log.py new file mode 100644 index 0000000..8b5c970 --- /dev/null +++ b/tests/test_decision_log.py @@ -0,0 +1,51 @@ +"""Tests for the event-sourced DecisionLog (SHARD-WP-0007 T5).""" + +from shard_wiki.coordination import DecisionLog, EventType + + +def test_append_is_totally_ordered_per_space(): + log = DecisionLog() + log.append("spaceA", EventType.ALIAS_SET, {"alias": "Home", "target": "shardA:Index"}) + log.append("spaceA", EventType.ALIAS_SET, {"alias": "Start", "target": "shardA:Index"}) + log.append("spaceB", EventType.ALIAS_SET, {"alias": "X", "target": "shardB:Y"}) + seqs = [e.seq for e in log.events("spaceA")] + assert seqs == [0, 1] # per-space monotonic + assert [e.seq for e in log.events("spaceB")] == [0] # independent ordering + + +def test_read_your_writes(): + log = DecisionLog() + ev = log.append("s", EventType.ALIAS_SET, {"alias": "a", "target": "shardA:b"}) + assert log.events("s")[-1] is ev + + +def test_fold_reproduces_current_state(): + log = DecisionLog() + log.append("s", EventType.ALIAS_SET, {"alias": "Home", "target": "shardA:Index"}) + log.append("s", EventType.ALIAS_SET, {"alias": "Home", "target": "shardB:Main"}) # LWW + state = log.fold("s") + assert state.resolve_alias("Home") == "shardB:Main" + assert state.resolve_alias("missing") is None + + +def test_fold_is_pure_function_of_log(): + log = DecisionLog() + log.append("s", EventType.BINDING_MADE, {"members": ["shardA:Home", "shardB:Home"]}) + first = log.fold("s") + second = log.fold("s") + assert first.equivalence_groups == second.equivalence_groups # derived = f(log) + + +def test_equivalence_groups_merge_transitively(): + log = DecisionLog() + log.append("s", EventType.BINDING_MADE, {"members": ["a", "b"]}) + log.append("s", EventType.BINDING_MADE, {"members": ["b", "c"]}) + state = log.fold("s") + assert state.equivalent_to("a") == frozenset({"a", "b", "c"}) + assert state.equivalent_to("lonely") == frozenset({"lonely"}) + + +def test_page_fork_creates_equivalence(): + log = DecisionLog() + log.append("s", EventType.PAGE_FORKED, {"source": "shardA:Doc", "fork": "shardB:Doc"}) + assert log.fold("s").equivalent_to("shardA:Doc") == frozenset({"shardA:Doc", "shardB:Doc"}) diff --git a/workplans/SHARD-WP-0007-foundation-implementation.md b/workplans/SHARD-WP-0007-foundation-implementation.md index bb20b5b..2b4940e 100644 --- a/workplans/SHARD-WP-0007-foundation-implementation.md +++ b/workplans/SHARD-WP-0007-foundation-implementation.md @@ -101,7 +101,7 @@ lying stub fails with a precise diff. ```task id: SHARD-WP-0007-T5 -status: todo +status: done priority: high state_hub_task_id: "c87b1896-59a5-4cde-a292-1086caebd085" ```