tabular-pinned@v1 + calculus-limit@v1 + calculus-series@v1 + linear-algebra@v1 + function-sampled@v1 — all reserved stubs graduated; the π* registry is now 15 concrete kernels with no remaining reserved-stub entries. #000030 Phase 4 — calculus-limit@v1 ==================================== sp.limit with thread-timeout. One-sided dir support (+/-/+-). Pinned spelling for infinity cases: b"+oo" / b"-oo" / b"zoo" (complex infinity) — bypasses sp.expand since Infinity isn't algebraic. Finite results re-canonicalize through algebra-symbolic recipe (sp.expand + sp.srepr). Unevaluated cases / timeouts emit b"unevaluated:" + sp.srepr(<Limit>) sentinel, mirroring calculus-integral's pattern. #000030 Phase 5 — calculus-series@v1 ===================================== sp.series(f, x, x0, n).removeO() → sp.expand → sp.srepr. Drops O(x**n) remainder explicitly so the canonical form is finite-byte. Sentinel format mirrors limit/integral: b"unevaluated:Series(...)" on timeout. n must be a positive int; 0 / float / negative rejected. #000030 Phase 6 — linear-algebra@v1 ==================================== Single π* covers the whole linear-algebra surface via {op, matrix} JSON. Ops: rref / det / eigenvalues / inverse. Matrix cells go through Fraction(Decimal(str(...))) for floats so 1, 1.0, "1.0" all collapse to Rational(1, 1) — matching arithmetic@v1's discipline. Without this fold, sp.sympify keeps floats as Float (separate type) and downstream det/inverse return Float-shaped bytes. Eigenvalues are sorted by srepr for determinism. Output formats: rref / inverse: rows/cols header + cells joined by | (rows by ||) det: det:<num/den-or-srepr> eigenvalues: eigenvalues:<value-1>x<mult-1>|... #000030 Phase 7 — function-sampled@v1 ====================================== Bridge to time-series-quantized@v1. SymPy expression + linspace grid → quantized integer-vector signature in time-series's exact output format (dt=...;dv=...;n=...;t0=0:v0|v1|...). Two functions that render identically (within sample-grid tolerance) collapse to the same canonical bytes. This is what plotting CAN become in π* terms — the PNG render is a downstream view of the same canonical evidence. Math-only sampler (no numpy in the dep surface); Python's round() is banker's-rounding so the bytes are interchangeable with time-series-quantized@v1's output. Complex / non-finite samples raise PiStarError rather than silently dropping imaginary parts. tabular-pinned@v1 — last reserved stub graduates ================================================= JSON-rows input ({schema, key_columns, rows}); declared key_columns sort policy (stable sort by primary-key tuple); type-fold per column (int/rational/bool through arithmetic@v1 discipline; str verbatim; bool normalized). Header case is PINNED EXACT — Excel and PostgreSQL both care about case; defaulting to lowercase-fold would break operator expectations. Output: header (schema + key + n) + rows joined by \n + cells by |. The π* registry has no remaining reserved stubs. Every modality the substrate paper reserved is now real. Test suite: 1568 passed (was 1467; +101). New closure-criterion test (test_no_stub_pi_stars_remain) replaces the old reserved-stub parametrize — adding a future stub re-opens this list. 110/110 fixtures pass across the 5 new bench-5s-* targets. PHASE_1_CARRIERS gained calculus / linear-algebra / function-sampled / tabular.
741 lines
23 KiB
Python
741 lines
23 KiB
Python
"""π* 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 ---------------------------------------------------------
|
|
|
|
|
|
def test_no_stub_pi_stars_remain():
|
|
"""Closure-criterion guard: every reserved-stub π* has graduated.
|
|
|
|
History:
|
|
- 2026-05-07 (#000015): six concrete + four reserved stubs
|
|
(``code-py-ast``, ``logic-kernel``, ``time-series-quantized``,
|
|
``tabular-pinned``).
|
|
- 2026-05-08: arithmetic / logic-kernel / code-py-ast /
|
|
time-series-quantized graduated.
|
|
- 2026-05-09 (#000030 + tabular phase): algebra-symbolic /
|
|
calculus-derivative / -integral / -limit / -series /
|
|
linear-algebra / function-sampled / tabular-pinned all real.
|
|
|
|
Adding a new reserved stub re-opens this list; that's the
|
|
governance event this test pins."""
|
|
from arborist.pi_star import REGISTRY
|
|
for key, pi_star in REGISTRY.items():
|
|
# A stub raises NotImplementedError on any input; a real
|
|
# canonicalizer either succeeds or raises PiStarError on
|
|
# bad input. We probe with empty bytes — most real kernels
|
|
# raise PiStarError; none should raise NotImplementedError.
|
|
try:
|
|
pi_star.canonicalize(b"")
|
|
except NotImplementedError:
|
|
raise AssertionError(
|
|
f"π* {key!r} still raises NotImplementedError — "
|
|
f"reserved stub not yet graduated"
|
|
)
|
|
except Exception:
|
|
# Any other exception (PiStarError, ValueError, etc.)
|
|
# means the kernel is real.
|
|
pass
|
|
|
|
|
|
# --- time-series-quantized@v1 (graduated from stub) ------------------
|
|
|
|
|
|
def test_time_series_canonicalizes_basic_series():
|
|
import json as _json
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
src = _json.dumps({"dt": 1, "dv": 0.1, "samples": [[0, 1.0], [1, 2.5]]}).encode()
|
|
out = pi_star.canonicalize(src)
|
|
assert out == b"dt=1;dv=0.1;n=2;t0=0:10|25"
|
|
|
|
|
|
def test_time_series_collapses_jitter_within_resolution():
|
|
"""Timestamps off by < dt/2 quantize to the same grid index."""
|
|
import json as _json
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
a = _json.dumps({"dt": 1, "dv": 0.1, "samples": [[0, 1.0], [1, 2.0]]}).encode()
|
|
b = _json.dumps({"dt": 1, "dv": 0.1, "samples": [[0.4, 1.04], [1.3, 2.0]]}).encode()
|
|
assert pi_star.canonicalize(a) == pi_star.canonicalize(b)
|
|
|
|
|
|
def test_time_series_sorts_out_of_order_samples():
|
|
import json as _json
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
sorted_input = _json.dumps({"dt": 1, "dv": 1, "samples": [[0, 1], [1, 2], [2, 3]]}).encode()
|
|
shuffled = _json.dumps({"dt": 1, "dv": 1, "samples": [[2, 3], [0, 1], [1, 2]]}).encode()
|
|
assert pi_star.canonicalize(sorted_input) == pi_star.canonicalize(shuffled)
|
|
|
|
|
|
def test_time_series_distinguishes_different_dt():
|
|
import json as _json
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
a = _json.dumps({"dt": 1, "dv": 1, "samples": [[0, 1]]}).encode()
|
|
b = _json.dumps({"dt": 2, "dv": 1, "samples": [[0, 1]]}).encode()
|
|
assert pi_star.canonicalize(a) != pi_star.canonicalize(b)
|
|
|
|
|
|
def test_time_series_distinguishes_different_dv():
|
|
import json as _json
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
a = _json.dumps({"dt": 1, "dv": 0.1, "samples": [[0, 1.0]]}).encode()
|
|
b = _json.dumps({"dt": 1, "dv": 1.0, "samples": [[0, 1.0]]}).encode()
|
|
assert pi_star.canonicalize(a) != pi_star.canonicalize(b)
|
|
|
|
|
|
def test_time_series_distinguishes_different_values():
|
|
import json as _json
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
a = _json.dumps({"dt": 1, "dv": 1, "samples": [[0, 1]]}).encode()
|
|
b = _json.dumps({"dt": 1, "dv": 1, "samples": [[0, 2]]}).encode()
|
|
assert pi_star.canonicalize(a) != pi_star.canonicalize(b)
|
|
|
|
|
|
def test_time_series_dedupes_collisions_keeping_last():
|
|
"""Two samples at the same quantized timestamp: last wins."""
|
|
import json as _json
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
src = _json.dumps({"dt": 1, "dv": 1, "samples": [[0, 1], [0.3, 5]]}).encode()
|
|
out = pi_star.canonicalize(src)
|
|
# Both timestamps quantize to t_idx=0; second sample (5) wins.
|
|
assert out == b"dt=1;dv=1;n=1;t0=0:5"
|
|
|
|
|
|
def test_time_series_handles_empty_samples():
|
|
import json as _json
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
out = pi_star.canonicalize(_json.dumps({"dt": 1, "dv": 1, "samples": []}).encode())
|
|
assert out == b"dt=1;dv=1;n=0;t0=0:"
|
|
|
|
|
|
def test_time_series_rejects_non_json():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
with pytest.raises(PiStarError, match="not valid JSON"):
|
|
pi_star.canonicalize(b"not json")
|
|
|
|
|
|
def test_time_series_rejects_missing_fields():
|
|
import json as _json
|
|
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
with pytest.raises(PiStarError, match="missing required field"):
|
|
pi_star.canonicalize(_json.dumps({"dt": 1, "samples": []}).encode())
|
|
|
|
|
|
def test_time_series_rejects_zero_dt():
|
|
import json as _json
|
|
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
with pytest.raises(PiStarError, match="dt must be positive"):
|
|
pi_star.canonicalize(_json.dumps({"dt": 0, "dv": 1, "samples": []}).encode())
|
|
|
|
|
|
def test_time_series_rejects_negative_dv():
|
|
import json as _json
|
|
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
with pytest.raises(PiStarError, match="dv must be positive"):
|
|
pi_star.canonicalize(_json.dumps({"dt": 1, "dv": -1, "samples": []}).encode())
|
|
|
|
|
|
def test_time_series_rejects_non_pair_samples():
|
|
import json as _json
|
|
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
with pytest.raises(PiStarError, match="\\[timestamp, value\\] pair"):
|
|
pi_star.canonicalize(_json.dumps({"dt": 1, "dv": 1, "samples": [[1, 2, 3]]}).encode())
|
|
|
|
|
|
def test_time_series_deterministic_across_repeated_calls():
|
|
import json as _json
|
|
|
|
pi_star = get("time-series-quantized@v1")
|
|
src = _json.dumps({"dt": 0.5, "dv": 0.01, "samples": [[0, 0.05], [0.5, 0.10], [1.0, 0.15]]}).encode()
|
|
a = pi_star.canonicalize(src)
|
|
b = pi_star.canonicalize(src)
|
|
c = pi_star.canonicalize(src)
|
|
assert a == b == c
|
|
|
|
|
|
# --- code-py-ast@v1 (graduated from stub) ----------------------------
|
|
|
|
|
|
def test_code_py_ast_canonicalizes_simple_assign():
|
|
pi_star = get("code-py-ast@v1")
|
|
out = pi_star.canonicalize(b"x = 1")
|
|
assert isinstance(out, bytes)
|
|
assert b"Assign" in out
|
|
assert b"Constant" in out
|
|
|
|
|
|
def test_code_py_ast_collapses_whitespace_and_comments():
|
|
pi_star = get("code-py-ast@v1")
|
|
a = pi_star.canonicalize(b"def foo(): pass")
|
|
b = pi_star.canonicalize(b"def foo():\n pass # comment")
|
|
assert a == b
|
|
|
|
|
|
def test_code_py_ast_collapses_quote_style():
|
|
pi_star = get("code-py-ast@v1")
|
|
a = pi_star.canonicalize(b"s = 'hello'")
|
|
b = pi_star.canonicalize(b's = "hello"')
|
|
assert a == b
|
|
|
|
|
|
def test_code_py_ast_collapses_operator_spacing():
|
|
pi_star = get("code-py-ast@v1")
|
|
a = pi_star.canonicalize(b"y = 1+2")
|
|
b = pi_star.canonicalize(b"y = 1 + 2")
|
|
assert a == b
|
|
|
|
|
|
def test_code_py_ast_distinguishes_identifiers():
|
|
pi_star = get("code-py-ast@v1")
|
|
a = pi_star.canonicalize(b"def foo(): pass")
|
|
b = pi_star.canonicalize(b"def bar(): pass")
|
|
assert a != b
|
|
|
|
|
|
def test_code_py_ast_distinguishes_operators():
|
|
pi_star = get("code-py-ast@v1")
|
|
a = pi_star.canonicalize(b"y = a + b")
|
|
b = pi_star.canonicalize(b"y = a - b")
|
|
assert a != b
|
|
|
|
|
|
def test_code_py_ast_distinguishes_argument_order():
|
|
pi_star = get("code-py-ast@v1")
|
|
a = pi_star.canonicalize(b"f(x, y)")
|
|
b = pi_star.canonicalize(b"f(y, x)")
|
|
assert a != b
|
|
|
|
|
|
def test_code_py_ast_rejects_non_bytes():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("code-py-ast@v1")
|
|
with pytest.raises(PiStarError):
|
|
pi_star.canonicalize("not bytes") # type: ignore[arg-type]
|
|
|
|
|
|
def test_code_py_ast_rejects_invalid_python():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("code-py-ast@v1")
|
|
with pytest.raises(PiStarError):
|
|
pi_star.canonicalize(b"def foo( malformed")
|
|
|
|
|
|
def test_code_py_ast_deterministic_across_repeated_calls():
|
|
pi_star = get("code-py-ast@v1")
|
|
src = b"class Foo:\n def bar(self, x):\n return x + 1\n"
|
|
a = pi_star.canonicalize(src)
|
|
b = pi_star.canonicalize(src)
|
|
c = pi_star.canonicalize(src)
|
|
assert a == b == c
|
|
|
|
|
|
def test_code_py_ast_handles_empty_source():
|
|
pi_star = get("code-py-ast@v1")
|
|
out = pi_star.canonicalize(b"")
|
|
# Empty source parses to ast.Module(body=[], type_ignores=[]).
|
|
assert b"Module" in out
|
|
assert b"body=[]" in out
|
|
|
|
|
|
def test_code_py_ast_equivalence_class_id_format():
|
|
pi_star = get("code-py-ast@v1")
|
|
eid = equivalence_class_id(pi_star, b"x = 1")
|
|
assert len(eid) == 64
|
|
# Same source twice → same id.
|
|
assert eid == equivalence_class_id(pi_star, b"x = 1")
|
|
|
|
|
|
# --- arithmetic@v1 (graduated from stub) -----------------------------
|
|
|
|
|
|
def test_arithmetic_canonicalizes_integer():
|
|
pi_star = get("arithmetic@v1")
|
|
assert pi_star.canonicalize(b"3") == b"3/1"
|
|
assert pi_star.canonicalize(b"-7") == b"-7/1"
|
|
|
|
|
|
def test_arithmetic_canonicalizes_decimal_exactly():
|
|
"""SQD §14.1: 0.1 must canonicalize to 1/10 exactly."""
|
|
pi_star = get("arithmetic@v1")
|
|
assert pi_star.canonicalize(b"0.1") == b"1/10"
|
|
assert pi_star.canonicalize(b"0.5") == b"1/2"
|
|
assert pi_star.canonicalize(b"0.25") == b"1/4"
|
|
|
|
|
|
def test_arithmetic_solves_classic_floating_point_question():
|
|
"""The SQD canonical example: 0.1 + 0.2 == 0.3 exactly."""
|
|
pi_star = get("arithmetic@v1")
|
|
a = pi_star.canonicalize(b"0.1+0.2")
|
|
b = pi_star.canonicalize(b"0.3")
|
|
assert a == b == b"3/10"
|
|
|
|
|
|
def test_arithmetic_collapses_equivalent_expressions():
|
|
pi_star = get("arithmetic@v1")
|
|
assert pi_star.canonicalize(b"1+2") == pi_star.canonicalize(b"3")
|
|
assert pi_star.canonicalize(b"(1+2)*3") == pi_star.canonicalize(b"9")
|
|
assert pi_star.canonicalize(b"6/4") == pi_star.canonicalize(b"3/2")
|
|
|
|
|
|
def test_arithmetic_distinguishes_distinct_values():
|
|
pi_star = get("arithmetic@v1")
|
|
assert pi_star.canonicalize(b"1+2") != pi_star.canonicalize(b"4")
|
|
assert pi_star.canonicalize(b"1/2") != pi_star.canonicalize(b"1/3")
|
|
|
|
|
|
def test_arithmetic_supports_integer_exponent():
|
|
pi_star = get("arithmetic@v1")
|
|
assert pi_star.canonicalize(b"2**3") == b"8/1"
|
|
assert pi_star.canonicalize(b"3**2") == b"9/1"
|
|
|
|
|
|
def test_arithmetic_rejects_division_by_zero():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("arithmetic@v1")
|
|
with pytest.raises(PiStarError, match="division by zero"):
|
|
pi_star.canonicalize(b"1/0")
|
|
|
|
|
|
def test_arithmetic_rejects_variables():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("arithmetic@v1")
|
|
with pytest.raises(PiStarError, match="identifier"):
|
|
pi_star.canonicalize(b"x + 1")
|
|
|
|
|
|
def test_arithmetic_rejects_non_integer_exponent():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("arithmetic@v1")
|
|
with pytest.raises(PiStarError, match="non-integer exponent"):
|
|
pi_star.canonicalize(b"2 ** 0.5")
|
|
|
|
|
|
def test_arithmetic_rejects_function_calls():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("arithmetic@v1")
|
|
with pytest.raises(PiStarError, match="function call"):
|
|
pi_star.canonicalize(b"abs(-5)")
|
|
|
|
|
|
def test_arithmetic_rejects_invalid_syntax():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("arithmetic@v1")
|
|
with pytest.raises(PiStarError):
|
|
pi_star.canonicalize(b"1 +")
|
|
|
|
|
|
def test_arithmetic_rejects_empty_input():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("arithmetic@v1")
|
|
with pytest.raises(PiStarError, match="empty"):
|
|
pi_star.canonicalize(b"")
|
|
|
|
|
|
def test_arithmetic_canonical_form_is_idempotent():
|
|
pi_star = get("arithmetic@v1")
|
|
once = pi_star.canonicalize(b"6/4")
|
|
twice = pi_star.canonicalize(once)
|
|
assert once == twice == b"3/2"
|
|
|
|
|
|
def test_arithmetic_handles_negative_results():
|
|
pi_star = get("arithmetic@v1")
|
|
assert pi_star.canonicalize(b"-(1+2)") == b"-3/1"
|
|
assert pi_star.canonicalize(b"3 - 5") == b"-2/1"
|
|
|
|
|
|
# --- logic-kernel@v1 (graduated from stub) ---------------------------
|
|
|
|
|
|
def test_logic_kernel_canonicalizes_simple_and():
|
|
pi_star = get("logic-kernel@v1")
|
|
assert pi_star.canonicalize(b"A AND B") == b"A AND B"
|
|
|
|
|
|
def test_logic_kernel_commutativity():
|
|
pi_star = get("logic-kernel@v1")
|
|
assert pi_star.canonicalize(b"A AND B") == pi_star.canonicalize(b"B AND A")
|
|
assert pi_star.canonicalize(b"A OR B") == pi_star.canonicalize(b"B OR A")
|
|
|
|
|
|
def test_logic_kernel_associativity():
|
|
pi_star = get("logic-kernel@v1")
|
|
a = pi_star.canonicalize(b"(A AND B) AND C")
|
|
b = pi_star.canonicalize(b"A AND (B AND C)")
|
|
assert a == b
|
|
|
|
|
|
def test_logic_kernel_impl_rewrite():
|
|
pi_star = get("logic-kernel@v1")
|
|
assert pi_star.canonicalize(b"A IMPL B") == pi_star.canonicalize(b"NOT A OR B")
|
|
|
|
|
|
def test_logic_kernel_iff_rewrite():
|
|
pi_star = get("logic-kernel@v1")
|
|
a = pi_star.canonicalize(b"A IFF B")
|
|
b = pi_star.canonicalize(b"(NOT A OR B) AND (NOT B OR A)")
|
|
assert a == b
|
|
|
|
|
|
def test_logic_kernel_xor_rewrite():
|
|
pi_star = get("logic-kernel@v1")
|
|
a = pi_star.canonicalize(b"A XOR B")
|
|
b = pi_star.canonicalize(b"(A OR B) AND (NOT A OR NOT B)")
|
|
assert a == b
|
|
|
|
|
|
def test_logic_kernel_de_morgans():
|
|
pi_star = get("logic-kernel@v1")
|
|
a = pi_star.canonicalize(b"NOT (A AND B)")
|
|
b = pi_star.canonicalize(b"(NOT A) OR (NOT B)")
|
|
assert a == b
|
|
|
|
|
|
def test_logic_kernel_double_negation():
|
|
pi_star = get("logic-kernel@v1")
|
|
assert pi_star.canonicalize(b"NOT NOT A") == pi_star.canonicalize(b"A")
|
|
|
|
|
|
def test_logic_kernel_distribution():
|
|
pi_star = get("logic-kernel@v1")
|
|
a = pi_star.canonicalize(b"A OR (B AND C)")
|
|
b = pi_star.canonicalize(b"(A OR B) AND (A OR C)")
|
|
assert a == b
|
|
|
|
|
|
def test_logic_kernel_tautology_collapses_to_true():
|
|
pi_star = get("logic-kernel@v1")
|
|
assert pi_star.canonicalize(b"A OR NOT A") == b"TRUE"
|
|
assert pi_star.canonicalize(b"TRUE") == b"TRUE"
|
|
|
|
|
|
def test_logic_kernel_idempotence():
|
|
pi_star = get("logic-kernel@v1")
|
|
assert pi_star.canonicalize(b"A AND A") == pi_star.canonicalize(b"A")
|
|
assert pi_star.canonicalize(b"A OR A") == pi_star.canonicalize(b"A")
|
|
|
|
|
|
def test_logic_kernel_distinguishes_logically_distinct():
|
|
pi_star = get("logic-kernel@v1")
|
|
assert pi_star.canonicalize(b"A AND B") != pi_star.canonicalize(b"A OR B")
|
|
assert pi_star.canonicalize(b"A IMPL B") != pi_star.canonicalize(b"B IMPL A")
|
|
|
|
|
|
def test_logic_kernel_caps_atom_count():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("logic-kernel@v1")
|
|
expr = b"A AND B AND C AND D AND E AND F AND G AND H AND I"
|
|
with pytest.raises(PiStarError, match="caps atoms at"):
|
|
pi_star.canonicalize(expr)
|
|
|
|
|
|
def test_logic_kernel_rejects_empty():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("logic-kernel@v1")
|
|
with pytest.raises(PiStarError, match="empty"):
|
|
pi_star.canonicalize(b"")
|
|
|
|
|
|
def test_logic_kernel_rejects_invalid_syntax():
|
|
from arborist.pi_star.protocol import PiStarError
|
|
|
|
pi_star = get("logic-kernel@v1")
|
|
with pytest.raises(PiStarError):
|
|
pi_star.canonicalize(b"A AND")
|
|
|
|
|
|
def test_logic_kernel_canonical_form_is_idempotent():
|
|
pi_star = get("logic-kernel@v1")
|
|
src = b"(A OR B) AND (NOT A OR C)"
|
|
once = pi_star.canonicalize(src)
|
|
twice = pi_star.canonicalize(once)
|
|
assert once == twice
|
|
|
|
|
|
def test_logic_kernel_contrapositive_equivalence():
|
|
"""A IMPL B ≡ NOT B IMPL NOT A"""
|
|
pi_star = get("logic-kernel@v1")
|
|
a = pi_star.canonicalize(b"A IMPL B")
|
|
b = pi_star.canonicalize(b"(NOT B) IMPL (NOT A)")
|
|
assert a == b
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
# --- protocol helpers (low-level unit) ----------------------------
|
|
|
|
|
|
def test_assert_round_trip_passes_on_idempotent_pi_star():
|
|
"""A π* that is idempotent on its inputs should not raise."""
|
|
pi_star = get("wikitext-base@v1")
|
|
assert_round_trip(pi_star, b"plain text") # no exception expected
|
|
|
|
|
|
def test_assert_round_trip_raises_on_non_idempotent():
|
|
"""A custom π* whose canonicalize is non-idempotent triggers
|
|
AssertionError. Construct one inline."""
|
|
from dataclasses import dataclass
|
|
|
|
@dataclass
|
|
class _Counter:
|
|
name: str = "_counter-pi-star"
|
|
version: str = "v1"
|
|
domain: str = "text"
|
|
n_calls: int = 0
|
|
|
|
def canonicalize(self, raw: bytes) -> bytes:
|
|
# Append a counter byte each call → never idempotent.
|
|
self.n_calls += 1
|
|
return raw + str(self.n_calls).encode()
|
|
|
|
bad = _Counter()
|
|
with pytest.raises(AssertionError):
|
|
assert_round_trip(bad, b"hello")
|
|
|
|
|
|
def test_equivalence_class_id_deterministic():
|
|
pi_star = get("wikitext-base@v1")
|
|
a = equivalence_class_id(pi_star, b"text")
|
|
b = equivalence_class_id(pi_star, b"text")
|
|
assert a == b
|
|
assert len(a) == 64
|
|
|
|
|
|
def test_equivalence_class_id_changes_with_input():
|
|
pi_star = get("wikitext-base@v1")
|
|
a = equivalence_class_id(pi_star, b"text one")
|
|
b = equivalence_class_id(pi_star, b"text two")
|
|
assert a != b
|
|
|
|
|
|
def test_registry_key_format():
|
|
from arborist.pi_star.protocol import registry_key
|
|
|
|
pi_star = get("wikitext-base@v1")
|
|
key = registry_key(pi_star)
|
|
assert "@" in key
|
|
name, version = key.split("@", 1)
|
|
assert name == "wikitext-base"
|
|
assert version == "v1"
|
|
|
|
|
|
# --- domains() smoke -----------------------------------------------
|
|
|
|
|
|
def test_domains_groups_keys_by_domain():
|
|
"""Every registered π* surfaces under exactly one domain key."""
|
|
d = domains()
|
|
all_keys: set[str] = set()
|
|
for keys in d.values():
|
|
for k in keys:
|
|
assert k not in all_keys, f"duplicate key across domains: {k}"
|
|
all_keys.add(k)
|
|
# Sanity: total keys == len(REGISTRY) less any non-PiStar keys.
|
|
assert len(all_keys) >= 6 # 2 active + 4 stubs minimum
|
|
|