pi_star: land ticket #000015 (π* domain library + composition algebra)
New arborist.pi_star/ namespace centralizes canonical projections under a name@version registry. Two existing canonicalizers re-homed as registered π*'s: - wikitext-base@v1 wraps arborist.wikitext.to_base - claim-lattice@v1 wraps arborist.qa.parse_claims.parse_pointer_claims Four stubs registered for follow-up modality tickets: code-py-ast@v1, logic-kernel@v1, time-series-quantized@v1, tabular-pinned@v1 — each raises NotImplementedError with a pointer to ticket #000015. Composition algebra in compose.py: PiStarComposition exposes outer ∘ inner as a first-class π* with its own registry key (default "<inner-name>-then-<outer-name>@v1"). canonical_composition_id returns a SHA-256 fingerprint suitable for governance hash inclusion. Order-sensitive: a∘b ≠ b∘a → different fingerprints. Documentation: docs/pi-star-composition.md covers the rule (type- compatible, deterministic, equivalence-class preserving), lossy vs invertible compositions, worked text→claim-lattice example, cross-domain anchor projections (future), authoring checklist. Re-home is non-breaking: arborist.wikitext.to_base remains importable. Tests: tests/test_pi_star.py (19 cases). Full suite: 1059 passed, 36 skipped.
This commit is contained in:
parent
3d8f8fbd47
commit
40d106fb2f
14 changed files with 910 additions and 10 deletions
66
arborist/pi_star/__init__.py
Normal file
66
arborist/pi_star/__init__.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""π* domain library — canonical projections registered by name@version.
|
||||
|
||||
Implements ticket #000015. Centralizes arborist's canonicalizers
|
||||
behind a unified protocol so:
|
||||
|
||||
- New domain π*'s register against a single namespace.
|
||||
- Composition (``π*_a ∘ π*_b``) is itself a registered π*.
|
||||
- Versioning is mechanical: ``name@version`` is the registry key;
|
||||
changing a π* means a new key.
|
||||
|
||||
Existing canonicalizers re-homed in this ticket:
|
||||
|
||||
- ``wikitext-base@v1`` — wraps :func:`arborist.wikitext.to_base`.
|
||||
- ``claim-lattice@v1`` — wraps
|
||||
:func:`arborist.qa.parse_claims.parse_pointer_claims`.
|
||||
|
||||
Stub modalities (raise :class:`NotImplementedError`; their own
|
||||
implementation tickets land them):
|
||||
|
||||
- ``code-py-ast@v1``
|
||||
- ``logic-kernel@v1``
|
||||
- ``time-series-quantized@v1``
|
||||
- ``tabular-pinned@v1``
|
||||
|
||||
Composition theory: see ``docs/pi-star-composition.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from arborist.pi_star.compose import (
|
||||
PiStarComposition,
|
||||
canonical_composition_id,
|
||||
compose,
|
||||
)
|
||||
from arborist.pi_star.protocol import PiStar, PiStarError
|
||||
from arborist.pi_star.registry import (
|
||||
REGISTRY,
|
||||
domains,
|
||||
get,
|
||||
list_keys,
|
||||
register,
|
||||
)
|
||||
|
||||
|
||||
# Side-effect imports populate REGISTRY at package load time.
|
||||
# Order is alphabetical for predictability.
|
||||
from arborist.pi_star import claim_lattice # noqa: F401,E402
|
||||
from arborist.pi_star import code # noqa: F401,E402
|
||||
from arborist.pi_star import logic # noqa: F401,E402
|
||||
from arborist.pi_star import tabular # noqa: F401,E402
|
||||
from arborist.pi_star import text # noqa: F401,E402
|
||||
from arborist.pi_star import time_series # noqa: F401,E402
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PiStar",
|
||||
"PiStarError",
|
||||
"PiStarComposition",
|
||||
"REGISTRY",
|
||||
"canonical_composition_id",
|
||||
"compose",
|
||||
"domains",
|
||||
"get",
|
||||
"list_keys",
|
||||
"register",
|
||||
]
|
||||
63
arborist/pi_star/claim_lattice.py
Normal file
63
arborist/pi_star/claim_lattice.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""``claim-lattice@v1`` π*: wraps
|
||||
:func:`arborist.qa.parse_claims.parse_pointer_claims`.
|
||||
|
||||
Domain: ``text``. Input UTF-8 bytes are parsed into a list of
|
||||
:class:`ParsedClaim` objects; the canonical projection is the
|
||||
canonical-JSON serialization of the parsed list (sorted keys, no
|
||||
whitespace).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from arborist.pi_star.protocol import PiStarError
|
||||
from arborist.pi_star.registry import register
|
||||
|
||||
|
||||
def _parsed_claim_to_dict(claim) -> dict:
|
||||
"""Convert a ParsedClaim dataclass to a sorted-key-friendly dict.
|
||||
|
||||
ParsedClaim shape (see ``arborist.qa.parse_claims``):
|
||||
``claim_text``, ``pointer_ids``, ``parse_status``, ``raw_line``.
|
||||
"""
|
||||
return {
|
||||
"claim_text": claim.claim_text,
|
||||
"pointer_ids": list(claim.pointer_ids),
|
||||
"parse_status": claim.parse_status,
|
||||
"raw_line": claim.raw_line,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClaimLatticeV1:
|
||||
name: str = "claim-lattice"
|
||||
version: str = "v1"
|
||||
domain: str = "text"
|
||||
|
||||
def canonicalize(self, raw: bytes) -> bytes:
|
||||
if not isinstance(raw, (bytes, bytearray)):
|
||||
raise PiStarError(
|
||||
"claim-lattice@v1 expects bytes; got "
|
||||
f"{type(raw).__name__}"
|
||||
)
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="surrogatepass")
|
||||
except UnicodeDecodeError as exc: # pragma: no cover
|
||||
raise PiStarError(f"input not valid UTF-8: {exc}") from exc
|
||||
|
||||
from arborist.qa.parse_claims import parse_pointer_claims
|
||||
|
||||
parsed = parse_pointer_claims(text)
|
||||
canon = [_parsed_claim_to_dict(c) for c in parsed]
|
||||
encoded = json.dumps(
|
||||
canon,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return encoded.encode("utf-8", errors="surrogatepass")
|
||||
|
||||
|
||||
register(ClaimLatticeV1())
|
||||
33
arborist/pi_star/code.py
Normal file
33
arborist/pi_star/code.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""``code-py-ast@v1`` π* (stub).
|
||||
|
||||
Domain: ``code``. Implementation lands in a follow-up ticket; this
|
||||
stub registers the namespace and raises :class:`NotImplementedError`
|
||||
on canonicalize.
|
||||
|
||||
Planned semantics: parse Python source → AST → canonical S-expression
|
||||
serialization (sorted keyword args, normalized literals, stripped
|
||||
comments). Equivalent code → identical canonical bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arborist.pi_star.protocol import PiStarError
|
||||
from arborist.pi_star.registry import register
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodePyAstV1:
|
||||
name: str = "code-py-ast"
|
||||
version: str = "v1"
|
||||
domain: str = "code"
|
||||
|
||||
def canonicalize(self, raw: bytes) -> bytes:
|
||||
raise NotImplementedError(
|
||||
"code-py-ast@v1 is a stub; implementation ticket pending. "
|
||||
"See docs/tickets/ticket-000015-pi-star-domain-library.md."
|
||||
)
|
||||
|
||||
|
||||
register(CodePyAstV1())
|
||||
104
arborist/pi_star/compose.py
Normal file
104
arborist/pi_star/compose.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Composition algebra for π*.
|
||||
|
||||
Per ticket #000015 §2.3, a composition ``π*_a ∘ π*_b`` (apply π*_b
|
||||
first, then π*_a) is a valid canonical projection iff:
|
||||
|
||||
1. **Type-compatible** — ``π*_b.codomain`` ⊆ ``π*_a.domain``.
|
||||
2. **Determinism preserved** — both factors are deterministic ⇒ chain
|
||||
is deterministic.
|
||||
3. **Equivalence-class-preserving** — function composition trivially
|
||||
preserves classes.
|
||||
|
||||
Each composition is itself a registered π* with key
|
||||
``"<inner>-then-<outer>@<version>"`` so callers see compositions as
|
||||
first-class projections, not implementation details.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from arborist.pi_star.protocol import PiStar, PiStarError, registry_key
|
||||
from arborist.pi_star.registry import REGISTRY, register
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PiStarComposition:
|
||||
"""A composition ``outer ∘ inner`` exposed as a π*.
|
||||
|
||||
``canonicalize(raw)`` runs ``inner.canonicalize`` first, then
|
||||
``outer.canonicalize`` on the result. Domain is the inner π*'s
|
||||
domain (we accept what the first stage accepts); codomain is the
|
||||
outer π*'s codomain.
|
||||
"""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
inner_key: str
|
||||
outer_key: str
|
||||
inner: PiStar
|
||||
outer: PiStar
|
||||
|
||||
@property
|
||||
def domain(self) -> str:
|
||||
return self.inner.domain
|
||||
|
||||
def canonicalize(self, raw: bytes) -> bytes:
|
||||
try:
|
||||
mid = self.inner.canonicalize(raw)
|
||||
except PiStarError:
|
||||
raise
|
||||
return self.outer.canonicalize(mid)
|
||||
|
||||
|
||||
def canonical_composition_id(inner_key: str, outer_key: str) -> str:
|
||||
"""Stable SHA-256 fingerprint for a composition manifest.
|
||||
|
||||
The composition's identity is the canonical-JSON of its (inner,
|
||||
outer) keys. Useful for governance hash inclusion when a policy
|
||||
pins which composition is in effect.
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{"chain": [inner_key, outer_key]},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def compose(
|
||||
inner_key: str,
|
||||
outer_key: str,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
version: str = "v1",
|
||||
register_in_registry: bool = True,
|
||||
) -> PiStarComposition:
|
||||
"""Build (and optionally register) a composition.
|
||||
|
||||
Default name is ``"<inner-name>-then-<outer-name>"`` so the
|
||||
canonical example renders as ``"wikitext-base-then-claim-lattice"``.
|
||||
"""
|
||||
if inner_key not in REGISTRY:
|
||||
raise KeyError(f"unknown inner π*: {inner_key}")
|
||||
if outer_key not in REGISTRY:
|
||||
raise KeyError(f"unknown outer π*: {outer_key}")
|
||||
inner = REGISTRY[inner_key]
|
||||
outer = REGISTRY[outer_key]
|
||||
derived_name = name or f"{inner.name}-then-{outer.name}"
|
||||
|
||||
composition = PiStarComposition(
|
||||
name=derived_name,
|
||||
version=version,
|
||||
inner_key=inner_key,
|
||||
outer_key=outer_key,
|
||||
inner=inner,
|
||||
outer=outer,
|
||||
)
|
||||
if register_in_registry:
|
||||
register(composition)
|
||||
return composition
|
||||
29
arborist/pi_star/logic.py
Normal file
29
arborist/pi_star/logic.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""``logic-kernel@v1`` π* (stub).
|
||||
|
||||
Domain: ``logic``. Implementation lands in a follow-up. Planned
|
||||
semantics: parse a math/proof string → kernel proof object →
|
||||
canonical serialization. Equivalent proofs → identical canonical bytes.
|
||||
Aligns with v7 §14.3 proof-object π*.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arborist.pi_star.registry import register
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogicKernelV1:
|
||||
name: str = "logic-kernel"
|
||||
version: str = "v1"
|
||||
domain: str = "logic"
|
||||
|
||||
def canonicalize(self, raw: bytes) -> bytes:
|
||||
raise NotImplementedError(
|
||||
"logic-kernel@v1 is a stub; implementation ticket pending. "
|
||||
"See docs/tickets/ticket-000015-pi-star-domain-library.md."
|
||||
)
|
||||
|
||||
|
||||
register(LogicKernelV1())
|
||||
63
arborist/pi_star/protocol.py
Normal file
63
arborist/pi_star/protocol.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""π* protocol: the contract every canonicalizer satisfies.
|
||||
|
||||
Per ticket #000015 §2.2:
|
||||
|
||||
- ``name`` + ``version`` form the registry key (``name@version``).
|
||||
- ``domain`` declares the input bucket (``text``, ``code``, ``logic``,
|
||||
``time-series``, ``tabular``, ``world``, ``claim-lattice``, ...).
|
||||
- ``canonicalize(raw)`` produces canonical bytes; raises
|
||||
:class:`PiStarError` when ``raw`` is outside the projection's domain.
|
||||
- ``equivalence_class_id(raw)`` returns SHA-256 over
|
||||
``canonicalize(raw)`` — the equivalence-class label.
|
||||
|
||||
Determinism: every π* MUST satisfy ``canonicalize(canonicalize(x))
|
||||
== canonicalize(x)`` (round-trip stability). The
|
||||
:func:`assert_round_trip` helper exercises this on test inputs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
class PiStarError(Exception):
|
||||
"""Raised when a π* is asked to canonicalize bytes outside its domain."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PiStar(Protocol):
|
||||
"""The π* contract.
|
||||
|
||||
Implementations declare ``name``, ``version``, ``domain`` as
|
||||
class-level attributes (or instance attributes). ``canonicalize``
|
||||
is the single required method.
|
||||
"""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
domain: str
|
||||
|
||||
def canonicalize(self, raw: bytes) -> bytes:
|
||||
"""Return canonical bytes; raise PiStarError on undefined input."""
|
||||
...
|
||||
|
||||
|
||||
def equivalence_class_id(pi_star: PiStar, raw: bytes) -> str:
|
||||
"""SHA-256 over ``canonicalize(raw)``."""
|
||||
return hashlib.sha256(pi_star.canonicalize(raw)).hexdigest()
|
||||
|
||||
|
||||
def assert_round_trip(pi_star: PiStar, raw: bytes) -> None:
|
||||
"""Raise AssertionError if ``canonicalize`` is not idempotent on ``raw``."""
|
||||
once = pi_star.canonicalize(raw)
|
||||
twice = pi_star.canonicalize(once)
|
||||
if once != twice:
|
||||
raise AssertionError(
|
||||
f"π* {pi_star.name}@{pi_star.version} not idempotent on input"
|
||||
)
|
||||
|
||||
|
||||
def registry_key(pi_star: PiStar) -> str:
|
||||
"""Stable ``name@version`` key used by :mod:`registry`."""
|
||||
return f"{pi_star.name}@{pi_star.version}"
|
||||
60
arborist/pi_star/registry.py
Normal file
60
arborist/pi_star/registry.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""π* registry: ``name@version`` → :class:`PiStar` instance.
|
||||
|
||||
Mutation discipline: registry is populated at module-import time via
|
||||
:func:`register`. Tests that need to modify the registry should
|
||||
import + register their fakes before any cache_key derivation runs.
|
||||
|
||||
There is no public ``unregister`` — once a name@version exists in
|
||||
the runtime, every cache_key that hashed it stays valid against it.
|
||||
Removing a key is equivalent to changing the cache_key invariant
|
||||
silently; we forbid it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator
|
||||
|
||||
from arborist.pi_star.protocol import PiStar, PiStarError, registry_key
|
||||
|
||||
|
||||
REGISTRY: dict[str, PiStar] = {}
|
||||
|
||||
|
||||
def register(pi_star: PiStar) -> None:
|
||||
"""Insert a π* into the registry. Re-registration of the same key
|
||||
with a different instance is rejected — keys are content-pinned.
|
||||
"""
|
||||
key = registry_key(pi_star)
|
||||
if key in REGISTRY and REGISTRY[key] is not pi_star:
|
||||
raise PiStarError(
|
||||
f"π* registry key already bound: {key} "
|
||||
f"(existing={type(REGISTRY[key]).__name__}, "
|
||||
f"incoming={type(pi_star).__name__})"
|
||||
)
|
||||
REGISTRY[key] = pi_star
|
||||
|
||||
|
||||
def get(key: str) -> PiStar:
|
||||
"""Look up a π* by ``name@version`` key. Raises ``KeyError`` if missing."""
|
||||
if key not in REGISTRY:
|
||||
raise KeyError(f"unknown π*: {key}")
|
||||
return REGISTRY[key]
|
||||
|
||||
|
||||
def list_keys() -> list[str]:
|
||||
"""All registered ``name@version`` keys, sorted."""
|
||||
return sorted(REGISTRY)
|
||||
|
||||
|
||||
def domains() -> dict[str, list[str]]:
|
||||
"""Map domain → sorted list of registered keys for that domain."""
|
||||
out: dict[str, list[str]] = {}
|
||||
for key, ps in REGISTRY.items():
|
||||
out.setdefault(ps.domain, []).append(key)
|
||||
for d in out:
|
||||
out[d] = sorted(out[d])
|
||||
return out
|
||||
|
||||
|
||||
def __iter__() -> Iterator[str]: # pragma: no cover — convenience
|
||||
return iter(REGISTRY)
|
||||
27
arborist/pi_star/tabular.py
Normal file
27
arborist/pi_star/tabular.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""``tabular-pinned@v1`` π* (stub).
|
||||
|
||||
Domain: ``tabular``. Planned semantics: declared schema (column order
|
||||
+ types) + canonical row encoding (sorted by primary key, type-pinned
|
||||
cells, normalized text).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arborist.pi_star.registry import register
|
||||
|
||||
|
||||
@dataclass
|
||||
class TabularPinnedV1:
|
||||
name: str = "tabular-pinned"
|
||||
version: str = "v1"
|
||||
domain: str = "tabular"
|
||||
|
||||
def canonicalize(self, raw: bytes) -> bytes:
|
||||
raise NotImplementedError(
|
||||
"tabular-pinned@v1 is a stub; implementation ticket pending."
|
||||
)
|
||||
|
||||
|
||||
register(TabularPinnedV1())
|
||||
42
arborist/pi_star/text.py
Normal file
42
arborist/pi_star/text.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""``wikitext-base@v1`` π*: wraps :func:`arborist.wikitext.to_base`.
|
||||
|
||||
Domain: ``text-or-wikitext``. Input bytes are interpreted as UTF-8;
|
||||
output bytes are UTF-8 of the canonical prose. ``to_base`` is the
|
||||
existing wikitext-to-prose canonicalizer; this module is the registry-
|
||||
facing wrapper. Imports lazily so the optional ``mwparserfromhell``
|
||||
dep stays optional.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arborist.pi_star.protocol import PiStarError
|
||||
from arborist.pi_star.registry import register
|
||||
|
||||
|
||||
@dataclass
|
||||
class WikitextBaseV1:
|
||||
name: str = "wikitext-base"
|
||||
version: str = "v1"
|
||||
domain: str = "text-or-wikitext"
|
||||
|
||||
def canonicalize(self, raw: bytes) -> bytes:
|
||||
if not isinstance(raw, (bytes, bytearray)):
|
||||
raise PiStarError(
|
||||
"wikitext-base@v1 expects bytes; got "
|
||||
f"{type(raw).__name__}"
|
||||
)
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="surrogatepass")
|
||||
except UnicodeDecodeError as exc: # pragma: no cover — defensive
|
||||
raise PiStarError(f"input not valid UTF-8: {exc}") from exc
|
||||
|
||||
# Lazy import — mwparserfromhell is an optional install.
|
||||
from arborist.wikitext import to_base
|
||||
|
||||
prose = to_base(text)
|
||||
return prose.encode("utf-8", errors="surrogatepass")
|
||||
|
||||
|
||||
register(WikitextBaseV1())
|
||||
27
arborist/pi_star/time_series.py
Normal file
27
arborist/pi_star/time_series.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""``time-series-quantized@v1`` π* (stub).
|
||||
|
||||
Domain: ``time-series``. Planned semantics: resample at a substrate-
|
||||
declared rate, quantize per public Δ_t / Δ_y, encode as committed
|
||||
integer vector with header (rate, Δ, length).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arborist.pi_star.registry import register
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimeSeriesQuantizedV1:
|
||||
name: str = "time-series-quantized"
|
||||
version: str = "v1"
|
||||
domain: str = "time-series"
|
||||
|
||||
def canonicalize(self, raw: bytes) -> bytes:
|
||||
raise NotImplementedError(
|
||||
"time-series-quantized@v1 is a stub; implementation ticket pending."
|
||||
)
|
||||
|
||||
|
||||
register(TimeSeriesQuantizedV1())
|
||||
|
|
@ -64,7 +64,7 @@ Newest first. Update on every open/close.
|
|||
| #000018 | Adversarial soft-hash covert-channel analysis | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
| #000017 | Memory-root: lifelong learning audit chain | closed · landed 2026-05-07 | 2026-05-07 | — |
|
||||
| #000016 | ZK Phase-2 frontier proof (concretize) | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
| #000015 | π* domain library + cross-domain composition | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
| #000015 | π* domain library + cross-domain composition | closed · landed 2026-05-07 | 2026-05-07 | — |
|
||||
| #000014 | SelfModel: schema, falsification, integration | closed · landed 2026-05-07 | 2026-05-07 | — |
|
||||
| #000013 | Spatial-temporal substrate (Merkle-AGI v7-W) | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
| #000012 | Selection & consensus protocol (Merkle-AGI v8) | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
|
|
|
|||
162
docs/pi-star-composition.md
Normal file
162
docs/pi-star-composition.md
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
# π* composition algebra
|
||||
|
||||
Reference doc for ticket #000015. Defines when two canonical
|
||||
projections compose to a valid canonical projection, and how
|
||||
arborist's runtime registers compositions as first-class π*'s.
|
||||
|
||||
## 1. Why composition matters
|
||||
|
||||
A canonical projection π*: `D → C` collapses domain elements that
|
||||
should be treated as "equivalent" by some downstream verifier into
|
||||
a single canonical-bytes representative. arborist's QA layer relies
|
||||
on at least two π*'s already:
|
||||
|
||||
- `wikitext-base@v1` — wikitext bytes → canonical prose bytes
|
||||
(collapses templates, links, formatting).
|
||||
- `claim-lattice@v1` — text bytes → canonical-JSON list of
|
||||
parsed claims (collapses surface variation in claim phrasing
|
||||
to its parsed structure).
|
||||
|
||||
A real query path runs these in series: raw wikitext → prose →
|
||||
parsed claim lattice. That sequence is itself a π*. Without first-
|
||||
class composition, every consumer re-derives the chain ad-hoc; the
|
||||
identity of the chain is implicit; cache-key hygiene drifts.
|
||||
|
||||
## 2. The composition rule
|
||||
|
||||
Given two registered π*'s:
|
||||
|
||||
- `π*_inner` with domain `D_in` and codomain `C_in`
|
||||
- `π*_outer` with domain `D_out` and codomain `C_out`
|
||||
|
||||
Their composition `π*_outer ∘ π*_inner` (apply inner first, then
|
||||
outer) is a valid canonical projection if and only if:
|
||||
|
||||
1. **Type-compatible.** `C_in ⊆ D_out`. The bytes the inner
|
||||
projection emits must be valid input to the outer.
|
||||
2. **Determinism preserved.** Both factors are deterministic
|
||||
functions ⇒ the chain is a deterministic function. (arborist
|
||||
demands this of every π*; composition inherits it for free.)
|
||||
3. **Equivalence-class-preserving.** If `π*_inner(x) =
|
||||
π*_inner(y)` then `(π*_outer ∘ π*_inner)(x) = (π*_outer ∘
|
||||
π*_inner)(y)`. Trivially true under function composition.
|
||||
|
||||
Type-compatibility is the only practical check the runtime can
|
||||
enforce automatically. `compose(inner_key, outer_key)` does NOT
|
||||
verify domain/codomain matching today (most domains are textual
|
||||
bytes; the inner's canonical bytes are usually valid input to the
|
||||
outer). Authors are responsible for declaring the domain match in
|
||||
the composition manifest doc when adding a new pair.
|
||||
|
||||
## 3. Lossy compositions
|
||||
|
||||
If `(π*_outer ∘ π*_inner)(x)` does not round-trip through the
|
||||
chain's codomain — i.e., `(π*_outer ∘ π*_inner)(out)` ≠ `out`
|
||||
under repeated application — the composition is **projective**
|
||||
rather than **invertible**.
|
||||
|
||||
The text-then-claim-lattice composition is projective: applying
|
||||
`claim-lattice` to its own output (a JSON-encoded list of parsed
|
||||
claims) does not return the same list. That's expected. The
|
||||
composition is still deterministic and equivalence-class-
|
||||
preserving; idempotency on the chain's codomain is a stronger
|
||||
property only some compositions need.
|
||||
|
||||
## 4. Composition as first-class π*
|
||||
|
||||
Each named composition registers itself in the global π* registry
|
||||
under a derived key:
|
||||
|
||||
```text
|
||||
"<inner-name>-then-<outer-name>@<version>"
|
||||
```
|
||||
|
||||
Default for `compose("wikitext-base@v1", "claim-lattice@v1")` is
|
||||
`"wikitext-base-then-claim-lattice@v1"`. This means:
|
||||
|
||||
- Downstream callers look up the composition by name like any other
|
||||
π*.
|
||||
- Cache keys folded against `governance_policy_hash` see one stable
|
||||
identity for the chain, not a tuple of (inner_key, outer_key).
|
||||
- Replacing the inner π* with a new version is a NEW composition
|
||||
key — old records still reference the old chain by content.
|
||||
|
||||
## 5. Composition manifest hash
|
||||
|
||||
For governance hash inclusion, compositions also expose a
|
||||
content-addressed fingerprint:
|
||||
|
||||
```python
|
||||
canonical_composition_id("wikitext-base@v1", "claim-lattice@v1")
|
||||
```
|
||||
|
||||
This is `SHA-256(canonical_json({"chain": [inner_key, outer_key]}))`.
|
||||
Order matters: `compose(a, b)` ≠ `compose(b, a)` and their
|
||||
fingerprints differ. Folding the fingerprint into a policy hash
|
||||
pins which composition is live for a given run.
|
||||
|
||||
## 6. Cross-domain anchor projections (future)
|
||||
|
||||
Genuinely cross-domain claims — "this paragraph describes that
|
||||
function" — need an **anchor projection** that takes both sides'
|
||||
canonical forms plus a relation kind:
|
||||
|
||||
```text
|
||||
anchor = SHA-256(
|
||||
canon_a_id || canon_b_id || relation_kind || relation_payload_canonical
|
||||
)
|
||||
```
|
||||
|
||||
Anchor projections are the cross-domain analog of single-domain
|
||||
π*. Designing them is out of scope for ticket #000015's first
|
||||
landing; the algebra above generalizes. Open work:
|
||||
|
||||
- Define a controlled vocabulary for `relation_kind` (committed in
|
||||
a sibling registry).
|
||||
- Define which operator policies require anchor commitments folded
|
||||
into `governance_policy_hash` vs sibling fields.
|
||||
- Worked example showing `text-anchored-to-code` as the first
|
||||
cross-domain composition in the registry.
|
||||
|
||||
## 7. Authoring checklist (new π* or composition)
|
||||
|
||||
Before landing a new π* or composition, verify:
|
||||
|
||||
- [ ] Round-trip stability test: `canonicalize(canonicalize(x)) ==
|
||||
canonicalize(x)` on a representative sample. (Note: only
|
||||
idempotent π*'s satisfy this; lossy compositions document
|
||||
the projective relationship explicitly.)
|
||||
- [ ] Determinism test: same input + same registry → byte-identical
|
||||
output across runs.
|
||||
- [ ] Domain declaration: `name`, `version`, `domain` set on the
|
||||
class.
|
||||
- [ ] Registry-entry test: `arborist.pi_star.list_keys()` includes
|
||||
the new key after package import.
|
||||
- [ ] Composition-fingerprint test (compositions only): the
|
||||
`canonical_composition_id` is stable across runs.
|
||||
- [ ] Spec-methodology checklist (ticket #000019 / `docs/spec-
|
||||
methodology.md`) reviewed.
|
||||
|
||||
## 8. Worked example: text → claim-lattice
|
||||
|
||||
```python
|
||||
from arborist.pi_star import compose, get
|
||||
|
||||
# Build the composition (auto-registers).
|
||||
chain = compose("wikitext-base@v1", "claim-lattice@v1")
|
||||
# Registry now holds "wikitext-base-then-claim-lattice@v1".
|
||||
|
||||
# Apply it.
|
||||
raw = b"- The thing happened. [E1]"
|
||||
canonical_bytes = chain.canonicalize(raw)
|
||||
# bytes are JSON-encoded list of one ParsedClaim with pointer_ids=["E1"].
|
||||
|
||||
# Same result via sequential composition (sanity check).
|
||||
inner = get("wikitext-base@v1")
|
||||
outer = get("claim-lattice@v1")
|
||||
manual = outer.canonicalize(inner.canonicalize(raw))
|
||||
assert canonical_bytes == manual
|
||||
```
|
||||
|
||||
This is the test fixture exercised in `tests/test_pi_star.py
|
||||
::test_compose_text_then_claim_lattice`.
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
# Ticket #000015 — π* domain library + cross-domain composition
|
||||
|
||||
**Status:** open · awaiting go/no-go
|
||||
**Status:** closed · landed 2026-05-07
|
||||
**Opened:** 2026-05-07
|
||||
**Closed:** 2026-05-07
|
||||
**Scope:** Stand up a unified library of canonical projections (π*) for
|
||||
the modalities arborist's QA layer touches, plus the composition theory
|
||||
needed to chain them. Doc + code: docs covers the algebra of π*
|
||||
|
|
@ -264,12 +265,39 @@ composition's identity in the registry.
|
|||
|
||||
## 7. Status
|
||||
|
||||
**Open · awaiting go/no-go.** Mid-sized implementation surface:
|
||||
~600 lines of code (registry + protocol + 2 re-homes + stubs +
|
||||
composition module) + ~200 lines of doc.
|
||||
**Closed 2026-05-07.** Scope delivered:
|
||||
|
||||
Closure criterion: `arborist/pi_star/` lands with re-homed
|
||||
`wikitext-base@v1` + `claim-lattice@v1`, stub modalities raising
|
||||
`NotImplementedError`, composition algebra documented in
|
||||
`docs/pi-star-composition.md` with the text-then-claim-lattice
|
||||
worked example tested.
|
||||
- New namespace `arborist.pi_star/` with registry pattern.
|
||||
- `protocol.py` — `PiStar` Protocol, `PiStarError`,
|
||||
`equivalence_class_id`, `assert_round_trip`, `registry_key`.
|
||||
- `registry.py` — name@version registry with no-mutation discipline
|
||||
(re-registering a different instance under an existing key
|
||||
raises). `list_keys()`, `domains()`, `get()`.
|
||||
- `compose.py` — `PiStarComposition`, `compose(inner_key,
|
||||
outer_key)`, `canonical_composition_id` for governance fingerprint.
|
||||
- Re-homed canonicalizers:
|
||||
- `text.py` — `wikitext-base@v1` wraps `arborist.wikitext.to_base`.
|
||||
- `claim_lattice.py` — `claim-lattice@v1` wraps
|
||||
`arborist.qa.parse_claims.parse_pointer_claims`.
|
||||
- Stubs (`NotImplementedError` with pointer to this ticket):
|
||||
`code-py-ast@v1`, `logic-kernel@v1`, `time-series-quantized@v1`,
|
||||
`tabular-pinned@v1`.
|
||||
- `docs/pi-star-composition.md` — composition algebra, worked
|
||||
text→claim-lattice example, authoring checklist.
|
||||
- Tests: `tests/test_pi_star.py` — 19 cases covering registry
|
||||
contents, round-trip stability, stub-error behavior, composition
|
||||
determinism, manifest fingerprint stability. Full suite: 1059
|
||||
passed, 36 skipped.
|
||||
|
||||
Out-of-scope items (deferred):
|
||||
|
||||
- Actual implementation of `code-py-ast`, `logic-kernel`,
|
||||
`time-series-quantized`, `tabular-pinned`. Each is its own
|
||||
follow-up ticket once needed.
|
||||
- Cross-domain anchor projections (algebra is in the doc; first
|
||||
concrete implementation is its own ticket).
|
||||
- Migration of legacy call sites to use
|
||||
`arborist.pi_star.get(...).canonicalize(...)` instead of
|
||||
`arborist.wikitext.to_base(...)` etc. Soft migration over time.
|
||||
- Versioning policy for π* deprecation. v1 of this library does
|
||||
not retire any π*; deprecation policy needs its own ticket.
|
||||
|
|
|
|||
196
tests/test_pi_star.py
Normal file
196
tests/test_pi_star.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""π* domain library tests (ticket #000015).
|
||||
|
||||
Covers:
|
||||
- Registry contains the expected built-in π*'s after import
|
||||
- Round-trip determinism for active π*'s
|
||||
- Stubs raise NotImplementedError
|
||||
- Composition algebra: chain produces same output as sequential calls
|
||||
- Composition manifest fingerprint stability
|
||||
- Existing arborist.wikitext.to_base behavior preserved
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from arborist.pi_star import (
|
||||
REGISTRY,
|
||||
canonical_composition_id,
|
||||
compose,
|
||||
domains,
|
||||
get,
|
||||
list_keys,
|
||||
)
|
||||
from arborist.pi_star.protocol import (
|
||||
PiStarError,
|
||||
assert_round_trip,
|
||||
equivalence_class_id,
|
||||
)
|
||||
|
||||
|
||||
# --- registry --------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_contains_expected_keys():
|
||||
keys = list_keys()
|
||||
assert "wikitext-base@v1" in keys
|
||||
assert "claim-lattice@v1" in keys
|
||||
assert "code-py-ast@v1" in keys
|
||||
assert "logic-kernel@v1" in keys
|
||||
assert "time-series-quantized@v1" in keys
|
||||
assert "tabular-pinned@v1" in keys
|
||||
|
||||
|
||||
def test_domains_groups_by_domain():
|
||||
d = domains()
|
||||
assert "code-py-ast@v1" in d.get("code", [])
|
||||
assert "logic-kernel@v1" in d.get("logic", [])
|
||||
assert "time-series-quantized@v1" in d.get("time-series", [])
|
||||
|
||||
|
||||
def test_get_unknown_raises():
|
||||
with pytest.raises(KeyError):
|
||||
get("nonexistent@v999")
|
||||
|
||||
|
||||
# --- wikitext-base@v1 -----------------------------------------------
|
||||
|
||||
|
||||
def test_wikitext_base_round_trip():
|
||||
pi_star = get("wikitext-base@v1")
|
||||
raw = b"Plain ''italic'' [[link|word]] text."
|
||||
once = pi_star.canonicalize(raw)
|
||||
twice = pi_star.canonicalize(once)
|
||||
assert once == twice
|
||||
assert_round_trip(pi_star, raw)
|
||||
|
||||
|
||||
def test_wikitext_base_rejects_non_bytes():
|
||||
pi_star = get("wikitext-base@v1")
|
||||
with pytest.raises(PiStarError):
|
||||
pi_star.canonicalize("not bytes") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_wikitext_base_equivalence_class():
|
||||
pi_star = get("wikitext-base@v1")
|
||||
raw = b"Plain text."
|
||||
eid = equivalence_class_id(pi_star, raw)
|
||||
assert len(eid) == 64 # SHA-256 hex
|
||||
|
||||
|
||||
def test_wikitext_base_matches_legacy_to_base():
|
||||
"""Re-home preserves identical output to the legacy
|
||||
``arborist.wikitext.to_base`` API."""
|
||||
from arborist.wikitext import to_base
|
||||
|
||||
pi_star = get("wikitext-base@v1")
|
||||
raw_text = "Some [[link|text]] with ''italics''."
|
||||
legacy = to_base(raw_text).encode("utf-8")
|
||||
via_pi_star = pi_star.canonicalize(raw_text.encode("utf-8"))
|
||||
assert legacy == via_pi_star
|
||||
|
||||
|
||||
# --- claim-lattice@v1 ------------------------------------------------
|
||||
|
||||
|
||||
def test_claim_lattice_round_trip_on_lattice_text():
|
||||
"""Canonicalize is idempotent: canonicalize(canonicalize(x)) ==
|
||||
canonicalize(x). Note: the canonical form is a JSON list, so the
|
||||
second canonicalization re-parses the JSON as text and produces
|
||||
a different list — these projections are NOT idempotent on raw
|
||||
text. We only assert determinism under the same input."""
|
||||
pi_star = get("claim-lattice@v1")
|
||||
sample = (
|
||||
b"- A claim. [E1]\n"
|
||||
b"- Another claim. [E2]\n"
|
||||
)
|
||||
once = pi_star.canonicalize(sample)
|
||||
twice = pi_star.canonicalize(sample)
|
||||
assert once == twice
|
||||
|
||||
|
||||
def test_claim_lattice_rejects_non_bytes():
|
||||
pi_star = get("claim-lattice@v1")
|
||||
with pytest.raises(PiStarError):
|
||||
pi_star.canonicalize(123) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_claim_lattice_empty_input_returns_empty_array():
|
||||
pi_star = get("claim-lattice@v1")
|
||||
out = pi_star.canonicalize(b"")
|
||||
assert out == b"[]"
|
||||
|
||||
|
||||
# --- stubs ---------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"code-py-ast@v1",
|
||||
"logic-kernel@v1",
|
||||
"time-series-quantized@v1",
|
||||
"tabular-pinned@v1",
|
||||
],
|
||||
)
|
||||
def test_stubs_raise_not_implemented(key):
|
||||
pi_star = get(key)
|
||||
with pytest.raises(NotImplementedError):
|
||||
pi_star.canonicalize(b"anything")
|
||||
|
||||
|
||||
# --- composition --------------------------------------------------
|
||||
|
||||
|
||||
def test_compose_text_then_claim_lattice():
|
||||
"""Chaining wikitext-base@v1 then claim-lattice@v1 produces the
|
||||
same bytes as applying them sequentially."""
|
||||
composition = compose(
|
||||
"wikitext-base@v1",
|
||||
"claim-lattice@v1",
|
||||
register_in_registry=False,
|
||||
)
|
||||
raw = b"- A simple claim. [E1]"
|
||||
via_composition = composition.canonicalize(raw)
|
||||
|
||||
inner = get("wikitext-base@v1")
|
||||
outer = get("claim-lattice@v1")
|
||||
sequential = outer.canonicalize(inner.canonicalize(raw))
|
||||
|
||||
assert via_composition == sequential
|
||||
|
||||
|
||||
def test_compose_registers_into_registry():
|
||||
"""compose(register_in_registry=True) puts the composition in REGISTRY."""
|
||||
pre = list_keys()
|
||||
composition = compose(
|
||||
"wikitext-base@v1",
|
||||
"claim-lattice@v1",
|
||||
name="wikitext-then-claims-test",
|
||||
version="vt",
|
||||
)
|
||||
post = list_keys()
|
||||
assert "wikitext-then-claims-test@vt" in post
|
||||
assert len(post) == len(pre) + 1
|
||||
assert composition.domain == "text-or-wikitext"
|
||||
|
||||
|
||||
def test_compose_unknown_inner_rejected():
|
||||
with pytest.raises(KeyError):
|
||||
compose("nope@v1", "claim-lattice@v1")
|
||||
|
||||
|
||||
def test_compose_unknown_outer_rejected():
|
||||
with pytest.raises(KeyError):
|
||||
compose("wikitext-base@v1", "nope@v1")
|
||||
|
||||
|
||||
def test_canonical_composition_id_stable():
|
||||
a = canonical_composition_id("wikitext-base@v1", "claim-lattice@v1")
|
||||
b = canonical_composition_id("wikitext-base@v1", "claim-lattice@v1")
|
||||
assert a == b
|
||||
# Order matters: a∘b ≠ b∘a.
|
||||
swapped = canonical_composition_id(
|
||||
"claim-lattice@v1", "wikitext-base@v1"
|
||||
)
|
||||
assert a != swapped
|
||||
Loading…
Add table
Add a link
Reference in a new issue