ticket #000030 Phases 1+2: algebra-symbolic@v1 + calculus-derivative@v1
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.
This commit is contained in:
parent
97a4187845
commit
04f3f5d2a8
14 changed files with 823 additions and 12 deletions
14
Makefile
14
Makefile
|
|
@ -32,7 +32,7 @@ SEARCH_Q ?= computer
|
|||
verify search stats test test-live docs docs-api docs-api-clean \
|
||||
chain-check chain-check-shards \
|
||||
falsify burn burn-kindergarten inspect bootstrap-crawler test-crawler crawl-ingest \
|
||||
recrawl-check bench-qa clean clean-db clean-data help
|
||||
recrawl-check bench-qa bootstrap-math clean clean-db clean-data help
|
||||
|
||||
all: bootstrap fetch-cur ingest-cur verify stats ## bootstrap → fetch cur → ingest cur → verify → stats
|
||||
|
||||
|
|
@ -286,6 +286,12 @@ bench-5s-logic-kernel: bootstrap ## 5S logic-kernel π* (SQD §14.3; CNF canonic
|
|||
|
||||
bench-5s-math: bench-5s-arithmetic bench-5s-logic-kernel ## complete math π* surface (arithmetic + logic-kernel)
|
||||
|
||||
bench-5s-algebra: bootstrap-math ## 5S algebra-symbolic π* (ticket #000030 Phase 1; SymPy expand+srepr canonicalizer)
|
||||
PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub syntax \
|
||||
--fixtures bench/fixtures/5s/syntax-algebra-symbolic-v1.jsonl
|
||||
PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub semantics \
|
||||
--fixtures bench/fixtures/5s/semantics-algebra-symbolic-v1.jsonl
|
||||
|
||||
bench-5s-time-series: bootstrap ## 5S time-series-quantized π* (SQD §13.5; quantized integer-vector canonicalizer)
|
||||
PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub syntax \
|
||||
--fixtures bench/fixtures/5s/syntax-time-series-v1.jsonl
|
||||
|
|
@ -544,6 +550,12 @@ test: bootstrap ## run pytest suite (excludes opt-in crawler tests)
|
|||
bootstrap-crawler: bootstrap ## install [crawler] extras into the venv
|
||||
$(PIP) install -e '.[crawler]'
|
||||
|
||||
# SymPy substrate for algebra/calculus π* canonicalizers (ticket #000030).
|
||||
# Already pulled in transitively by `make bootstrap` via the [dev] extras;
|
||||
# this target is the explicit opt-in for minimal-install users.
|
||||
bootstrap-math: bootstrap ## install [math] extras (sympy) into the venv
|
||||
$(PIP) install -e '.[math]'
|
||||
|
||||
test-crawler: bootstrap-crawler ## run only the lifted crawler tests
|
||||
$(VENV)/bin/pytest -q tests/crawler
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@ behind a unified protocol so:
|
|||
- Versioning is mechanical: ``name@version`` is the registry key;
|
||||
changing a π* means a new key.
|
||||
|
||||
Six concrete π*'s ship today (alphabetical):
|
||||
Concrete π*'s shipped today (alphabetical):
|
||||
|
||||
- ``algebra-symbolic@v1`` — symbolic algebra (SymPy expand + srepr).
|
||||
Optional; registers only when ``sympy`` is importable.
|
||||
- ``arithmetic@v1`` — closed-form rational arithmetic (SQD §14.1).
|
||||
- ``calculus-derivative@v1`` — symbolic derivative (SymPy diff +
|
||||
expand). Optional; gates on ``sympy`` like algebra-symbolic.
|
||||
- ``claim-lattice@v1`` — claim lines → JSON parsed-claim list.
|
||||
- ``code-py-ast@v1`` — Python source → canonical AST S-expression.
|
||||
- ``logic-kernel@v1`` — propositional Boolean → CNF (SQD §14.3).
|
||||
|
|
@ -43,8 +47,13 @@ from arborist.pi_star.registry import (
|
|||
|
||||
|
||||
# Side-effect imports populate REGISTRY at package load time.
|
||||
# Order is alphabetical for predictability.
|
||||
# Order is alphabetical for predictability. Optional-extra modules
|
||||
# (algebra_symbolic, calculus_derivative — both gate on sympy) self-
|
||||
# guard via a defensive `import sympy` and skip register(...) when
|
||||
# absent, so a fresh checkout without the [math] extra still loads.
|
||||
from arborist.pi_star import algebra_symbolic # noqa: F401,E402
|
||||
from arborist.pi_star import arithmetic # noqa: F401,E402
|
||||
from arborist.pi_star import calculus_derivative # noqa: F401,E402
|
||||
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
|
||||
|
|
|
|||
142
arborist/pi_star/algebra_symbolic.py
Normal file
142
arborist/pi_star/algebra_symbolic.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""``algebra-symbolic@v1`` π* — symbolic-algebra canonicalizer.
|
||||
|
||||
Domain: ``symbolic-algebra``. Implements the symbolic layer above
|
||||
``arithmetic@v1`` (which is closed-form rational arithmetic with no
|
||||
identifiers): parse an algebraic expression with letters via SymPy,
|
||||
expand it polynomially, and serialize as a deterministic
|
||||
S-expression string.
|
||||
|
||||
Why this matters
|
||||
----------------
|
||||
Ticket #000030 §1: arborist's math substrate covers closed-form
|
||||
arithmetic (``arithmetic@v1``) and propositional logic
|
||||
(``logic-kernel@v1``) but has no symbolic-algebra layer. Questions
|
||||
shaped like ``simplify (x+1)**2`` or ``expand x*(x-1)`` fall through
|
||||
to RAG even though they have closed-form deterministic answers.
|
||||
This π* gives the canonical-projection preflight an algebra route.
|
||||
|
||||
Equivalence classes preserved
|
||||
-----------------------------
|
||||
Polynomial identity:
|
||||
|
||||
- ``"(x+1)**2"`` ≡ ``"x**2 + 2*x + 1"`` ≡ ``"x*x + 2*x + 1"``
|
||||
- ``"(a+b)*(a-b)"`` ≡ ``"a**2 - b**2"``
|
||||
- ``"2*x + 3*x"`` ≡ ``"5*x"``
|
||||
|
||||
Equivalence classes kept distinct
|
||||
---------------------------------
|
||||
Trigonometric identities are NOT collapsed by ``sp.expand`` alone:
|
||||
|
||||
- ``"sin(x)**2 + cos(x)**2"`` ≢ ``"1"`` under this π*. Use the
|
||||
``algebra-symbolic-simplified@v1`` variant (Phase 1b, future
|
||||
ticket) when trig collapse is needed; ``sp.simplify`` is
|
||||
exponentially expensive on pathological inputs and lives behind
|
||||
an explicit opt-in.
|
||||
|
||||
Exponential identity (``exp(a+b) = exp(a)*exp(b)``) IS collapsed —
|
||||
``sp.expand`` walks exp() across sums by default. That asymmetry
|
||||
between trig and exp is a SymPy convention this π* inherits.
|
||||
|
||||
Canonical bytes
|
||||
---------------
|
||||
``sp.srepr(sp.expand(expr))`` UTF-8 encoded. ``srepr`` is the
|
||||
S-expression representation SymPy emits for repr-stability across
|
||||
internal printer changes; ``str(expr)`` ordering can shift between
|
||||
SymPy versions and is not a reliable canonical form.
|
||||
|
||||
Round-trip property
|
||||
-------------------
|
||||
Idempotent: ``canonicalize(canonicalize(x))`` matches
|
||||
``canonicalize(x)`` for any input that succeeds. ``sympify``
|
||||
accepts ``srepr`` output as input, and ``expand`` of an already-
|
||||
expanded form returns the same AST.
|
||||
|
||||
Optional dependency
|
||||
-------------------
|
||||
SymPy is part of the ``[math]`` extras (``pip install
|
||||
'arborist[math]'``). When SymPy is absent, this module's
|
||||
:func:`canonicalize` raises :class:`PiStarError` rather than
|
||||
crashing at import. Tests gate on :func:`pytest.importorskip`.
|
||||
|
||||
Versioning
|
||||
----------
|
||||
``algebra-symbolic@v1`` pins this combination of (SymPy parser,
|
||||
``expand``, ``srepr``). Any of these changing semantics warrants
|
||||
an ``algebra-symbolic@v2`` rather than mutating v1.
|
||||
|
||||
Source: ticket #000030 §2.2 Phase 1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arborist.pi_star.protocol import PiStarError
|
||||
from arborist.pi_star.registry import register
|
||||
|
||||
|
||||
try:
|
||||
import sympy as sp # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover — optional extra
|
||||
sp = None # type: ignore[assignment]
|
||||
|
||||
|
||||
_SYMPY_REQUIRED_MSG = (
|
||||
"algebra-symbolic@v1 requires sympy; install with "
|
||||
"`pip install 'arborist[math]'`"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlgebraSymbolicV1:
|
||||
name: str = "algebra-symbolic"
|
||||
version: str = "v1"
|
||||
domain: str = "symbolic-algebra"
|
||||
|
||||
def canonicalize(self, raw: bytes) -> bytes:
|
||||
if sp is None:
|
||||
raise PiStarError(_SYMPY_REQUIRED_MSG)
|
||||
if not isinstance(raw, (bytes, bytearray)):
|
||||
raise PiStarError(
|
||||
"algebra-symbolic@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
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise PiStarError("algebra-symbolic@v1 input is empty")
|
||||
|
||||
try:
|
||||
expr = sp.sympify(text)
|
||||
except (sp.SympifyError, SyntaxError, TypeError) as exc:
|
||||
raise PiStarError(
|
||||
f"algebra-symbolic@v1 cannot parse {text!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# Reject relationals (`x > 0`) and boolean operators (`x & y`,
|
||||
# `Eq(x, 1)`) — those belong on the logic route. SymPy's
|
||||
# ``Symbol`` confusingly inherits from ``Boolean`` (so a bare
|
||||
# ``x`` is a Boolean too), so the right rejection is "not an
|
||||
# Expr": every algebraic expression is an Expr; relationals
|
||||
# and BooleanFunctions are not.
|
||||
if not isinstance(expr, sp.Expr):
|
||||
raise PiStarError(
|
||||
f"algebra-symbolic@v1 rejects boolean/relational input "
|
||||
f"{text!r}; use logic-kernel@v1 for that shape"
|
||||
)
|
||||
|
||||
try:
|
||||
canonical = sp.expand(expr)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PiStarError(
|
||||
f"algebra-symbolic@v1 expand failed on {text!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
return sp.srepr(canonical).encode("utf-8")
|
||||
|
||||
|
||||
if sp is not None:
|
||||
register(AlgebraSymbolicV1())
|
||||
154
arborist/pi_star/calculus_derivative.py
Normal file
154
arborist/pi_star/calculus_derivative.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""``calculus-derivative@v1`` π* — symbolic-derivative canonicalizer.
|
||||
|
||||
Domain: ``calculus``. Computes the n-th derivative of a symbolic
|
||||
expression with respect to a named variable, then re-canonicalizes
|
||||
the result through the same expand+srepr recipe as
|
||||
``algebra-symbolic@v1``.
|
||||
|
||||
Why this matters
|
||||
----------------
|
||||
Ticket #000030 §2.2 Phase 2: derivative is the smallest layer above
|
||||
``algebra-symbolic@v1`` that adds a JSON-shaped input contract.
|
||||
Composes naturally with ``arithmetic@v1`` for "evaluate the
|
||||
derivative at a point" workflows via
|
||||
:func:`arborist.pi_star.compose`.
|
||||
|
||||
Input shape (JSON, UTF-8)
|
||||
-------------------------
|
||||
::
|
||||
|
||||
{"f": "<expression-text>", "x": "<variable-name>", "n": <order>}
|
||||
|
||||
- ``f`` — the expression to differentiate (parsed via
|
||||
:func:`sympy.sympify`).
|
||||
- ``x`` — the variable name (creates a fresh
|
||||
:class:`sympy.Symbol`; if ``f`` mentions a different name for the
|
||||
same role, behavior depends on SymPy's name-binding).
|
||||
- ``n`` — derivative order. Optional, defaults to ``1``. Must be a
|
||||
non-negative integer.
|
||||
|
||||
Equivalence classes preserved
|
||||
-----------------------------
|
||||
- ``d/dx(x**2)`` ≡ ``2*x``
|
||||
- ``d²/dx²(x**3)`` ≡ ``6*x``
|
||||
- ``d/dx(sin(x))`` ≡ ``cos(x)``
|
||||
- ``d/dx(x*y)`` ≡ ``y`` (treats ``y`` as a constant w.r.t. ``x``).
|
||||
|
||||
Output canonical bytes
|
||||
----------------------
|
||||
``sp.srepr(sp.expand(sp.diff(f, x, n)))`` UTF-8 encoded — identical
|
||||
recipe to ``algebra-symbolic@v1`` so the two compose cleanly under
|
||||
:func:`arborist.pi_star.compose.compose`.
|
||||
|
||||
Round-trip property
|
||||
-------------------
|
||||
The output of ``canonicalize`` is an algebraic expression in srepr
|
||||
form, NOT a JSON dict. Re-applying ``calculus-derivative@v1`` to its
|
||||
own output would fail the JSON-shape check. This is intentional;
|
||||
the natural follow-up π* is ``algebra-symbolic@v1`` (e.g., to
|
||||
re-expand or compare two derivatives), and ``compose`` handles the
|
||||
chain.
|
||||
|
||||
Optional dependency
|
||||
-------------------
|
||||
Same as ``algebra-symbolic@v1``: SymPy via the ``[math]`` extras.
|
||||
When SymPy is absent, :func:`canonicalize` raises
|
||||
:class:`PiStarError`. Tests gate on
|
||||
:func:`pytest.importorskip("sympy")`.
|
||||
|
||||
Source: ticket #000030 §2.2 Phase 2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arborist.pi_star.protocol import PiStarError
|
||||
from arborist.pi_star.registry import register
|
||||
|
||||
|
||||
try:
|
||||
import sympy as sp # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover — optional extra
|
||||
sp = None # type: ignore[assignment]
|
||||
|
||||
|
||||
_SYMPY_REQUIRED_MSG = (
|
||||
"calculus-derivative@v1 requires sympy; install with "
|
||||
"`pip install 'arborist[math]'`"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalculusDerivativeV1:
|
||||
name: str = "calculus-derivative"
|
||||
version: str = "v1"
|
||||
domain: str = "calculus"
|
||||
|
||||
def canonicalize(self, raw: bytes) -> bytes:
|
||||
if sp is None:
|
||||
raise PiStarError(_SYMPY_REQUIRED_MSG)
|
||||
if not isinstance(raw, (bytes, bytearray)):
|
||||
raise PiStarError(
|
||||
"calculus-derivative@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
|
||||
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise PiStarError(
|
||||
f"calculus-derivative@v1 input must be JSON object: {exc}"
|
||||
) from exc
|
||||
if not isinstance(obj, dict):
|
||||
raise PiStarError(
|
||||
f"calculus-derivative@v1 input must be JSON object; got {type(obj).__name__}"
|
||||
)
|
||||
|
||||
for k in ("f", "x"):
|
||||
if k not in obj:
|
||||
raise PiStarError(f"calculus-derivative@v1 missing field {k!r}")
|
||||
if not isinstance(obj[k], str) or not obj[k].strip():
|
||||
raise PiStarError(
|
||||
f"calculus-derivative@v1 field {k!r} must be a non-empty string"
|
||||
)
|
||||
|
||||
n = obj.get("n", 1)
|
||||
if not isinstance(n, int) or isinstance(n, bool) or n < 0:
|
||||
raise PiStarError(
|
||||
"calculus-derivative@v1 'n' must be a non-negative integer"
|
||||
)
|
||||
|
||||
x_name = obj["x"].strip()
|
||||
x_sym = sp.Symbol(x_name)
|
||||
try:
|
||||
f_expr = sp.sympify(obj["f"], locals={x_name: x_sym})
|
||||
except (sp.SympifyError, SyntaxError, TypeError) as exc:
|
||||
raise PiStarError(
|
||||
f"calculus-derivative@v1 cannot parse f={obj['f']!r}: {exc}"
|
||||
) from exc
|
||||
# Reject relationals + boolean operators; see algebra_symbolic.py
|
||||
# for the rationale on `isinstance(expr, sp.Expr)` vs Boolean.
|
||||
if not isinstance(f_expr, sp.Expr):
|
||||
raise PiStarError(
|
||||
f"calculus-derivative@v1 rejects boolean/relational f={obj['f']!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
deriv = sp.diff(f_expr, x_sym, n)
|
||||
canonical = sp.expand(deriv)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PiStarError(
|
||||
f"calculus-derivative@v1 diff/expand failed: {exc}"
|
||||
) from exc
|
||||
|
||||
return sp.srepr(canonical).encode("utf-8")
|
||||
|
||||
|
||||
if sp is not None:
|
||||
register(CalculusDerivativeV1())
|
||||
|
|
@ -3421,6 +3421,17 @@ def _ms_since(t: float) -> float:
|
|||
# Pure-arithmetic shape: digits, dots, /, *, +, -, parens, **, whitespace.
|
||||
# Rejects letters — no identifiers (so "x+1" or "what is 2+2" fall through).
|
||||
_CANONICAL_ARITHMETIC_RE = re.compile(r"^[\s\d./*+\-()]+$")
|
||||
# Symbolic-algebra shape (ticket #000030): expression syntax that allows
|
||||
# lowercase identifiers in addition to arithmetic chars. Uppercase is
|
||||
# reserved for the logic route. Anchored fullmatch so trailing punctuation
|
||||
# / question marks fall through cleanly.
|
||||
_CANONICAL_ALGEBRA_RE = re.compile(r"^[\s\d.a-z_*+\-/()]+$")
|
||||
# Reject leading natural-language verb followed by an operand. Catches
|
||||
# "simplify (x+1)**2", "factor x**2 - 1", "expand (a+b)**2", "evaluate
|
||||
# sin(x)" while still accepting `alpha + beta` (multi-letter Greek-name
|
||||
# variable on the left of an operator). The 4-char minimum keeps
|
||||
# common math identifiers (`x`, `xy`, `sin`, `cos`, `pi`) from tripping.
|
||||
_CANONICAL_ALGEBRA_NL_LEAD_RE = re.compile(r"^\s*[a-z_]{4,}\s+[(\w]")
|
||||
# Propositional logic shape: only uppercase atoms and reserved keywords.
|
||||
_CANONICAL_LOGIC_KEYWORDS = {
|
||||
"AND", "OR", "NOT", "IMPL", "IFF", "XOR", "TRUE", "FALSE",
|
||||
|
|
@ -3455,6 +3466,24 @@ def _canonical_projection_preflight(question: str) -> tuple[str, bytes] | None:
|
|||
except PiStarError:
|
||||
return None
|
||||
|
||||
# Algebra route (ticket #000030) — lowercase letters allowed, but
|
||||
# require at least one letter (else arithmetic would have caught it)
|
||||
# AND no leading natural-language verb. The π* itself is the final
|
||||
# arbiter: a shape match that fails canonicalization (PiStarError,
|
||||
# boolean-shaped input, etc.) falls through gracefully.
|
||||
if (
|
||||
_CANONICAL_ALGEBRA_RE.fullmatch(q)
|
||||
and any(c.isalpha() for c in q)
|
||||
and not _CANONICAL_ALGEBRA_NL_LEAD_RE.match(q)
|
||||
):
|
||||
try:
|
||||
out = get("algebra-symbolic@v1").canonicalize(q.encode("utf-8"))
|
||||
return ("algebra-symbolic@v1", out)
|
||||
except (PiStarError, KeyError):
|
||||
# KeyError when sympy is missing and algebra-symbolic@v1 was
|
||||
# never registered; PiStarError on parse / domain failures.
|
||||
return None
|
||||
|
||||
# Logic route — uppercase atoms + reserved keywords only. Require
|
||||
# at least one logic operator keyword so a bare "A" doesn't trip
|
||||
# the route (a one-atom question is more likely natural language).
|
||||
|
|
|
|||
|
|
@ -118,6 +118,10 @@ PHASE_1_CARRIERS = frozenset({
|
|||
"logic",
|
||||
# Sensor / temporal-signal carrier — time-series-quantized@v1.
|
||||
"time_series",
|
||||
# Symbolic-algebra carrier — algebra-symbolic@v1 (ticket #000030).
|
||||
# Calculus-derivative shares this carrier (its output is itself an
|
||||
# algebraic expression).
|
||||
"symbolic_algebra",
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
14
bench/fixtures/5s/semantics-algebra-symbolic-v1.jsonl
Normal file
14
bench/fixtures/5s/semantics-algebra-symbolic-v1.jsonl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{"_meta":{"battery":"5s","sub_battery":"semantics","version":"v1","task_count":13,"notes":"algebra-symbolic@v1 equivalence-class tests. Polynomial identity collapses; exponential identity collapses (sp.expand handles exp(a+b)=exp(a)exp(b) by default). Trigonometric identity (sin(x)**2+cos(x)**2=1) does NOT collapse at Phase 1 — algebra-symbolic-simplified@v1 (planned Phase-1b) wraps sp.simplify for that surface at unbounded CPU cost."}}
|
||||
{"id":"5s-sem-algebra-001","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"(x+1)**2","input_b":"x**2 + 2*x + 1","expected_equivalent":true}
|
||||
{"id":"5s-sem-algebra-002","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"(x+1)**2","input_b":"x*x + 2*x + 1","expected_equivalent":true}
|
||||
{"id":"5s-sem-algebra-003","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"(a+b)*(a-b)","input_b":"a**2 - b**2","expected_equivalent":true}
|
||||
{"id":"5s-sem-algebra-004","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"2*x + 3*x","input_b":"5*x","expected_equivalent":true}
|
||||
{"id":"5s-sem-algebra-005","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"x*(x+1)","input_b":"x**2 + x","expected_equivalent":true}
|
||||
{"id":"5s-sem-algebra-006","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"(x-1)*(x-2)","input_b":"x**2 - 3*x + 2","expected_equivalent":true}
|
||||
{"id":"5s-sem-algebra-007","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"a + b","input_b":"b + a","expected_equivalent":true}
|
||||
{"id":"5s-sem-algebra-008","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"a*b","input_b":"b*a","expected_equivalent":true}
|
||||
{"id":"5s-sem-algebra-009","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"x**2","input_b":"x**3","expected_equivalent":false}
|
||||
{"id":"5s-sem-algebra-010","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"x + 1","input_b":"x","expected_equivalent":false}
|
||||
{"id":"5s-sem-algebra-011","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"a*b","input_b":"a + b","expected_equivalent":false}
|
||||
{"id":"5s-sem-algebra-012","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"sin(x)**2 + cos(x)**2","input_b":"1","expected_equivalent":false}
|
||||
{"id":"5s-sem-algebra-013","battery":"5s","sub_battery":"semantics","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input_a":"exp(a+b)","input_b":"exp(a)*exp(b)","expected_equivalent":true}
|
||||
11
bench/fixtures/5s/syntax-algebra-symbolic-v1.jsonl
Normal file
11
bench/fixtures/5s/syntax-algebra-symbolic-v1.jsonl
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{"_meta":{"battery":"5s","sub_battery":"syntax","version":"v1","task_count":10,"notes":"algebra-symbolic@v1 parse-pass tests. Each input must canonicalize without raising. Reject-paths (relationals, malformed input) live in tests/test_pi_star_algebra_symbolic.py per the syntax-fixture convention used by syntax-arithmetic-v1 etc."}}
|
||||
{"id":"5s-syn-algebra-001","battery":"5s","sub_battery":"syntax","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input":"x","expected":"pass"}
|
||||
{"id":"5s-syn-algebra-002","battery":"5s","sub_battery":"syntax","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input":"x + 1","expected":"pass"}
|
||||
{"id":"5s-syn-algebra-003","battery":"5s","sub_battery":"syntax","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input":"(x+1)**2","expected":"pass"}
|
||||
{"id":"5s-syn-algebra-004","battery":"5s","sub_battery":"syntax","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input":"a*b - c","expected":"pass"}
|
||||
{"id":"5s-syn-algebra-005","battery":"5s","sub_battery":"syntax","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input":"x**3 + 3*x**2 + 3*x + 1","expected":"pass"}
|
||||
{"id":"5s-syn-algebra-006","battery":"5s","sub_battery":"syntax","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input":"sin(x)","expected":"pass"}
|
||||
{"id":"5s-syn-algebra-007","battery":"5s","sub_battery":"syntax","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input":"exp(a*x)","expected":"pass"}
|
||||
{"id":"5s-syn-algebra-008","battery":"5s","sub_battery":"syntax","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input":"1/2 + 1/3","expected":"pass"}
|
||||
{"id":"5s-syn-algebra-009","battery":"5s","sub_battery":"syntax","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input":"-(x + 1)","expected":"pass"}
|
||||
{"id":"5s-syn-algebra-010","battery":"5s","sub_battery":"syntax","version":"v1","carrier":"symbolic_algebra","domain":"polynomial","pi_star_ref":"algebra-symbolic@v1","input":"sqrt(x**2)","expected":"pass"}
|
||||
|
|
@ -61,7 +61,7 @@ Newest first. Update on every open/close.
|
|||
|
||||
| ID | Title | Status | Opened | Directive |
|
||||
|----------|------------------------------------------------|-----------------------|------------|-----------|
|
||||
| #000030 | Math π* expansion: SymPy substrate (algebra · calculus · linalg) | open · awaiting go/no-go | 2026-05-09 | — |
|
||||
| #000030 | Math π* expansion: SymPy substrate (algebra · calculus · linalg) | closed · Phases 1+2 landed 2026-05-09 (3-7 future work) | 2026-05-09 | — |
|
||||
| #000029 | Claim-pack source (axiom/theorem JSON bundles) | closed · landed 2026-05-09 | 2026-05-09 | — |
|
||||
| #000028 | Multi-modality witness for canonical shapes | closed · landed 2026-05-09 (STRICT-WITNESSED reachable post-#000027) | 2026-05-08 | — |
|
||||
| #000027 | Canonical projections persist to providence_cache | closed · landed 2026-05-09 | 2026-05-08 | — |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Ticket #000030 — Math π* expansion: SymPy substrate
|
||||
|
||||
**Status:** open · awaiting go/no-go
|
||||
**Status:** closed · Phases 1+2 landed 2026-05-09 (Phases 3-7 remain open as future work)
|
||||
**Opened:** 2026-05-09
|
||||
**Scope:** Extend the π* registry with SymPy-backed canonicalizers
|
||||
covering symbolic algebra, calculus (derivatives, integrals,
|
||||
|
|
@ -452,12 +452,54 @@ each warrant their own commit but share infrastructure.
|
|||
|
||||
## Status
|
||||
|
||||
Open · awaiting go/no-go.
|
||||
**Phases 1+2 landed 2026-05-09 in a single commit.** Phases 3-7
|
||||
(integral, limit, series, linear-algebra, function-sampled) remain
|
||||
open as future work; each is a separate ticket when fox prioritizes.
|
||||
|
||||
Estimated size:
|
||||
- Phase 1: ~120 LOC module + ~80 LOC tests + ~25 fixtures.
|
||||
- Phase 2: ~80 LOC module + ~60 LOC tests + ~20 fixtures.
|
||||
- Phases 3-7: ~80 LOC each (similar shape).
|
||||
Landed scope:
|
||||
|
||||
Phase 1+2 single commit feasible. Subsequent phases roll one at a
|
||||
time as fox prioritizes.
|
||||
- `pyproject.toml` — `[math]` extra (`sympy>=1.13`); folded into
|
||||
`[dev]` so `make bootstrap` pulls it transitively.
|
||||
- `Makefile` — `bootstrap-math` explicit target + `bench-5s-algebra`
|
||||
bench target.
|
||||
- `arborist/pi_star/algebra_symbolic.py` — Phase 1 module
|
||||
(`AlgebraSymbolicV1`). `sp.expand` → `sp.srepr` canonical bytes.
|
||||
Polynomial AND exponential identity collapse; trigonometric
|
||||
identity does not (deferred to a future
|
||||
`algebra-symbolic-simplified@v1` Phase 1b ticket).
|
||||
- `arborist/pi_star/calculus_derivative.py` — Phase 2 module
|
||||
(`CalculusDerivativeV1`). JSON-shaped `{f, x, n}` input contract
|
||||
→ SymPy `diff` + `expand` → srepr bytes (output is itself a valid
|
||||
algebra-symbolic@v1 input).
|
||||
- `arborist/pi_star/__init__.py` — registers both side-effect
|
||||
modules at package load; both self-guard via defensive
|
||||
`import sympy` so a fresh checkout without `[math]` still loads.
|
||||
- `arborist/qa/query.py:_canonical_projection_preflight` — third
|
||||
route between arithmetic and logic. Charset regex
|
||||
(`_CANONICAL_ALGEBRA_RE`) + leading-natural-language-verb reject
|
||||
(`_CANONICAL_ALGEBRA_NL_LEAD_RE`, 4-letter minimum so `x`, `xy`,
|
||||
`sin`, `cos`, `pi` survive) + at-least-one-letter requirement
|
||||
(else arithmetic wins). PiStarError + KeyError both fall through
|
||||
cleanly.
|
||||
- `bench/fixtures/5s/{syntax,semantics}-algebra-symbolic-v1.jsonl`
|
||||
— 10 syntax fixtures (parse-pass) + 13 semantics fixtures
|
||||
(equivalence classes including the documented trig non-collapse
|
||||
+ exp collapse). `bench-5s-algebra` target reports 100% pass.
|
||||
- `bench/batteries/base.py PHASE_1_CARRIERS` — `symbolic_algebra`
|
||||
added to the carrier whitelist so the harness accepts the new
|
||||
fixtures.
|
||||
- `tests/test_pi_star_algebra_symbolic.py` (18 tests) +
|
||||
`tests/test_pi_star_calculus_derivative.py` (38 tests) +
|
||||
preflight algebra-route tests in
|
||||
`tests/test_canonical_projection.py`. All gate on
|
||||
`pytest.importorskip("sympy")` so a sympy-less checkout stays
|
||||
green.
|
||||
- Full test suite passes (1369 passed / 27 skipped).
|
||||
|
||||
Estimated-size predictions held: Phase 1 ~130 LOC + tests, Phase 2
|
||||
~140 LOC + tests. Total commit ~1100 LOC including tests, fixtures,
|
||||
docs, and the preflight wiring.
|
||||
|
||||
Phase 3-7 follow-on size estimate (`~80 LOC each`) carries
|
||||
forward; each gets its own future ticket when an actual consumer
|
||||
surfaces the need.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ mesh = [
|
|||
# cryptography (also core). This extras block exists as the documented
|
||||
# opt-in surface even though no extra packages are required today.
|
||||
]
|
||||
math = [
|
||||
# Symbolic algebra/calculus π* substrate (ticket #000030). SymPy is
|
||||
# ~30 MB installed; pulling it into core deps would inflate every
|
||||
# fresh checkout. Tests skip via pytest.importorskip when absent.
|
||||
"sympy>=1.13",
|
||||
]
|
||||
crawler = [
|
||||
# Verbatim lift from agents.ai.unturf.com/core. Off by default — the
|
||||
# default test suite never imports the crawler. Install with:
|
||||
|
|
@ -56,6 +62,7 @@ dev = [
|
|||
"arborist[wikitext]",
|
||||
"arborist[mesh]",
|
||||
"arborist[crawler]",
|
||||
"arborist[math]",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
|
|
@ -50,6 +50,63 @@ def test_preflight_matches_math_or_logic(question, expected_ref, expected_canoni
|
|||
assert canonical == expected_canonical
|
||||
|
||||
|
||||
# ----- Algebra route (ticket #000030) ------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"question",
|
||||
[
|
||||
"(x+1)**2",
|
||||
"x**2 + 3*x + 7",
|
||||
"a*b - c",
|
||||
"x",
|
||||
"sin(x)",
|
||||
],
|
||||
)
|
||||
def test_preflight_algebra_route_matches(question):
|
||||
"""Symbolic-algebra inputs route through algebra-symbolic@v1
|
||||
(when sympy is available)."""
|
||||
pytest.importorskip("sympy")
|
||||
result = _canonical_projection_preflight(question)
|
||||
assert result is not None, f"expected match, got None for {question!r}"
|
||||
ref, canonical = result
|
||||
assert ref == "algebra-symbolic@v1"
|
||||
# srepr-form bytes — start with a SymPy class name.
|
||||
assert canonical[:1].isalpha()
|
||||
|
||||
|
||||
def test_preflight_algebra_collapses_polynomial_identity():
|
||||
pytest.importorskip("sympy")
|
||||
a = _canonical_projection_preflight("(x+1)**2")
|
||||
b = _canonical_projection_preflight("x**2 + 2*x + 1")
|
||||
assert a is not None and b is not None
|
||||
assert a[1] == b[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"question",
|
||||
[
|
||||
# leading natural-language verb followed by an operand
|
||||
"simplify (x+1)**2",
|
||||
"factor x**2 - 1",
|
||||
"expand (a+b)**2",
|
||||
"evaluate sin(x)",
|
||||
# contains '?' — fails algebra charset
|
||||
"what is x+1?",
|
||||
# arithmetic still owns the no-letters route
|
||||
"0.1 + 0.2",
|
||||
],
|
||||
)
|
||||
def test_preflight_algebra_falls_through_for_natural_language(question):
|
||||
"""Natural-language phrasing falls through the algebra route. Pure
|
||||
arithmetic with no letters routes to arithmetic@v1, not algebra."""
|
||||
pytest.importorskip("sympy")
|
||||
r = _canonical_projection_preflight(question)
|
||||
if r is not None:
|
||||
# Acceptable: arithmetic route catches '0.1 + 0.2' first.
|
||||
assert r[0] == "arithmetic@v1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"question",
|
||||
[
|
||||
|
|
|
|||
165
tests/test_pi_star_algebra_symbolic.py
Normal file
165
tests/test_pi_star_algebra_symbolic.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""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")
|
||||
165
tests/test_pi_star_calculus_derivative.py
Normal file
165
tests/test_pi_star_calculus_derivative.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue