generated from coulomb/repo-seed
feat(coordination): event-sourced DecisionLog keystone (WP-0007 T5)
Append-only, totally-ordered-per-space decision log (in-process append authority; git+lease later) with event types overlay/binding/alias/merge/fork, and a derived fold to CoordinationState (aliases LWW, transitively-merged equivalence groups, open overlays). derived = f(log); read-your-writes. 6 tests green. (blueprint §8.1) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
10
src/shard_wiki/coordination/__init__.py
Normal file
10
src/shard_wiki/coordination/__init__.py
Normal file
@@ -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"]
|
||||
124
src/shard_wiki/coordination/decision_log.py
Normal file
124
src/shard_wiki/coordination/decision_log.py
Normal file
@@ -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)
|
||||
51
tests/test_decision_log.py
Normal file
51
tests/test_decision_log.py
Normal file
@@ -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"})
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user