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
|
```task
|
||||||
id: SHARD-WP-0007-T5
|
id: SHARD-WP-0007-T5
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c87b1896-59a5-4cde-a292-1086caebd085"
|
state_hub_task_id: "c87b1896-59a5-4cde-a292-1086caebd085"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user