generated from coulomb/repo-seed
feat(policy,union): policy leaf + UnionGraph resolution with chorus (WP-0007 T6)
policy/ leaf (CanonicalSource presets, default chorus). union/ UnionGraph: identity-keyed resolve (alias-redirect via log fold → union lookup → chorus → red-link); chorus records divergent peers in each page's provenance envelope (union without erasure); designated-canonical orders the pick. Imports down only. 6 tests green. (blueprint §8.4, ADR-01) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
36
src/shard_wiki/policy/__init__.py
Normal file
36
src/shard_wiki/policy/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""policy/ — the configurable policy surface, a dependency-free leaf (blueprint §10, §11).
|
||||||
|
|
||||||
|
Mechanism over policy (I-7): core mechanisms read policy *choices* from here; they never
|
||||||
|
hard-code one. This leaf holds only the presets + a pure ``resolve``-style selector. Mechanism
|
||||||
|
(how a choice is honoured) lives in ``coordination``/``union``/``projection``, never here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
__all__ = ["CanonicalSource", "Policy", "DEFAULT_POLICY"]
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalSource(Enum):
|
||||||
|
"""Resolution policy over a divergent/equivalent set (FederationArchitecture T9). Detection
|
||||||
|
is core; this is only the *resolution* choice."""
|
||||||
|
|
||||||
|
CHORUS = "chorus" # default: present all versions, none privileged
|
||||||
|
DESIGNATED_CANONICAL = "designated-canonical"
|
||||||
|
GIT_MERGE = "git-merge"
|
||||||
|
VOTE = "vote"
|
||||||
|
OVERLAY_ONLY = "overlay-only"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Policy:
|
||||||
|
"""A space's policy choices. Extended as the policy surface grows (freshness, compaction,
|
||||||
|
execution, tenant mapping — blueprint §10); the slice needs canonical-source + designation."""
|
||||||
|
|
||||||
|
canonical_source: CanonicalSource = CanonicalSource.CHORUS
|
||||||
|
designated_shard: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_POLICY = Policy()
|
||||||
8
src/shard_wiki/union/__init__.py
Normal file
8
src/shard_wiki/union/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""union/ — the derived-tier union (resolution, equivalence, projection read path).
|
||||||
|
|
||||||
|
Disposable/recomputable (I-2); imports down only and is imported by nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shard_wiki.union.resolver import Resolution, ResolutionKind, UnionGraph
|
||||||
|
|
||||||
|
__all__ = ["UnionGraph", "Resolution", "ResolutionKind"]
|
||||||
119
src/shard_wiki/union/resolver.py
Normal file
119
src/shard_wiki/union/resolver.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Union resolution — the derived-tier read path (CoreArchitectureBlueprint §8.4, ADR-01).
|
||||||
|
|
||||||
|
A minimal :class:`UnionGraph` over ≥1 attached shard plus the decision-log fold. ``resolve``
|
||||||
|
keys on **identity** (FederationRequirements ADR-01): alias redirect → exact union lookup →
|
||||||
|
equivalence chorus → red-link. Ambiguity returns a **chorus set** (union without erasure, I-4):
|
||||||
|
divergent peers are recorded in each page's provenance envelope rather than silently dropped.
|
||||||
|
|
||||||
|
This is derived/disposable: it reads shards + the log fold; it stores nothing canonical. Per the
|
||||||
|
dependency rule it may import down (model/adapters/coordination/policy/provenance) and is
|
||||||
|
imported by nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from shard_wiki.adapters import ShardAdapter
|
||||||
|
from shard_wiki.coordination import DecisionLog
|
||||||
|
from shard_wiki.model import Page
|
||||||
|
from shard_wiki.policy import DEFAULT_POLICY, CanonicalSource, Policy
|
||||||
|
|
||||||
|
__all__ = ["ResolutionKind", "Resolution", "UnionGraph"]
|
||||||
|
|
||||||
|
|
||||||
|
class ResolutionKind(Enum):
|
||||||
|
SINGLE = "single"
|
||||||
|
CHORUS = "chorus"
|
||||||
|
RED_LINK = "red-link"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Resolution:
|
||||||
|
name: str
|
||||||
|
kind: ResolutionKind
|
||||||
|
pages: tuple[Page, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_red_link(self) -> bool:
|
||||||
|
return self.kind is ResolutionKind.RED_LINK
|
||||||
|
|
||||||
|
def single(self) -> Page:
|
||||||
|
"""The one page (SINGLE), or the canonical pick of a CHORUS (first), else KeyError."""
|
||||||
|
if not self.pages:
|
||||||
|
raise KeyError(self.name)
|
||||||
|
return self.pages[0]
|
||||||
|
|
||||||
|
|
||||||
|
class UnionGraph:
|
||||||
|
"""Composes attached shards + the coordination-log fold into a resolvable union."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
space: str,
|
||||||
|
log: DecisionLog | None = None,
|
||||||
|
policy: Policy = DEFAULT_POLICY,
|
||||||
|
) -> None:
|
||||||
|
self.space = space
|
||||||
|
self.log = log or DecisionLog()
|
||||||
|
self.policy = policy
|
||||||
|
self._shards: list[ShardAdapter] = []
|
||||||
|
|
||||||
|
def attach(self, adapter: ShardAdapter) -> None:
|
||||||
|
self._shards.append(adapter)
|
||||||
|
|
||||||
|
def _shard(self, shard_id: str) -> ShardAdapter | None:
|
||||||
|
return next((s for s in self._shards if s.shard_id == shard_id), None)
|
||||||
|
|
||||||
|
def _read_all(self, key: str) -> list[Page]:
|
||||||
|
pages: list[Page] = []
|
||||||
|
for shard in self._shards:
|
||||||
|
try:
|
||||||
|
pages.append(shard.read(key))
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
return pages
|
||||||
|
|
||||||
|
def resolve(self, name: str) -> Resolution:
|
||||||
|
# 1. alias redirect (coordination-canonical, via the log fold)
|
||||||
|
state = self.log.fold(self.space)
|
||||||
|
target = state.resolve_alias(name)
|
||||||
|
if target is not None and ":" in target:
|
||||||
|
shard_id, _, key = target.partition(":")
|
||||||
|
shard = self._shard(shard_id)
|
||||||
|
if shard is not None:
|
||||||
|
try:
|
||||||
|
page = shard.read(key)
|
||||||
|
return Resolution(name, ResolutionKind.SINGLE, (page,))
|
||||||
|
except KeyError:
|
||||||
|
pass # alias dangles → fall through to normal resolution
|
||||||
|
|
||||||
|
# 2/3. union lookup by key across shards
|
||||||
|
pages = self._read_all(name)
|
||||||
|
if not pages:
|
||||||
|
return Resolution(name, ResolutionKind.RED_LINK)
|
||||||
|
if len(pages) == 1:
|
||||||
|
return Resolution(name, ResolutionKind.SINGLE, (pages[0],))
|
||||||
|
|
||||||
|
# ambiguity → chorus, with divergence recorded (never erased, I-4)
|
||||||
|
ordered = self._order_for_policy(pages)
|
||||||
|
marked = tuple(_with_divergence(p, ordered) for p in ordered)
|
||||||
|
return Resolution(name, ResolutionKind.CHORUS, marked)
|
||||||
|
|
||||||
|
def _order_for_policy(self, pages: list[Page]) -> list[Page]:
|
||||||
|
if (
|
||||||
|
self.policy.canonical_source is CanonicalSource.DESIGNATED_CANONICAL
|
||||||
|
and self.policy.designated_shard is not None
|
||||||
|
):
|
||||||
|
pages = sorted(
|
||||||
|
pages, key=lambda p: p.identity.shard != self.policy.designated_shard
|
||||||
|
)
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def _with_divergence(page: Page, group: list[Page]) -> Page:
|
||||||
|
peers = tuple(str(p.identity) for p in group if p.identity != page.identity)
|
||||||
|
new_env = dataclasses.replace(page.envelope, divergence=peers)
|
||||||
|
return dataclasses.replace(page, envelope=new_env)
|
||||||
69
tests/test_union.py
Normal file
69
tests/test_union.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Tests for union resolution (SHARD-WP-0007 T6)."""
|
||||||
|
|
||||||
|
from shard_wiki.adapters import FolderAdapter
|
||||||
|
from shard_wiki.coordination import DecisionLog, EventType
|
||||||
|
from shard_wiki.policy import CanonicalSource, Policy
|
||||||
|
from shard_wiki.union import ResolutionKind, UnionGraph
|
||||||
|
|
||||||
|
|
||||||
|
def _shard(tmp_path, name, files):
|
||||||
|
root = tmp_path / name
|
||||||
|
for rel, text in files.items():
|
||||||
|
p = root / rel
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(text, encoding="utf-8")
|
||||||
|
return FolderAdapter(name, root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_resolution(tmp_path):
|
||||||
|
u = UnionGraph("space")
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Home.md": "A home"}))
|
||||||
|
res = u.resolve("Home")
|
||||||
|
assert res.kind is ResolutionKind.SINGLE
|
||||||
|
assert res.single().body == "A home"
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_link_when_absent(tmp_path):
|
||||||
|
u = UnionGraph("space")
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Home.md": "x"}))
|
||||||
|
assert u.resolve("Nope").is_red_link
|
||||||
|
|
||||||
|
|
||||||
|
def test_chorus_on_ambiguity_records_divergence(tmp_path):
|
||||||
|
u = UnionGraph("space")
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Home.md": "A home"}))
|
||||||
|
u.attach(_shard(tmp_path, "shardB", {"Home.md": "B home"}))
|
||||||
|
res = u.resolve("Home")
|
||||||
|
assert res.kind is ResolutionKind.CHORUS
|
||||||
|
assert {p.body for p in res.pages} == {"A home", "B home"}
|
||||||
|
# Each page names its divergent peer — union without erasure.
|
||||||
|
a = next(p for p in res.pages if p.identity.shard == "shardA")
|
||||||
|
assert a.envelope.divergence == ("shardB:Home",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_designated_canonical_orders_first(tmp_path):
|
||||||
|
policy = Policy(canonical_source=CanonicalSource.DESIGNATED_CANONICAL, designated_shard="shardB")
|
||||||
|
u = UnionGraph("space", policy=policy)
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Home.md": "A"}))
|
||||||
|
u.attach(_shard(tmp_path, "shardB", {"Home.md": "B"}))
|
||||||
|
res = u.resolve("Home")
|
||||||
|
assert res.kind is ResolutionKind.CHORUS
|
||||||
|
assert res.single().identity.shard == "shardB" # designated wins the canonical pick
|
||||||
|
|
||||||
|
|
||||||
|
def test_alias_from_log_redirects(tmp_path):
|
||||||
|
log = DecisionLog()
|
||||||
|
log.append("space", EventType.ALIAS_SET, {"alias": "Start", "target": "shardA:Index"})
|
||||||
|
u = UnionGraph("space", log=log)
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Index.md": "the index"}))
|
||||||
|
res = u.resolve("Start")
|
||||||
|
assert res.kind is ResolutionKind.SINGLE
|
||||||
|
assert res.single().body == "the index"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dangling_alias_falls_through_to_red_link(tmp_path):
|
||||||
|
log = DecisionLog()
|
||||||
|
log.append("space", EventType.ALIAS_SET, {"alias": "Start", "target": "shardA:Missing"})
|
||||||
|
u = UnionGraph("space", log=log)
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Index.md": "x"}))
|
||||||
|
assert u.resolve("Start").is_red_link
|
||||||
@@ -116,7 +116,7 @@ append→ordered read; fold reproduces current state; read-your-writes.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0007-T6
|
id: SHARD-WP-0007-T6
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "fed38b60-dc0b-40cf-93e9-ab0260aa3ff9"
|
state_hub_task_id: "fed38b60-dc0b-40cf-93e9-ab0260aa3ff9"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user