generated from coulomb/repo-seed
Compare commits
7 Commits
3b4f10a349
...
517cf1d282
| Author | SHA1 | Date | |
|---|---|---|---|
| 517cf1d282 | |||
| b44b2a74a4 | |||
| 24108b65aa | |||
| 6d48a1d3e6 | |||
| 9a4e00a05a | |||
| 5a77ea879c | |||
| aca9bf30f9 |
2
SCOPE.md
2
SCOPE.md
@@ -17,7 +17,7 @@ Learnings update both SCOPE and INTENT where necessary.
|
||||
|
||||
| Layer | State |
|
||||
|-------|-------|
|
||||
| Code | Python package scaffold (`src/shard_wiki/`, smoke tests only) |
|
||||
| Code | Foundation slice implemented (SHARD-WP-0007): `provenance` + `policy` leaves, `model` (Identity/Placement/Span/Page/CapabilityProfile), `adapters` (contract + FolderAdapter + conformance suite), `coordination` (event-sourced DecisionLog), `union` (resolution + chorus), `InformationSpace` orchestrator — attach→resolve→read works; 39 tests green |
|
||||
| Intent | `INTENT.md` established; authorization-in-core amendments drafted |
|
||||
| Research | yawex prior art; c2 origins; federation concepts; wikiengines overview (`research/260608-*/`); XWiki/TWiki/Foswiki deep dives (`research/260613-*/`); Xanadu + ZigZag + Roam + Obsidian + Notion + Joplin + Logseq + local-first workspaces (Anytype/AFFiNE/AppFlowy) + Trilium + Wiki.js + Federated Wiki + Wikibase + git-forge wikis + TiddlyWiki + ikiwiki + Quip + MojoMojo + Oddmuse + UseModWiki deep dives & shard-spectrum synthesis (`research/260614-*/`) |
|
||||
| Demand | NetKingdom integration asks captured, not yet negotiated |
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""shard-wiki — Git-based Markdown wiki orchestrator and federation layer.
|
||||
|
||||
See INTENT.md for the authoritative specification of scope and boundaries.
|
||||
This package orchestrates wiki-shaped content across heterogeneous *shards*;
|
||||
it is not itself a wiki engine.
|
||||
See INTENT.md for the authoritative specification of scope and boundaries, and
|
||||
spec/CoreArchitectureBlueprint.md for the architecture. This package orchestrates
|
||||
wiki-shaped content across heterogeneous *shards*; it is not itself a wiki engine.
|
||||
|
||||
Foundation slice (SHARD-WP-0007): attach folder shard(s) to an
|
||||
:class:`~shard_wiki.space.InformationSpace`, resolve a name through the union, and
|
||||
read a page with layered provenance (chorus on ambiguity).
|
||||
"""
|
||||
|
||||
from shard_wiki.space import InformationSpace
|
||||
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__all__ = ["__version__", "InformationSpace"]
|
||||
|
||||
22
src/shard_wiki/adapters/__init__.py
Normal file
22
src/shard_wiki/adapters/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""adapters/ — the shard adapter contract (bottom waist) and concrete adapters."""
|
||||
|
||||
from shard_wiki.adapters.conformance import (
|
||||
Check,
|
||||
ConformanceError,
|
||||
ConformanceReport,
|
||||
assert_conformant,
|
||||
run_conformance,
|
||||
)
|
||||
from shard_wiki.adapters.contract import CONTRACT_VERSION, ShardAdapter
|
||||
from shard_wiki.adapters.folder import FolderAdapter
|
||||
|
||||
__all__ = [
|
||||
"ShardAdapter",
|
||||
"FolderAdapter",
|
||||
"CONTRACT_VERSION",
|
||||
"Check",
|
||||
"ConformanceReport",
|
||||
"ConformanceError",
|
||||
"run_conformance",
|
||||
"assert_conformant",
|
||||
]
|
||||
126
src/shard_wiki/adapters/conformance.py
Normal file
126
src/shard_wiki/adapters/conformance.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Adapter conformance — profiles are verified, not self-asserted (TSD §A.2, blueprint §6.6).
|
||||
|
||||
Capability-as-data (I-3) is only sound if a binding's *declared* profile matches its *observed*
|
||||
behaviour. This battery exercises a binding and reports, check by check, whether claim == reality;
|
||||
``assert_conformant`` gates registration. A lying/buggy profile fails here instead of silently
|
||||
poisoning degradation decisions downstream.
|
||||
|
||||
This slice verifies the read path + honest absence of unclaimed verbs. Positive probes for
|
||||
claimed write/diff/merge are deferred (they mutate) to a later workplan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from shard_wiki.adapters.contract import ShardAdapter
|
||||
from shard_wiki.model import NotSupported, Verb
|
||||
|
||||
__all__ = ["Check", "ConformanceReport", "ConformanceError", "run_conformance", "assert_conformant"]
|
||||
|
||||
# Optional verbs whose *absence* must be honest (calling an unclaimed one raises NotSupported).
|
||||
_HONEST_ABSENCE_VERBS = (Verb.WRITE, Verb.DIFF, Verb.NOTIFY)
|
||||
_PROBE = {
|
||||
Verb.WRITE: lambda a: a.write("__probe__", ""),
|
||||
Verb.DIFF: lambda a: a.diff("__probe__", "__probe2__"),
|
||||
Verb.NOTIFY: lambda a: a.notify(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Check:
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConformanceReport:
|
||||
adapter: str
|
||||
checks: tuple[Check, ...]
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return all(c.ok for c in self.checks)
|
||||
|
||||
@property
|
||||
def failures(self) -> tuple[Check, ...]:
|
||||
return tuple(c for c in self.checks if not c.ok)
|
||||
|
||||
def diff(self) -> str:
|
||||
return "; ".join(f"{c.name}: {c.detail}" for c in self.failures) or "conformant"
|
||||
|
||||
|
||||
class ConformanceError(Exception):
|
||||
def __init__(self, report: ConformanceReport) -> None:
|
||||
super().__init__(f"{report.adapter} not conformant — {report.diff()}")
|
||||
self.report = report
|
||||
|
||||
|
||||
def _safe(fn, name: str, ok_detail: str = "") -> Check:
|
||||
try:
|
||||
return fn()
|
||||
except Exception as exc: # noqa: BLE001 — a check must never crash the battery
|
||||
return Check(name, False, f"unexpected error: {exc!r}")
|
||||
|
||||
|
||||
def run_conformance(adapter: ShardAdapter) -> ConformanceReport:
|
||||
checks: list[Check] = []
|
||||
profile = None
|
||||
|
||||
def _profile_validates() -> Check:
|
||||
nonlocal profile
|
||||
profile = adapter.profile()
|
||||
profile.validate()
|
||||
return Check("profile-validates", True)
|
||||
|
||||
checks.append(_safe(_profile_validates, "profile-validates"))
|
||||
if profile is None: # profile() or validate() failed; can't probe further meaningfully
|
||||
return ConformanceReport(type(adapter).__name__, tuple(checks))
|
||||
|
||||
# READ is the capability floor.
|
||||
checks.append(Check("supports-read", profile.supports(Verb.READ),
|
||||
"" if profile.supports(Verb.READ) else "READ not declared"))
|
||||
|
||||
# READ round-trips: a declared-readable shard must actually read its own keys.
|
||||
def _read_round_trips() -> Check:
|
||||
keys = list(adapter.keys())
|
||||
if not keys:
|
||||
return Check("read-round-trips", True, "empty shard")
|
||||
page = adapter.read(keys[0])
|
||||
if page.identity.shard != adapter.shard_id:
|
||||
return Check("read-round-trips", False,
|
||||
f"page shard {page.identity.shard!r} != {adapter.shard_id!r}")
|
||||
if not isinstance(page.body, str):
|
||||
return Check("read-round-trips", False, "body is not text")
|
||||
return Check("read-round-trips", True)
|
||||
|
||||
if profile.supports(Verb.READ):
|
||||
checks.append(_safe(_read_round_trips, "read-round-trips"))
|
||||
|
||||
# Honest absence: an *unclaimed* optional verb must raise NotSupported when invoked.
|
||||
for verb in _HONEST_ABSENCE_VERBS:
|
||||
if profile.supports(verb):
|
||||
continue # claimed → positive probe deferred (would mutate)
|
||||
name = f"honest-absence:{verb.value}"
|
||||
|
||||
def _probe(v=verb, n=name) -> Check:
|
||||
try:
|
||||
_PROBE[v](adapter)
|
||||
except NotSupported:
|
||||
return Check(n, True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return Check(n, False, f"raised {type(exc).__name__}, expected NotSupported")
|
||||
return Check(n, False, "did not raise NotSupported though verb is unclaimed")
|
||||
|
||||
checks.append(_probe())
|
||||
|
||||
return ConformanceReport(type(adapter).__name__, tuple(checks))
|
||||
|
||||
|
||||
def assert_conformant(adapter: ShardAdapter) -> ConformanceReport:
|
||||
"""Run the battery; raise :class:`ConformanceError` if any check fails. Returns the report."""
|
||||
report = run_conformance(adapter)
|
||||
if not report.ok:
|
||||
raise ConformanceError(report)
|
||||
return report
|
||||
52
src/shard_wiki/adapters/contract.py
Normal file
52
src/shard_wiki/adapters/contract.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""The shard adapter contract — the bottom narrow waist (CoreArchitectureBlueprint §6, TSD §A).
|
||||
|
||||
A backend participates by implementing :class:`ShardAdapter`. ``shard_id``, ``profile`` and
|
||||
``read`` are mandatory; everything else is an optional capability that defaults to raising
|
||||
:class:`~shard_wiki.model.NotSupported` — so a limited backend is honest about what it can't do
|
||||
(graceful degradation, I-8) and core never assumes a capability it wasn't given (capability-as-
|
||||
data, I-3). Declared profiles are verified by the conformance suite (T4), never taken on trust.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from collections.abc import Iterable
|
||||
|
||||
from shard_wiki.model import CapabilityProfile, NotSupported, Page
|
||||
|
||||
__all__ = ["ShardAdapter", "CONTRACT_VERSION"]
|
||||
|
||||
CONTRACT_VERSION = "0.1"
|
||||
|
||||
|
||||
class ShardAdapter(abc.ABC):
|
||||
"""Versioned interface a backend implements to attach as a shard."""
|
||||
|
||||
contract_version: str = CONTRACT_VERSION
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def shard_id(self) -> str:
|
||||
"""Stable id scoping every Identity this shard mints."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def profile(self) -> CapabilityProfile:
|
||||
"""The (to-be-verified) capability profile of this binding."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def keys(self) -> Iterable[str]:
|
||||
"""The stable page keys this shard offers (the handle half of Identity)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def read(self, key: str) -> Page:
|
||||
"""Read one page by its stable key. Raises ``KeyError`` if absent."""
|
||||
|
||||
# --- optional capability verbs: honest NotSupported by default ---
|
||||
def write(self, key: str, body: str) -> Page: # noqa: ARG002
|
||||
raise NotSupported(f"{type(self).__name__} does not support write")
|
||||
|
||||
def diff(self, key: str, other: str) -> str: # noqa: ARG002
|
||||
raise NotSupported(f"{type(self).__name__} does not support diff")
|
||||
|
||||
def notify(self):
|
||||
raise NotSupported(f"{type(self).__name__} does not support notify")
|
||||
91
src/shard_wiki/adapters/folder.py
Normal file
91
src/shard_wiki/adapters/folder.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""FolderAdapter — a read-only file-store shard over a directory of Markdown.
|
||||
|
||||
The home-case substrate: a plain folder of ``.md`` files. The relative path (sans extension,
|
||||
``/``-separated) is the stable page **key**; the file is the page **body**; mtime gives a
|
||||
freshness stamp. Read-only in this slice (overlay/write-through come later); declared profile
|
||||
reflects exactly that (read-only, file-store, path addressing, no native history/query).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from shard_wiki.adapters.contract import ShardAdapter
|
||||
from shard_wiki.model import (
|
||||
AccessGrant,
|
||||
Addressing,
|
||||
AttachmentMode,
|
||||
CapabilityProfile,
|
||||
ContentOpacity,
|
||||
History,
|
||||
Identity,
|
||||
MergeModel,
|
||||
NativeQuery,
|
||||
OperationalEnvelope,
|
||||
Page,
|
||||
Placement,
|
||||
Substrate,
|
||||
Translation,
|
||||
Verb,
|
||||
WriteGranularity,
|
||||
)
|
||||
from shard_wiki.provenance import Liveness, ProvenanceEnvelope, Staleness
|
||||
|
||||
__all__ = ["FolderAdapter"]
|
||||
|
||||
|
||||
class FolderAdapter(ShardAdapter):
|
||||
def __init__(self, shard_id: str, root: str | Path) -> None:
|
||||
self._shard_id = shard_id
|
||||
self._root = Path(root)
|
||||
|
||||
@property
|
||||
def shard_id(self) -> str:
|
||||
return self._shard_id
|
||||
|
||||
def profile(self) -> CapabilityProfile:
|
||||
return CapabilityProfile(
|
||||
substrate=Substrate.FILES,
|
||||
attachment_mode=AttachmentMode.FILE_STORE,
|
||||
write_granularity=WriteGranularity.NONE,
|
||||
content_opacity=ContentOpacity.TRANSPARENT,
|
||||
operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED,
|
||||
access_grant=AccessGrant.OPEN,
|
||||
liveness=Liveness.STATIC,
|
||||
history=History.NONE,
|
||||
merge_model=MergeModel.NONE,
|
||||
addressing=Addressing.PATH,
|
||||
native_query=NativeQuery.NONE,
|
||||
translation=Translation.NATIVE,
|
||||
supported_verbs=frozenset({Verb.READ}),
|
||||
).validate()
|
||||
|
||||
def _path_for(self, key: str) -> Path:
|
||||
return self._root / f"{key}.md"
|
||||
|
||||
def keys(self) -> Iterable[str]:
|
||||
for p in sorted(self._root.rglob("*.md")):
|
||||
yield p.relative_to(self._root).with_suffix("").as_posix()
|
||||
|
||||
def read(self, key: str) -> Page:
|
||||
path = self._path_for(key)
|
||||
if not path.is_file():
|
||||
raise KeyError(key)
|
||||
body = path.read_text(encoding="utf-8")
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
||||
envelope = ProvenanceEnvelope(
|
||||
source_shard=self._shard_id,
|
||||
liveness=Liveness.STATIC,
|
||||
staleness=Staleness.FRESH,
|
||||
source_rev=mtime.isoformat(),
|
||||
observed_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
rel = path.relative_to(self._root).as_posix()
|
||||
return Page(
|
||||
identity=Identity(self._shard_id, key),
|
||||
body=body,
|
||||
envelope=envelope,
|
||||
placements=(Placement(self._shard_id, rel),),
|
||||
)
|
||||
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)
|
||||
47
src/shard_wiki/model/__init__.py
Normal file
47
src/shard_wiki/model/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""model/ — the backend-neutral page model and capability types (the top waist).
|
||||
|
||||
Imports only the ``provenance`` leaf; nothing backend-specific lives here.
|
||||
"""
|
||||
|
||||
from shard_wiki.model.capability import (
|
||||
AccessGrant,
|
||||
Addressing,
|
||||
AttachmentMode,
|
||||
CapabilityProfile,
|
||||
ContentOpacity,
|
||||
History,
|
||||
MergeModel,
|
||||
NativeQuery,
|
||||
NotSupported,
|
||||
OperationalEnvelope,
|
||||
ProfileError,
|
||||
Substrate,
|
||||
Translation,
|
||||
Verb,
|
||||
WriteGranularity,
|
||||
)
|
||||
from shard_wiki.model.identity import Identity, Placement, Span
|
||||
from shard_wiki.model.page import Page, PageShape
|
||||
|
||||
__all__ = [
|
||||
"Identity",
|
||||
"Placement",
|
||||
"Span",
|
||||
"Page",
|
||||
"PageShape",
|
||||
"CapabilityProfile",
|
||||
"Verb",
|
||||
"Substrate",
|
||||
"AttachmentMode",
|
||||
"WriteGranularity",
|
||||
"ContentOpacity",
|
||||
"OperationalEnvelope",
|
||||
"AccessGrant",
|
||||
"History",
|
||||
"MergeModel",
|
||||
"Addressing",
|
||||
"NativeQuery",
|
||||
"Translation",
|
||||
"ProfileError",
|
||||
"NotSupported",
|
||||
]
|
||||
220
src/shard_wiki/model/capability.py
Normal file
220
src/shard_wiki/model/capability.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Capability profile — capability-as-data (CoreArchitectureBlueprint §6, I-3).
|
||||
|
||||
A binding's abilities are a *position on each capability spectrum*, not a boolean checklist.
|
||||
Descriptively there are fifteen spectra (§6.1); operationally they reduce to a small
|
||||
**orthogonal core** plus **implied** axes, with **implication rules that forbid impossible
|
||||
profiles** (§6.5). ``CapabilityProfile.validate()`` enforces those rules; a profile that
|
||||
violates one is rejected (the structural half of "capability-as-data is sound" — the behavioural
|
||||
half is the conformance suite, T4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from shard_wiki.provenance import Liveness
|
||||
|
||||
__all__ = [
|
||||
"Verb",
|
||||
"Substrate",
|
||||
"AttachmentMode",
|
||||
"WriteGranularity",
|
||||
"ContentOpacity",
|
||||
"OperationalEnvelope",
|
||||
"AccessGrant",
|
||||
"History",
|
||||
"MergeModel",
|
||||
"Addressing",
|
||||
"NativeQuery",
|
||||
"Translation",
|
||||
"CapabilityProfile",
|
||||
"ProfileError",
|
||||
"NotSupported",
|
||||
]
|
||||
|
||||
CONTRACT_VERSION = "0.1"
|
||||
|
||||
|
||||
class ProfileError(ValueError):
|
||||
"""Raised when a capability profile is internally impossible (violates §6.5 rules)."""
|
||||
|
||||
|
||||
class NotSupported(Exception):
|
||||
"""Raised by an adapter when an operation verb is not in its capability profile."""
|
||||
|
||||
|
||||
class Verb(Enum):
|
||||
READ = "read"
|
||||
WRITE = "write"
|
||||
DIFF = "diff"
|
||||
MERGE = "merge"
|
||||
LOCK = "lock"
|
||||
VERSION = "version"
|
||||
PUBLISH = "publish"
|
||||
NOTIFY = "notify"
|
||||
TRANSCLUDE_SOURCE = "transclude-source"
|
||||
TRANSLATE_SYNTAX = "translate-syntax"
|
||||
STRUCTURED_PAYLOAD = "structured-payload"
|
||||
DERIVE_PROJECTION = "derive-projection" # gated, off by default (T18)
|
||||
EXECUTE = "execute" # gated, off by default (T18)
|
||||
|
||||
|
||||
# --- orthogonal core axes (§6.5a) ---
|
||||
class Substrate(Enum):
|
||||
GIT = "git"
|
||||
FILES = "files"
|
||||
RELATIONAL_DB = "relational-db"
|
||||
EXTERNAL_API = "external-api"
|
||||
CRDT = "crdt"
|
||||
|
||||
|
||||
class WriteGranularity(Enum):
|
||||
NONE = "none" # read-only
|
||||
WHOLE_FILE = "whole-file"
|
||||
SECTION = "section"
|
||||
PER_PAGE = "per-page"
|
||||
PER_BLOCK = "per-block"
|
||||
|
||||
|
||||
class ContentOpacity(Enum):
|
||||
TRANSPARENT = "transparent"
|
||||
STRUCTURED_REEVALUABLE = "structured-re-evaluable"
|
||||
ENCRYPTED = "encrypted"
|
||||
PER_ITEM = "per-item"
|
||||
PROPRIETARY_LOSSY = "proprietary-lossy"
|
||||
|
||||
|
||||
class OperationalEnvelope(Enum):
|
||||
LOCAL_UNBOUNDED = "local-unbounded"
|
||||
REALTIME = "realtime"
|
||||
RATE_LIMITED = "rate-limited"
|
||||
|
||||
|
||||
class AccessGrant(Enum):
|
||||
OPEN = "open"
|
||||
TOKEN = "token"
|
||||
OAUTH_SCOPED = "oauth-scoped"
|
||||
P2P_KEY = "p2p-key"
|
||||
ENTERPRISE_ACL = "enterprise-acl"
|
||||
|
||||
|
||||
# --- implied axes (§6.5b): may be derived from the core, validated for consistency ---
|
||||
class History(Enum):
|
||||
NONE = "none"
|
||||
INTERNAL_ONLY = "internal-only"
|
||||
CRDT_LOG = "crdt-log"
|
||||
OPEN_FILE = "open-file"
|
||||
GIT_NATIVE = "git-native"
|
||||
|
||||
|
||||
class MergeModel(Enum):
|
||||
NONE = "none"
|
||||
GIT_TEXT = "git-text"
|
||||
KEEP_BOTH = "keep-both"
|
||||
NATIVE_CRDT = "native-crdt"
|
||||
COEXIST_RANK = "coexist-rank"
|
||||
|
||||
|
||||
class AttachmentMode(Enum):
|
||||
# NB: there is deliberately no IMAGE mode — an image/live-memory blob is never an attach
|
||||
# target (I-12). It participates only via export→files.
|
||||
FILE_STORE = "file-store"
|
||||
GIT_IS_STORE = "git-is-store"
|
||||
IN_ENGINE_HOST = "in-engine-host"
|
||||
LOCAL_REST = "local-rest"
|
||||
EXTERNAL_API = "external-api"
|
||||
DIRECT_DB = "direct-db"
|
||||
CRDT_REPLICA = "crdt-replica"
|
||||
P2P = "p2p"
|
||||
|
||||
|
||||
class Addressing(Enum):
|
||||
NONE = "none"
|
||||
PATH = "path"
|
||||
PAGE_ID = "page-id"
|
||||
SPAN = "span"
|
||||
BLOCK_ID = "block-id"
|
||||
STORE_UUID = "store-uuid"
|
||||
TUMBLER = "tumbler"
|
||||
|
||||
|
||||
class NativeQuery(Enum):
|
||||
NONE = "none"
|
||||
TEXT = "text"
|
||||
DERIVED_INDEX = "derived-index"
|
||||
DATALOG_GRAPH = "datalog-graph"
|
||||
DB_QUERY = "db-query"
|
||||
SPARQL = "sparql"
|
||||
|
||||
|
||||
class Translation(Enum):
|
||||
NATIVE = "native"
|
||||
LOSSLESS = "lossless"
|
||||
LOSSY_WITH_REPORT = "lossy-with-report"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CapabilityProfile:
|
||||
"""A verified-at-registration position on each capability axis, plus supported verbs."""
|
||||
|
||||
# orthogonal core
|
||||
substrate: Substrate
|
||||
attachment_mode: AttachmentMode
|
||||
write_granularity: WriteGranularity
|
||||
content_opacity: ContentOpacity
|
||||
operational_envelope: OperationalEnvelope
|
||||
access_grant: AccessGrant
|
||||
liveness: Liveness # computational/liveness axis (15th)
|
||||
# implied
|
||||
history: History
|
||||
merge_model: MergeModel
|
||||
addressing: Addressing
|
||||
native_query: NativeQuery
|
||||
translation: Translation
|
||||
supported_verbs: frozenset[Verb] = field(default_factory=frozenset)
|
||||
contract_version: str = CONTRACT_VERSION
|
||||
|
||||
def supports(self, verb: Verb) -> bool:
|
||||
return verb in self.supported_verbs
|
||||
|
||||
def validate(self) -> CapabilityProfile:
|
||||
"""Apply the §6.5 implication rules; raise :class:`ProfileError` on an impossible
|
||||
combination. Returns ``self`` so it can be used fluently at construction."""
|
||||
rules: list[tuple[bool, str]] = [
|
||||
(
|
||||
Verb.READ in self.supported_verbs,
|
||||
"every shard must support READ (the capability floor)",
|
||||
),
|
||||
(
|
||||
self.attachment_mode is not AttachmentMode.GIT_IS_STORE
|
||||
or (self.substrate is Substrate.GIT and self.history is History.GIT_NATIVE),
|
||||
"git-is-store ⟹ substrate=git ∧ history=git-native",
|
||||
),
|
||||
(
|
||||
self.content_opacity is not ContentOpacity.ENCRYPTED
|
||||
or self.native_query is NativeQuery.NONE,
|
||||
"encrypted opacity ⟹ native-query=none",
|
||||
),
|
||||
(
|
||||
self.merge_model is not MergeModel.NATIVE_CRDT
|
||||
or (
|
||||
self.history is History.CRDT_LOG
|
||||
and self.operational_envelope is OperationalEnvelope.REALTIME
|
||||
),
|
||||
"native-crdt merge ⟹ history=crdt-log ∧ envelope=realtime",
|
||||
),
|
||||
(
|
||||
(self.write_granularity is WriteGranularity.NONE)
|
||||
== (Verb.WRITE not in self.supported_verbs),
|
||||
"write-granularity=none ⟺ WRITE not supported (read-only consistency)",
|
||||
),
|
||||
(
|
||||
Verb.MERGE not in self.supported_verbs or self.merge_model is not MergeModel.NONE,
|
||||
"MERGE verb ⟹ merge-model ≠ none",
|
||||
),
|
||||
]
|
||||
broken = [msg for ok, msg in rules if not ok]
|
||||
if broken:
|
||||
raise ProfileError("; ".join(broken))
|
||||
return self
|
||||
60
src/shard_wiki/model/identity.py
Normal file
60
src/shard_wiki/model/identity.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Identity, Placement, Span — three distinct concepts (CoreArchitectureBlueprint §7.2).
|
||||
|
||||
- **Identity** is a *stable handle* assigned/minted by a shard; it survives edits. It is NEVER
|
||||
derived from content (a content fingerprint identifies a *version*, not a *page*).
|
||||
- **Placement** is *where* an identity sits (path in a shard); one identity may have many
|
||||
placements (a DAG). Placement can change without changing identity.
|
||||
- **Span** is a sub-page address within an identity, optionally carrying a provenance delta.
|
||||
|
||||
Equivalence (detecting that two *distinct* identities hold the same content) is a separate
|
||||
mechanism and lives in ``union/`` — not here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from shard_wiki.provenance import SpanProvenanceDelta
|
||||
|
||||
__all__ = ["Identity", "Placement", "Span"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Identity:
|
||||
"""A shard-scoped, stable page handle. Value-equal; stable across content edits.
|
||||
|
||||
``shard`` scopes the handle so native ids survive projection and never collide across
|
||||
shards. ``key`` is the backend's stable handle (page name / native uid) — not a fingerprint.
|
||||
"""
|
||||
|
||||
shard: str
|
||||
key: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.shard}:{self.key}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Placement:
|
||||
"""Where an identity sits: a path within a shard. Mutable over an identity's life."""
|
||||
|
||||
shard: str
|
||||
path: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.shard}/{self.path}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Span:
|
||||
"""A sub-page address within a page identity, with an optional provenance delta.
|
||||
|
||||
``ref`` is a native span id where the backend mints one (Roam ``:block/uid``, Logseq
|
||||
``id::``), else a positional/fingerprint address. ``delta`` overrides the page envelope
|
||||
only where this span genuinely differs (effective-vs-own, blueprint §7.3).
|
||||
"""
|
||||
|
||||
ref: str
|
||||
start: int | None = None
|
||||
end: int | None = None
|
||||
delta: SpanProvenanceDelta | None = None
|
||||
43
src/shard_wiki/model/page.py
Normal file
43
src/shard_wiki/model/page.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""The backend-neutral wiki page model — the top narrow waist (CoreArchitectureBlueprint §7).
|
||||
|
||||
Markdown-first but stretchable: every shape reduces to
|
||||
``(content|source, structure, provenance envelope, [derivation rule])``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from shard_wiki.model.identity import Identity, Placement, Span
|
||||
from shard_wiki.provenance import ProvenanceEnvelope
|
||||
|
||||
__all__ = ["PageShape", "Page"]
|
||||
|
||||
|
||||
class PageShape(Enum):
|
||||
"""The page-model shapes (blueprint §7.1). The slice handles PROSE; the rest are declared
|
||||
so adapters can tag content faithfully (union without erasure)."""
|
||||
|
||||
PROSE = "prose"
|
||||
TYPED_RECORD = "typed-record"
|
||||
TYPED_GRAPH = "typed-graph"
|
||||
INLINE_EMBEDDED = "inline-embedded"
|
||||
NON_MARKDOWN_ASSET = "non-markdown-asset"
|
||||
ONE_SOURCE_MANY_PROJECTIONS = "one-source-many-projections"
|
||||
NOTEBOOK = "notebook"
|
||||
PROGRAM_AS_PAGE = "program-as-page"
|
||||
LIVE_TEMPORAL = "live-temporal"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Page:
|
||||
"""A page in the union: a stable identity, content, the page-level provenance envelope,
|
||||
its placements, and its spans (whose deltas layer over the page envelope, §7.3)."""
|
||||
|
||||
identity: Identity
|
||||
body: str
|
||||
envelope: ProvenanceEnvelope
|
||||
shape: PageShape = PageShape.PROSE
|
||||
placements: tuple[Placement, ...] = ()
|
||||
spans: tuple[Span, ...] = field(default_factory=tuple)
|
||||
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()
|
||||
116
src/shard_wiki/provenance/__init__.py
Normal file
116
src/shard_wiki/provenance/__init__.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Provenance — the cross-cutting leaf rail (CoreArchitectureBlueprint §7.3, §11).
|
||||
|
||||
Every artifact in the union carries a :class:`ProvenanceEnvelope`. To keep per-span cost near
|
||||
zero when provenance is uniform, envelopes are **layered**: a page-level envelope plus optional
|
||||
per-span deltas. The *effective* provenance of a span is ``page_envelope ⊕ span_delta`` — a span
|
||||
with no delta inherits the page envelope at zero cost ("effective-vs-own", the same pattern the
|
||||
page model uses for computed metadata).
|
||||
|
||||
This module is a **dependency-free leaf**: it imports nothing else in the package and contains
|
||||
only stable data types plus the pure ``effective`` composition. Mechanism never lives here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
__all__ = [
|
||||
"Liveness",
|
||||
"Staleness",
|
||||
"OverlayState",
|
||||
"ProvenanceEnvelope",
|
||||
"SpanProvenanceDelta",
|
||||
"effective",
|
||||
]
|
||||
|
||||
|
||||
class Liveness(Enum):
|
||||
"""Where a view sits on the live↔snapshot axis (blueprint §8, T18)."""
|
||||
|
||||
STATIC = "static"
|
||||
CAPTURED_SNAPSHOT = "captured-snapshot"
|
||||
LIVE_OVER_FILES = "live-over-files"
|
||||
VIEW_TIME = "view-time"
|
||||
IRREDUCIBLY_LIVE = "irreducibly-live"
|
||||
|
||||
|
||||
class Staleness(Enum):
|
||||
"""Freshness state of cached/projected content (blueprint §8.8). ``UNAVAILABLE`` is the
|
||||
dead-shard state (FederationRequirements ADR-04)."""
|
||||
|
||||
LIVE = "live"
|
||||
FRESH = "fresh"
|
||||
STALE = "stale"
|
||||
UNAVAILABLE = "unavailable"
|
||||
|
||||
|
||||
class OverlayState(Enum):
|
||||
"""Overlay lifecycle state of an artifact (FederationRequirements ADR-05)."""
|
||||
|
||||
NONE = "none"
|
||||
DRAFT = "draft"
|
||||
PATCH_PENDING = "patch-pending"
|
||||
APPLIED = "applied"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProvenanceEnvelope:
|
||||
"""The metadata every page/span carries — union without erasure (INTENT I-4).
|
||||
|
||||
This is the *page-level* (or fully-resolved *effective*) form. Per-span overrides are
|
||||
expressed as a :class:`SpanProvenanceDelta` and composed via :func:`effective`.
|
||||
"""
|
||||
|
||||
source_shard: str
|
||||
liveness: Liveness = Liveness.STATIC
|
||||
staleness: Staleness = Staleness.FRESH
|
||||
overlay_state: OverlayState = OverlayState.NONE
|
||||
source_rev: str | None = None
|
||||
observed_at: datetime | None = None
|
||||
authz_context: str | None = None
|
||||
divergence: tuple[str, ...] = ()
|
||||
lineage: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SpanProvenanceDelta:
|
||||
"""A per-span override of a page envelope. Every field is optional; ``None`` (or, for
|
||||
``divergence``, the sentinel default) means *inherit the page-level value*. A span with an
|
||||
all-default delta — or no delta at all — costs nothing and resolves to the page envelope.
|
||||
"""
|
||||
|
||||
source_shard: str | None = None
|
||||
liveness: Liveness | None = None
|
||||
staleness: Staleness | None = None
|
||||
overlay_state: OverlayState | None = None
|
||||
source_rev: str | None = None
|
||||
observed_at: datetime | None = None
|
||||
authz_context: str | None = None
|
||||
# Sentinel: ``None`` means inherit; an explicit (possibly empty) tuple overrides.
|
||||
divergence: tuple[str, ...] | None = None
|
||||
lineage: str | None = None
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""True when the delta overrides nothing (the common, zero-cost case)."""
|
||||
return all(getattr(self, f.name) is None for f in dataclasses.fields(self))
|
||||
|
||||
|
||||
def effective(
|
||||
base: ProvenanceEnvelope, delta: SpanProvenanceDelta | None = None
|
||||
) -> ProvenanceEnvelope:
|
||||
"""Compose a page envelope with an optional span delta → the span's effective provenance.
|
||||
|
||||
``effective(base, None)`` and ``effective(base, <empty delta>)`` both return ``base``
|
||||
unchanged (zero-cost inheritance). Any non-``None`` delta field overrides ``base``.
|
||||
"""
|
||||
if delta is None or delta.is_empty():
|
||||
return base
|
||||
overrides = {
|
||||
f.name: value
|
||||
for f in dataclasses.fields(delta)
|
||||
if (value := getattr(delta, f.name)) is not None
|
||||
}
|
||||
return dataclasses.replace(base, **overrides)
|
||||
42
src/shard_wiki/space.py
Normal file
42
src/shard_wiki/space.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""InformationSpace — the thin orchestrator entry tying the slice together (blueprint §3 L6).
|
||||
|
||||
A root entity / information space: shards attach to it (conformance-gated, TSD §A.2), a
|
||||
coordination decision log records its canonical decisions, and a derived union resolves names to
|
||||
pages. This is the L6 consumer surface for the foundation slice (attach → resolve → read);
|
||||
a network API is a later workplan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shard_wiki.adapters import ShardAdapter, assert_conformant
|
||||
from shard_wiki.coordination import DecisionLog, EventType
|
||||
from shard_wiki.model import Page
|
||||
from shard_wiki.policy import DEFAULT_POLICY, Policy
|
||||
from shard_wiki.union import Resolution, UnionGraph
|
||||
|
||||
__all__ = ["InformationSpace"]
|
||||
|
||||
|
||||
class InformationSpace:
|
||||
def __init__(self, space_id: str, policy: Policy = DEFAULT_POLICY) -> None:
|
||||
self.space_id = space_id
|
||||
self.log = DecisionLog()
|
||||
self.union = UnionGraph(space_id, log=self.log, policy=policy)
|
||||
|
||||
def attach(self, adapter: ShardAdapter) -> None:
|
||||
"""Attach a shard — only if it passes conformance (verified profile, I-3/§6.6)."""
|
||||
assert_conformant(adapter)
|
||||
self.union.attach(adapter)
|
||||
|
||||
def alias(self, name: str, target: str, actor: str | None = None) -> None:
|
||||
"""Record a coordination-canonical alias (``name`` → ``"shard:key"``) in the log."""
|
||||
self.log.append(
|
||||
self.space_id, EventType.ALIAS_SET, {"alias": name, "target": target}, actor=actor
|
||||
)
|
||||
|
||||
def resolve(self, name: str) -> Resolution:
|
||||
return self.union.resolve(name)
|
||||
|
||||
def read(self, name: str) -> Page:
|
||||
"""Resolve and return the page (or the canonical pick of a chorus). KeyError if red-link."""
|
||||
return self.union.resolve(name).single()
|
||||
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)
|
||||
67
tests/test_conformance.py
Normal file
67
tests/test_conformance.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Tests for the adapter conformance suite (SHARD-WP-0007 T4)."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
import pytest
|
||||
|
||||
from shard_wiki.adapters import (
|
||||
ConformanceError,
|
||||
FolderAdapter,
|
||||
ShardAdapter,
|
||||
assert_conformant,
|
||||
run_conformance,
|
||||
)
|
||||
from shard_wiki.model import CapabilityProfile, Identity, Page
|
||||
from shard_wiki.provenance import ProvenanceEnvelope
|
||||
|
||||
|
||||
def test_folder_adapter_is_conformant(tmp_path):
|
||||
(tmp_path / "Home.md").write_text("# Home", encoding="utf-8")
|
||||
report = assert_conformant(FolderAdapter("shardA", tmp_path))
|
||||
assert report.ok
|
||||
assert report.diff() == "conformant"
|
||||
|
||||
|
||||
class _LyingReadAdapter(ShardAdapter):
|
||||
"""Claims READ but read() is broken — must fail conformance."""
|
||||
|
||||
def __init__(self, profile: CapabilityProfile) -> None:
|
||||
self._profile = profile
|
||||
|
||||
@property
|
||||
def shard_id(self) -> str:
|
||||
return "liar"
|
||||
|
||||
def profile(self) -> CapabilityProfile:
|
||||
return self._profile
|
||||
|
||||
def keys(self) -> Iterable[str]:
|
||||
return ["Home"]
|
||||
|
||||
def read(self, key: str) -> Page:
|
||||
# Returns a page attributed to the WRONG shard — a lie about its own content.
|
||||
return Page(Identity("someone-else", key), "x", ProvenanceEnvelope(source_shard="x"))
|
||||
|
||||
|
||||
def test_lying_read_adapter_fails_with_precise_diff(tmp_path):
|
||||
good = FolderAdapter("shardA", tmp_path).profile() # a valid read-only profile
|
||||
liar = _LyingReadAdapter(good)
|
||||
report = run_conformance(liar)
|
||||
assert not report.ok
|
||||
assert "read-round-trips" in report.diff()
|
||||
with pytest.raises(ConformanceError, match="read-round-trips"):
|
||||
assert_conformant(liar)
|
||||
|
||||
|
||||
class _DishonestWriteAdapter(FolderAdapter):
|
||||
"""Declares no WRITE (inherits read-only folder profile) but write() silently succeeds."""
|
||||
|
||||
def write(self, key: str, body: str) -> Page: # noqa: ARG002
|
||||
return self.read(next(iter(self.keys())))
|
||||
|
||||
|
||||
def test_dishonest_absence_fails(tmp_path):
|
||||
(tmp_path / "Home.md").write_text("x", encoding="utf-8")
|
||||
report = run_conformance(_DishonestWriteAdapter("shardA", tmp_path))
|
||||
assert not report.ok
|
||||
assert "honest-absence:write" in report.diff()
|
||||
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"})
|
||||
44
tests/test_folder_adapter.py
Normal file
44
tests/test_folder_adapter.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Tests for the FolderAdapter (SHARD-WP-0007 T3)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shard_wiki.adapters import FolderAdapter
|
||||
from shard_wiki.model import Identity, NotSupported, Verb
|
||||
|
||||
|
||||
def _make_shard(tmp_path, files: dict[str, str]) -> FolderAdapter:
|
||||
for rel, text in files.items():
|
||||
p = tmp_path / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(text, encoding="utf-8")
|
||||
return FolderAdapter("shardA", tmp_path)
|
||||
|
||||
|
||||
def test_keys_and_read(tmp_path):
|
||||
shard = _make_shard(tmp_path, {"Home.md": "# Home", "sub/Topic.md": "topic body"})
|
||||
assert set(shard.keys()) == {"Home", "sub/Topic"}
|
||||
page = shard.read("sub/Topic")
|
||||
assert page.identity == Identity("shardA", "sub/Topic")
|
||||
assert page.body == "topic body"
|
||||
assert page.envelope.source_shard == "shardA"
|
||||
assert page.envelope.source_rev is not None # mtime stamp
|
||||
assert page.placements[0].path == "sub/Topic.md"
|
||||
|
||||
|
||||
def test_read_missing_raises_keyerror(tmp_path):
|
||||
shard = _make_shard(tmp_path, {"Home.md": "x"})
|
||||
with pytest.raises(KeyError):
|
||||
shard.read("Nope")
|
||||
|
||||
|
||||
def test_profile_is_valid_and_read_only(tmp_path):
|
||||
shard = _make_shard(tmp_path, {"Home.md": "x"})
|
||||
prof = shard.profile() # .validate() already called inside
|
||||
assert prof.supports(Verb.READ)
|
||||
assert not prof.supports(Verb.WRITE)
|
||||
|
||||
|
||||
def test_unsupported_write_is_honest(tmp_path):
|
||||
shard = _make_shard(tmp_path, {"Home.md": "x"})
|
||||
with pytest.raises(NotSupported):
|
||||
shard.write("Home", "new")
|
||||
80
tests/test_integration.py
Normal file
80
tests/test_integration.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""End-to-end slice test (SHARD-WP-0007 T7): attach folder shards → resolve → read union."""
|
||||
|
||||
import shard_wiki
|
||||
from shard_wiki import InformationSpace
|
||||
from shard_wiki.adapters import ConformanceError, FolderAdapter, ShardAdapter
|
||||
from shard_wiki.model import CapabilityProfile, Identity, Page
|
||||
from shard_wiki.provenance import ProvenanceEnvelope, Staleness
|
||||
from shard_wiki.union import ResolutionKind
|
||||
|
||||
|
||||
def _folder(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_public_api_surface():
|
||||
assert isinstance(shard_wiki.__version__, str) and shard_wiki.__version__
|
||||
assert shard_wiki.InformationSpace is InformationSpace
|
||||
|
||||
|
||||
def test_attach_resolve_read_union_with_provenance_and_chorus(tmp_path):
|
||||
space = InformationSpace("team")
|
||||
space.attach(_folder(tmp_path, "wikiA", {"Home.md": "A home", "OnlyA.md": "a"}))
|
||||
space.attach(_folder(tmp_path, "wikiB", {"Home.md": "B home", "OnlyB.md": "b"}))
|
||||
|
||||
# unique page → single, with provenance attributing the source shard
|
||||
only_a = space.read("OnlyA")
|
||||
assert only_a.body == "a"
|
||||
assert only_a.envelope.source_shard == "wikiA"
|
||||
assert only_a.envelope.staleness is Staleness.FRESH
|
||||
assert only_a.envelope.observed_at is not None
|
||||
|
||||
# same name in both shards → chorus, divergence recorded both ways (union without erasure)
|
||||
res = space.resolve("Home")
|
||||
assert res.kind is ResolutionKind.CHORUS
|
||||
assert {p.envelope.source_shard for p in res.pages} == {"wikiA", "wikiB"}
|
||||
for p in res.pages:
|
||||
assert len(p.envelope.divergence) == 1
|
||||
|
||||
# missing → red-link
|
||||
assert space.resolve("Ghost").is_red_link
|
||||
|
||||
|
||||
def test_alias_redirects_across_the_union(tmp_path):
|
||||
space = InformationSpace("team")
|
||||
space.attach(_folder(tmp_path, "wikiA", {"Index.md": "the index"}))
|
||||
space.alias("Start", "wikiA:Index")
|
||||
assert space.read("Start").body == "the index"
|
||||
|
||||
|
||||
class _LyingShard(ShardAdapter):
|
||||
def __init__(self, profile: CapabilityProfile) -> None:
|
||||
self._p = profile
|
||||
|
||||
@property
|
||||
def shard_id(self) -> str:
|
||||
return "liar"
|
||||
|
||||
def profile(self) -> CapabilityProfile:
|
||||
return self._p
|
||||
|
||||
def keys(self):
|
||||
return ["X"]
|
||||
|
||||
def read(self, key: str) -> Page:
|
||||
return Page(Identity("not-liar", key), "x", ProvenanceEnvelope(source_shard="x"))
|
||||
|
||||
|
||||
def test_attach_rejects_nonconformant_shard(tmp_path):
|
||||
good_profile = _folder(tmp_path, "wikiA", {"Home.md": "x"}).profile()
|
||||
space = InformationSpace("team")
|
||||
try:
|
||||
space.attach(_LyingShard(good_profile))
|
||||
raise AssertionError("expected ConformanceError")
|
||||
except ConformanceError:
|
||||
pass
|
||||
106
tests/test_model.py
Normal file
106
tests/test_model.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Tests for the page model + capability profile (SHARD-WP-0007 T2)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shard_wiki.model import (
|
||||
AccessGrant,
|
||||
Addressing,
|
||||
AttachmentMode,
|
||||
CapabilityProfile,
|
||||
ContentOpacity,
|
||||
History,
|
||||
Identity,
|
||||
MergeModel,
|
||||
NativeQuery,
|
||||
OperationalEnvelope,
|
||||
Page,
|
||||
PageShape,
|
||||
ProfileError,
|
||||
Substrate,
|
||||
Translation,
|
||||
Verb,
|
||||
WriteGranularity,
|
||||
)
|
||||
from shard_wiki.provenance import Liveness, ProvenanceEnvelope
|
||||
|
||||
|
||||
def _read_only_folder_profile(**over) -> CapabilityProfile:
|
||||
base = dict(
|
||||
substrate=Substrate.FILES,
|
||||
attachment_mode=AttachmentMode.FILE_STORE,
|
||||
write_granularity=WriteGranularity.NONE,
|
||||
content_opacity=ContentOpacity.TRANSPARENT,
|
||||
operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED,
|
||||
access_grant=AccessGrant.OPEN,
|
||||
liveness=Liveness.STATIC,
|
||||
history=History.NONE,
|
||||
merge_model=MergeModel.NONE,
|
||||
addressing=Addressing.PATH,
|
||||
native_query=NativeQuery.NONE,
|
||||
translation=Translation.NATIVE,
|
||||
supported_verbs=frozenset({Verb.READ}),
|
||||
)
|
||||
base.update(over)
|
||||
return CapabilityProfile(**base)
|
||||
|
||||
|
||||
def test_identity_is_stable_across_content_and_value_equal():
|
||||
a = Identity("shardA", "Home")
|
||||
b = Identity("shardA", "Home")
|
||||
assert a == b and hash(a) == hash(b)
|
||||
assert str(a) == "shardA:Home"
|
||||
# Identity does not depend on content: a Page edit reuses the same identity.
|
||||
env = ProvenanceEnvelope(source_shard="shardA")
|
||||
p1 = Page(a, "first body", env)
|
||||
p2 = Page(a, "edited body", env)
|
||||
assert p1.identity == p2.identity # stable handle, not a fingerprint
|
||||
|
||||
|
||||
def test_read_only_folder_profile_validates():
|
||||
assert _read_only_folder_profile().validate().supports(Verb.READ)
|
||||
|
||||
|
||||
def test_missing_read_is_rejected():
|
||||
with pytest.raises(ProfileError, match="READ"):
|
||||
_read_only_folder_profile(supported_verbs=frozenset()).validate()
|
||||
|
||||
|
||||
def test_git_is_store_requires_git_substrate_and_history():
|
||||
with pytest.raises(ProfileError, match="git-is-store"):
|
||||
_read_only_folder_profile(attachment_mode=AttachmentMode.GIT_IS_STORE).validate()
|
||||
# consistent git-is-store profile validates
|
||||
_read_only_folder_profile(
|
||||
attachment_mode=AttachmentMode.GIT_IS_STORE,
|
||||
substrate=Substrate.GIT,
|
||||
history=History.GIT_NATIVE,
|
||||
).validate()
|
||||
|
||||
|
||||
def test_encrypted_forbids_native_query():
|
||||
with pytest.raises(ProfileError, match="encrypted"):
|
||||
_read_only_folder_profile(
|
||||
content_opacity=ContentOpacity.ENCRYPTED, native_query=NativeQuery.DB_QUERY
|
||||
).validate()
|
||||
|
||||
|
||||
def test_write_verb_requires_write_granularity():
|
||||
with pytest.raises(ProfileError, match="read-only consistency"):
|
||||
_read_only_folder_profile(supported_verbs=frozenset({Verb.READ, Verb.WRITE})).validate()
|
||||
# writable profile is consistent
|
||||
_read_only_folder_profile(
|
||||
write_granularity=WriteGranularity.PER_PAGE,
|
||||
supported_verbs=frozenset({Verb.READ, Verb.WRITE}),
|
||||
).validate()
|
||||
|
||||
|
||||
def test_native_crdt_merge_requires_crdt_log_and_realtime():
|
||||
with pytest.raises(ProfileError, match="native-crdt"):
|
||||
_read_only_folder_profile(
|
||||
merge_model=MergeModel.NATIVE_CRDT,
|
||||
supported_verbs=frozenset({Verb.READ, Verb.MERGE}),
|
||||
).validate()
|
||||
|
||||
|
||||
def test_page_defaults_to_prose():
|
||||
p = Page(Identity("s", "k"), "x", ProvenanceEnvelope(source_shard="s"))
|
||||
assert p.shape is PageShape.PROSE
|
||||
73
tests/test_provenance.py
Normal file
73
tests/test_provenance.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Tests for the provenance leaf rail (SHARD-WP-0007 T1)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shard_wiki.provenance import (
|
||||
Liveness,
|
||||
OverlayState,
|
||||
ProvenanceEnvelope,
|
||||
SpanProvenanceDelta,
|
||||
Staleness,
|
||||
effective,
|
||||
)
|
||||
|
||||
|
||||
def _base() -> ProvenanceEnvelope:
|
||||
return ProvenanceEnvelope(
|
||||
source_shard="shardA",
|
||||
liveness=Liveness.STATIC,
|
||||
staleness=Staleness.FRESH,
|
||||
source_rev="r1",
|
||||
observed_at=datetime(2026, 6, 15, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def test_no_delta_returns_base_identity():
|
||||
base = _base()
|
||||
assert effective(base) is base
|
||||
assert effective(base, SpanProvenanceDelta()) is base
|
||||
|
||||
|
||||
def test_empty_delta_is_detected():
|
||||
assert SpanProvenanceDelta().is_empty()
|
||||
assert not SpanProvenanceDelta(source_shard="other").is_empty()
|
||||
|
||||
|
||||
def test_single_field_override():
|
||||
base = _base()
|
||||
eff = effective(base, SpanProvenanceDelta(source_shard="shardB"))
|
||||
assert eff.source_shard == "shardB"
|
||||
# Untouched fields inherit from the page envelope.
|
||||
assert eff.source_rev == "r1"
|
||||
assert eff.liveness is Liveness.STATIC
|
||||
# Base is unchanged (frozen, pure composition).
|
||||
assert base.source_shard == "shardA"
|
||||
|
||||
|
||||
def test_partial_delta_overrides_several_fields():
|
||||
base = _base()
|
||||
eff = effective(
|
||||
base,
|
||||
SpanProvenanceDelta(
|
||||
staleness=Staleness.STALE,
|
||||
overlay_state=OverlayState.DRAFT,
|
||||
divergence=("shardB:Home",),
|
||||
),
|
||||
)
|
||||
assert eff.staleness is Staleness.STALE
|
||||
assert eff.overlay_state is OverlayState.DRAFT
|
||||
assert eff.divergence == ("shardB:Home",)
|
||||
assert eff.source_shard == "shardA" # inherited
|
||||
|
||||
|
||||
def test_divergence_empty_tuple_overrides_but_none_inherits():
|
||||
base = ProvenanceEnvelope(source_shard="s", divergence=("x",))
|
||||
# None → inherit the page's divergence.
|
||||
assert effective(base, SpanProvenanceDelta(divergence=None)).divergence == ("x",)
|
||||
# Explicit empty tuple → override (clear it).
|
||||
assert effective(base, SpanProvenanceDelta(divergence=())).divergence == ()
|
||||
|
||||
|
||||
def test_envelope_is_hashable_and_value_equal():
|
||||
assert _base() == _base()
|
||||
assert len({_base(), _base()}) == 1
|
||||
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
|
||||
@@ -4,13 +4,14 @@ type: workplan
|
||||
title: "foundation implementation — model, contract, decision log, union read"
|
||||
domain: whynot
|
||||
repo: shard-wiki
|
||||
status: active
|
||||
status: done
|
||||
owner: tegwick
|
||||
topic_slug: whynot
|
||||
created: "2026-06-15"
|
||||
updated: "2026-06-15"
|
||||
depends_on:
|
||||
- SHARD-WP-0002
|
||||
state_hub_workstream_id: "d551fba1-9c48-4841-a9a5-a9f190f73a60"
|
||||
---
|
||||
|
||||
# SHARD-WP-0007 — Foundation implementation
|
||||
@@ -41,8 +42,9 @@ projection, authz beyond the null provider, a network API. Those are later workp
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0007-T1
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "be8f9efe-cfba-46d0-be91-f9b6e87bc0d2"
|
||||
```
|
||||
|
||||
`src/shard_wiki/provenance/`: the `ProvenanceEnvelope` value type (source_shard, source_rev?,
|
||||
@@ -54,8 +56,9 @@ delta). Frozen dataclasses, no tree deps. Tests: ⊕ identity (no delta), overri
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0007-T2
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "780ad01f-c3e1-4b49-9ae9-60e0324178a7"
|
||||
```
|
||||
|
||||
`src/shard_wiki/model/`: `Identity` (shard-scoped stable handle, value-equal, survives edits),
|
||||
@@ -68,8 +71,9 @@ identity stability vs content change; profile validation accepts/rejects.
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0007-T3
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f6e35ddb-ab1e-406a-82f8-563244455f6b"
|
||||
```
|
||||
|
||||
`src/shard_wiki/adapters/`: `AdapterContract` (a `Protocol`/ABC with the operation verbs;
|
||||
@@ -82,8 +86,9 @@ git-or-none history). Imports `model/`, `provenance/`. Tests: read a temp folder
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0007-T4
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "12d7fb8d-3842-4142-a462-9d1e6efe58bd"
|
||||
```
|
||||
|
||||
`src/shard_wiki/adapters/conformance.py`: a battery that, given a binding, **verifies declared
|
||||
@@ -96,8 +101,9 @@ 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"
|
||||
```
|
||||
|
||||
`src/shard_wiki/coordination/`: `DecisionLog` — append-only, **totally ordered per space**
|
||||
@@ -110,8 +116,9 @@ append→ordered read; fold reproduces current state; read-your-writes.
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0007-T6
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "fed38b60-dc0b-40cf-93e9-ab0260aa3ff9"
|
||||
```
|
||||
|
||||
`src/shard_wiki/policy/` (leaf: presets + `resolve()` for canonical-source = chorus default);
|
||||
@@ -124,8 +131,9 @@ the same name → chorus; alias from the log redirects resolution.
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0007-T7
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "86c0b255-e373-460d-859d-a39bf6ea4ffb"
|
||||
```
|
||||
|
||||
A thin `shard_wiki` entry (attach shard, resolve, read) tying the slice together; an
|
||||
|
||||
Reference in New Issue
Block a user