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
"""calculus-derivative@v1 π* tests (ticket #000030 Phase 2).
|
|
|
|
Covers JSON-shaped input contract, derivative correctness across
|
|
common shapes, n-th order, multi-variable handling, error paths,
|
|
and round-trip semantics. Whole file skips when SymPy is absent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
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 PiStarError # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def ps():
|
|
return get("calculus-derivative@v1")
|
|
|
|
|
|
@pytest.fixture
|
|
def algebra():
|
|
return get("algebra-symbolic@v1")
|
|
|
|
|
|
def _q(**kwargs) -> bytes:
|
|
return json.dumps(kwargs).encode("utf-8")
|
|
|
|
|
|
# --- registry presence ------------------------------------------------
|
|
|
|
|
|
def test_registry_contains_calculus_derivative():
|
|
ps = get("calculus-derivative@v1")
|
|
assert ps.name == "calculus-derivative"
|
|
assert ps.version == "v1"
|
|
assert ps.domain == "calculus"
|
|
|
|
|
|
# --- correctness via cross-check against algebra-symbolic@v1 ---------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"f,x,expected",
|
|
[
|
|
("x**2", "x", "2*x"),
|
|
("x**3", "x", "3*x**2"),
|
|
("x", "x", "1"),
|
|
("5", "x", "0"),
|
|
("a*x + b", "x", "a"),
|
|
("x*y", "x", "y"), # treats y as constant w.r.t. x
|
|
("x**2 + 3*x + 7", "x", "2*x + 3"),
|
|
("(x+1)**2", "x", "2*x + 2"),
|
|
],
|
|
)
|
|
def test_first_derivative_matches_expected(ps, algebra, f, x, expected):
|
|
got = ps.canonicalize(_q(f=f, x=x))
|
|
want = algebra.canonicalize(expected.encode())
|
|
assert got == want
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"f,x,n,expected",
|
|
[
|
|
("x**3", "x", 2, "6*x"),
|
|
("x**4", "x", 3, "24*x"),
|
|
("x**2", "x", 0, "x**2"), # n=0 returns the original (expanded)
|
|
("x**2", "x", 5, "0"),
|
|
],
|
|
)
|
|
def test_nth_derivative(ps, algebra, f, x, n, expected):
|
|
got = ps.canonicalize(_q(f=f, x=x, n=n))
|
|
want = algebra.canonicalize(expected.encode())
|
|
assert got == want
|
|
|
|
|
|
def test_trig_derivative(ps, algebra):
|
|
"""sin/cos derivatives go through sympy's symbolic engine."""
|
|
out = ps.canonicalize(_q(f="sin(x)", x="x")).decode()
|
|
assert "cos" in out and "Symbol('x')" in out
|
|
|
|
|
|
# --- equivalence classes preserved -----------------------------------
|
|
|
|
|
|
def test_equivalent_inputs_collapse(ps):
|
|
"""d/dx of equivalent expressions produces equivalent results."""
|
|
a = ps.canonicalize(_q(f="(x+1)**2", x="x"))
|
|
b = ps.canonicalize(_q(f="x**2 + 2*x + 1", x="x"))
|
|
assert a == b
|
|
|
|
|
|
# --- error paths ------------------------------------------------------
|
|
|
|
|
|
def test_non_bytes_input_raises(ps):
|
|
with pytest.raises(PiStarError, match="expects bytes"):
|
|
ps.canonicalize("not bytes") # type: ignore[arg-type]
|
|
|
|
|
|
def test_non_json_input_raises(ps):
|
|
with pytest.raises(PiStarError, match="JSON"):
|
|
ps.canonicalize(b"not json at all")
|
|
|
|
|
|
def test_non_object_json_raises(ps):
|
|
with pytest.raises(PiStarError, match="JSON object"):
|
|
ps.canonicalize(b'["x**2", "x"]')
|
|
with pytest.raises(PiStarError, match="JSON object"):
|
|
ps.canonicalize(b'"just a string"')
|
|
|
|
|
|
def test_missing_f_raises(ps):
|
|
with pytest.raises(PiStarError, match="missing field 'f'"):
|
|
ps.canonicalize(_q(x="x"))
|
|
|
|
|
|
def test_missing_x_raises(ps):
|
|
with pytest.raises(PiStarError, match="missing field 'x'"):
|
|
ps.canonicalize(_q(f="x**2"))
|
|
|
|
|
|
def test_empty_field_rejected(ps):
|
|
with pytest.raises(PiStarError, match="non-empty"):
|
|
ps.canonicalize(_q(f="", x="x"))
|
|
with pytest.raises(PiStarError, match="non-empty"):
|
|
ps.canonicalize(_q(f="x", x=""))
|
|
|
|
|
|
def test_non_string_field_rejected(ps):
|
|
with pytest.raises(PiStarError, match="non-empty string"):
|
|
ps.canonicalize(_q(f=42, x="x"))
|
|
|
|
|
|
@pytest.mark.parametrize("bad_n", [-1, 1.5, "1", True, False])
|
|
def test_n_must_be_non_negative_int(ps, bad_n):
|
|
with pytest.raises(PiStarError, match="non-negative integer"):
|
|
ps.canonicalize(_q(f="x**2", x="x", n=bad_n))
|
|
|
|
|
|
def test_unparseable_f_raises(ps):
|
|
with pytest.raises(PiStarError, match="cannot parse"):
|
|
ps.canonicalize(_q(f="$$$ % nonsense", x="x"))
|
|
|
|
|
|
def test_relational_f_rejected(ps):
|
|
with pytest.raises(PiStarError, match="boolean/relational"):
|
|
ps.canonicalize(_q(f="x > 0", x="x"))
|
|
|
|
|
|
# --- output is itself an algebra-symbolic@v1 valid input -------------
|
|
|
|
|
|
def test_output_canonicalizes_under_algebra_symbolic(ps, algebra):
|
|
"""The derivative output is an expression in srepr form. Feeding
|
|
it back into algebra-symbolic@v1 should be idempotent (it parses
|
|
srepr S-expressions natively)."""
|
|
raw = ps.canonicalize(_q(f="(x+1)**2", x="x"))
|
|
once_through_algebra = algebra.canonicalize(raw)
|
|
twice_through_algebra = algebra.canonicalize(once_through_algebra)
|
|
assert once_through_algebra == twice_through_algebra
|