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:
russell@unturf.com 2026-05-09 12:35:18 -04:00
parent 97a4187845
commit 04f3f5d2a8
No known key found for this signature in database
14 changed files with 823 additions and 12 deletions

View file

@ -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

View file

@ -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

View 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())

View 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``
- ``/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())

View file

@ -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).

View file

@ -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",
})

View 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}

View 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"}

View file

@ -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 | — |

View file

@ -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.

View file

@ -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]

View file

@ -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",
[

View 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")

View 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