Two new π* canonicalizers extend the math substrate above
arithmetic@v1 (closed-form rationals) and logic-kernel@v1
(propositional Boolean → CNF):
algebra-symbolic@v1 (Phase 1) — symbolic-algebra domain.
sp.expand → sp.srepr canonical bytes. Polynomial identity collapses
((x+1)**2 ≡ x**2 + 2*x + 1); exponential identity collapses
(exp(a+b) ≡ exp(a)*exp(b), inherited from sp.expand's default
behavior); trigonometric identity does NOT collapse
(sin²+cos² ≢ 1). The trig surface is reserved for a future
algebra-symbolic-simplified@v1 variant that wraps sp.simplify at
unbounded CPU cost. Rejects relationals (`x > 0`) and
BooleanFunction shapes (`x & y`) via `isinstance(expr, sp.Expr)` —
sp.Symbol confusingly inherits from Boolean so the right rejection
filter is "not Expr" rather than "Boolean".
calculus-derivative@v1 (Phase 2) — calculus domain. JSON-shaped
{f, x, n} input → sp.diff → sp.expand → srepr bytes. Output is
itself a valid algebra-symbolic@v1 input so the two compose
naturally under arborist.pi_star.compose. n defaults to 1; bools
explicitly rejected (Python isinstance(True, int) is True so we
filter that explicitly).
Optional dependency: sympy ships in the new [math] extra
(pyproject.toml). Folded into [dev] so make bootstrap pulls it
transitively. An explicit `bootstrap-math` Makefile target documents
the opt-in for minimal-install users. Both modules self-guard
via `try: import sympy as sp / except ImportError: sp = None` and
only register(...) when sympy is present, so a fresh checkout
without [math] still loads arborist.pi_star without raising.
Preflight algebra route lands in
arborist.qa.query._canonical_projection_preflight between the
arithmetic and logic routes. Charset regex (_CANONICAL_ALGEBRA_RE)
allows lowercase letters + math chars; requires at least one
letter (else arithmetic wins); rejects natural-language leading
verbs via _CANONICAL_ALGEBRA_NL_LEAD_RE (4-letter minimum so
single-/two-/three-char identifiers like x, xy, sin, cos, pi
survive while "simplify (...)", "factor x...", "expand (a+b)..."
fall through). PiStarError + KeyError both fall through cleanly
so a sympy-less install just routes everything past algebra.
Bench substrate:
- bench/batteries/base.py PHASE_1_CARRIERS gains "symbolic_algebra"
- bench/fixtures/5s/syntax-algebra-symbolic-v1.jsonl (10 fixtures)
- bench/fixtures/5s/semantics-algebra-symbolic-v1.jsonl (13 fixtures
including the documented trig non-collapse + exp collapse)
- Makefile bench-5s-algebra target → 100% pass
Tests: 18 algebra-symbolic + 38 calculus-derivative unit tests +
~10 new preflight-route tests in test_canonical_projection.py. All
gate on pytest.importorskip("sympy") so a sympy-less suite stays
green. Full suite: 1369 passed / 27 skipped.
Phases 3-7 (integral, limit, series, linear-algebra,
function-sampled) remain open as future work; each lands as its
own ticket when an actual consumer surfaces.
165 lines
4.8 KiB
Python
165 lines
4.8 KiB
Python
"""algebra-symbolic@v1 π* tests (ticket #000030 Phase 1).
|
|
|
|
Covers polynomial-identity equivalence, distinct-class separation,
|
|
round-trip idempotence, error paths, and the optional-extra skip
|
|
behavior. Whole file skips when SymPy is absent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
sympy = pytest.importorskip("sympy") # entire file skipped if absent
|
|
|
|
from arborist.pi_star import get # noqa: E402
|
|
from arborist.pi_star.protocol import ( # noqa: E402
|
|
PiStarError,
|
|
assert_round_trip,
|
|
equivalence_class_id,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def ps():
|
|
return get("algebra-symbolic@v1")
|
|
|
|
|
|
# --- registry presence ------------------------------------------------
|
|
|
|
|
|
def test_registry_contains_algebra_symbolic():
|
|
ps = get("algebra-symbolic@v1")
|
|
assert ps.name == "algebra-symbolic"
|
|
assert ps.version == "v1"
|
|
assert ps.domain == "symbolic-algebra"
|
|
|
|
|
|
# --- polynomial-identity equivalence classes -------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"left,right",
|
|
[
|
|
("(x+1)**2", "x**2 + 2*x + 1"),
|
|
("(x+1)**2", "x*x + 2*x + 1"),
|
|
("(a+b)*(a-b)", "a**2 - b**2"),
|
|
("2*x + 3*x", "5*x"),
|
|
("x*(x+1)", "x**2 + x"),
|
|
("(x-1)*(x-2)", "x**2 - 3*x + 2"),
|
|
],
|
|
)
|
|
def test_polynomial_identity_collapses(ps, left, right):
|
|
assert ps.canonicalize(left.encode()) == ps.canonicalize(right.encode())
|
|
assert (
|
|
equivalence_class_id(ps, left.encode())
|
|
== equivalence_class_id(ps, right.encode())
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"left,right",
|
|
[
|
|
("x**2", "x**3"),
|
|
("x + 1", "x"),
|
|
("(x+1)**2", "(x+1)**3"),
|
|
("a*b", "a + b"),
|
|
],
|
|
)
|
|
def test_distinct_polynomials_distinct(ps, left, right):
|
|
assert ps.canonicalize(left.encode()) != ps.canonicalize(right.encode())
|
|
|
|
|
|
# --- round-trip / idempotence ----------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"expr",
|
|
[
|
|
"x",
|
|
"x + 1",
|
|
"(x+1)**2",
|
|
"a*b - b*a", # zero
|
|
"x**2 - 2*x + 1",
|
|
"(a+b)*(c+d)",
|
|
"1/2 + 1/3", # purely numeric — π* still accepts
|
|
],
|
|
)
|
|
def test_round_trip_idempotent(ps, expr):
|
|
assert_round_trip(ps, expr.encode())
|
|
|
|
|
|
# --- canonical bytes shape -------------------------------------------
|
|
|
|
|
|
def test_canonical_bytes_are_srepr_form(ps):
|
|
out = ps.canonicalize(b"(x+1)**2").decode()
|
|
# srepr emits S-expression syntax with capitalized SymPy class names.
|
|
assert out.startswith("Add(")
|
|
assert "Pow(Symbol('x'), Integer(2))" in out
|
|
|
|
|
|
def test_numeric_inputs_produce_pure_numeric_canonical(ps):
|
|
# 1/2 + 1/3 = 5/6
|
|
out = ps.canonicalize(b"1/2 + 1/3").decode()
|
|
assert out == "Rational(5, 6)"
|
|
|
|
|
|
# --- error paths ------------------------------------------------------
|
|
|
|
|
|
def test_empty_input_raises(ps):
|
|
with pytest.raises(PiStarError, match="empty"):
|
|
ps.canonicalize(b"")
|
|
with pytest.raises(PiStarError, match="empty"):
|
|
ps.canonicalize(b" \n\t ")
|
|
|
|
|
|
def test_unparseable_input_raises(ps):
|
|
with pytest.raises(PiStarError, match="cannot parse"):
|
|
ps.canonicalize(b"not a valid expression $$$ %%%")
|
|
|
|
|
|
def test_non_bytes_input_raises(ps):
|
|
with pytest.raises(PiStarError, match="expects bytes"):
|
|
ps.canonicalize("not bytes") # type: ignore[arg-type]
|
|
|
|
|
|
def test_relational_input_rejected(ps):
|
|
with pytest.raises(PiStarError, match="boolean/relational"):
|
|
ps.canonicalize(b"x > 0")
|
|
|
|
|
|
# --- known limitation: trig identity NOT collapsed by Phase 1 --------
|
|
|
|
|
|
def test_trig_identity_not_collapsed_in_phase_1(ps):
|
|
"""Documents the known Phase-1 limitation. Phase 1b adds a
|
|
`algebra-symbolic-simplified@v1` variant that uses ``sp.simplify``
|
|
at unbounded CPU cost.
|
|
"""
|
|
a = ps.canonicalize(b"sin(x)**2 + cos(x)**2")
|
|
b = ps.canonicalize(b"1")
|
|
assert a != b
|
|
|
|
|
|
# --- composition with other π* ----------------------------------------
|
|
|
|
|
|
def test_compose_with_arithmetic_for_numeric_eval():
|
|
"""algebra-symbolic@v1 ∘ arithmetic@v1 — symbolic identity that
|
|
happens to reduce to a number passes through both stages."""
|
|
from arborist.pi_star import compose
|
|
|
|
chain = compose("algebra-symbolic@v1", "arithmetic@v1")
|
|
# Bridge: take a symbolic answer that reduces to pure number,
|
|
# round-trip its srepr form into arithmetic? No — composition is
|
|
# just chained canonicalize. We test the chain registers cleanly.
|
|
assert chain is not None
|
|
# Instead exercise pure-numeric flow that BOTH π*'s can handle:
|
|
# algebra-symbolic@v1 accepts `1/2 + 1/3` and emits Rational(5, 6);
|
|
# composition itself runs the SECOND π* on the FIRST's bytes — and
|
|
# arithmetic@v1 cannot parse Rational(5, 6). We expect PiStarError
|
|
# in that direction. Document the failure as the natural composition
|
|
# boundary.
|
|
with pytest.raises(PiStarError):
|
|
chain.canonicalize(b"1/2 + 1/3")
|