# π* 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 "-then-@" ``` 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`.