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.
329 lines
11 KiB
Python
329 lines
11 KiB
Python
"""Tests for the canonical-projection preflight + ``arborist canon`` CLI.
|
|
|
|
Two surfaces, one substrate. The π* registry already has unit-level
|
|
coverage in ``tests/test_pi_star.py``; these tests exercise the ways
|
|
those π*'s become reachable from the user — without RAG, without an
|
|
LLM, and without a shard.
|
|
|
|
- ``_canonical_projection_preflight`` — the sniff + π* dispatch
|
|
helper inside ``arborist.qa.query``.
|
|
- ``query()`` short-circuit path — math/logic-shaped questions return
|
|
``status="canonical_projection"`` before retrieval starts.
|
|
- ``arborist canon ...`` CLI subcommand — direct π* call.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from arborist.qa.client import StubClient
|
|
from arborist.qa.query import _canonical_projection_preflight, query
|
|
|
|
|
|
# ----- preflight helper ---------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"question,expected_ref,expected_canonical",
|
|
[
|
|
("0.1 + 0.2", "arithmetic@v1", b"3/10"),
|
|
("0.3", "arithmetic@v1", b"3/10"),
|
|
("1/3 + 1/6", "arithmetic@v1", b"1/2"),
|
|
("6/4", "arithmetic@v1", b"3/2"),
|
|
("(2+3)*4", "arithmetic@v1", b"20/1"),
|
|
("A IMPL B", "logic-kernel@v1", b"(NOT A OR B)"),
|
|
("(NOT B) IMPL (NOT A)", "logic-kernel@v1", b"(NOT A OR B)"),
|
|
("A OR NOT A", "logic-kernel@v1", b"TRUE"),
|
|
("NOT NOT A", "logic-kernel@v1", b"A"),
|
|
],
|
|
)
|
|
def test_preflight_matches_math_or_logic(question, expected_ref, expected_canonical):
|
|
result = _canonical_projection_preflight(question)
|
|
assert result is not None, f"expected match, got None for {question!r}"
|
|
ref, canonical = result
|
|
assert ref == expected_ref
|
|
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",
|
|
[
|
|
# natural language → no match
|
|
"what is 0.1 + 0.2?",
|
|
"tell me about the capital of france",
|
|
"is A IMPL B the same as B IMPL A",
|
|
# mixed case (lowercase letters) → no logic match
|
|
"a IMPL b",
|
|
# bare uppercase atom without operator → no logic match
|
|
"A",
|
|
"B",
|
|
# empty / whitespace
|
|
"",
|
|
" ",
|
|
],
|
|
)
|
|
def test_preflight_falls_through_for_natural_language(question):
|
|
assert _canonical_projection_preflight(question) is None
|
|
|
|
|
|
def test_preflight_returns_none_on_pi_star_error():
|
|
"""Shape-matched but π*-rejected input falls through gracefully.
|
|
|
|
``arithmetic@v1`` rejects identifiers; a string that LOOKS
|
|
arithmetic but contains nothing parseable should not crash query().
|
|
"""
|
|
# Empty parens — passes the regex shape but arithmetic@v1 rejects.
|
|
assert _canonical_projection_preflight("()") is None
|
|
|
|
|
|
# ----- query() short-circuit ----------------------------------------------
|
|
|
|
|
|
def test_query_math_short_circuits_with_no_shards(tmp_path: Path):
|
|
"""Math-shaped question persists a canonical row + answers without
|
|
needing a shard or hitting the LLM. Status is cache_miss_then_written
|
|
(first call writes the row); audit_mode/verifier_method are the
|
|
canonical-projection tokens; cache_key is real."""
|
|
qa_db = tmp_path / "qa.db"
|
|
no_shards = tmp_path / "shards" # absent on purpose
|
|
result = query(
|
|
question="0.1 + 0.2",
|
|
qa_db=qa_db,
|
|
chat_client=StubClient(""),
|
|
model_id="test/model",
|
|
shards_dir=no_shards,
|
|
)
|
|
assert result["status"] == "cache_miss_then_written"
|
|
assert result["audit_mode"] == "CANONICAL_PROJECTION"
|
|
assert result["verifier_method"] == "canonical_projection"
|
|
assert result["pi_star_ref"] == "arithmetic@v1"
|
|
assert result["answer_text"] == "3/10"
|
|
assert result["lookup_path"] == "canonical_cache_miss"
|
|
assert result["sources"] == []
|
|
assert result["cache_key"] is not None
|
|
assert result["audit_event_hash"] is not None
|
|
assert result["run_dag_root"] is not None
|
|
|
|
|
|
def test_query_logic_short_circuits(tmp_path: Path):
|
|
qa_db = tmp_path / "qa.db"
|
|
no_shards = tmp_path / "shards"
|
|
result = query(
|
|
question="A IMPL B",
|
|
qa_db=qa_db,
|
|
chat_client=StubClient(""),
|
|
model_id="test/model",
|
|
shards_dir=no_shards,
|
|
)
|
|
assert result["status"] == "cache_miss_then_written"
|
|
assert result["pi_star_ref"] == "logic-kernel@v1"
|
|
assert result["answer_text"] == "(NOT A OR B)"
|
|
|
|
|
|
def test_query_canonical_transient_mode(tmp_path: Path):
|
|
"""Operators can disable persistence per-call via
|
|
policy['canonical_projection_preflight_persist']=False; the legacy
|
|
transient render-only behavior comes back (status='canonical_projection',
|
|
cache_key=None, no audit-chain entry)."""
|
|
qa_db = tmp_path / "qa.db"
|
|
no_shards = tmp_path / "shards"
|
|
result = query(
|
|
question="0.1 + 0.2",
|
|
qa_db=qa_db,
|
|
chat_client=StubClient(""),
|
|
model_id="test/model",
|
|
shards_dir=no_shards,
|
|
policy={"canonical_projection_preflight_persist": False},
|
|
)
|
|
assert result["status"] == "canonical_projection"
|
|
assert result["lookup_path"] == "preflight_canonical"
|
|
assert result["cache_key"] is None
|
|
|
|
|
|
def test_query_contrapositive_collapses_to_same_canonical(tmp_path: Path):
|
|
"""A IMPL B and (NOT B) IMPL (NOT A) are the same equivalence class."""
|
|
qa_db = tmp_path / "qa.db"
|
|
no_shards = tmp_path / "shards"
|
|
a = query(
|
|
question="A IMPL B",
|
|
qa_db=qa_db,
|
|
chat_client=StubClient(""),
|
|
model_id="m",
|
|
shards_dir=no_shards,
|
|
)
|
|
b = query(
|
|
question="(NOT B) IMPL (NOT A)",
|
|
qa_db=qa_db,
|
|
chat_client=StubClient(""),
|
|
model_id="m",
|
|
shards_dir=no_shards,
|
|
)
|
|
assert a["answer_text"] == b["answer_text"] == "(NOT A OR B)"
|
|
|
|
|
|
def test_query_natural_language_falls_through_to_rag(tmp_path: Path):
|
|
"""Natural-language question doesn't trigger the preflight; goes
|
|
to RAG and (with no corpus) returns no_sources."""
|
|
qa_db = tmp_path / "qa.db"
|
|
no_shards = tmp_path / "shards"
|
|
result = query(
|
|
question="what is the capital of france?",
|
|
qa_db=qa_db,
|
|
chat_client=StubClient(""),
|
|
model_id="m",
|
|
shards_dir=no_shards,
|
|
)
|
|
# Falls through — preflight didn't match, RAG runs, finds no shards.
|
|
assert result["status"] != "canonical_projection"
|
|
assert result.get("audit_mode") != "CANONICAL_PROJECTION"
|
|
|
|
|
|
def test_query_canonical_preflight_disable_via_policy(tmp_path: Path):
|
|
"""policy['canonical_projection_preflight']=False disables the
|
|
short-circuit even on math-shaped input."""
|
|
qa_db = tmp_path / "qa.db"
|
|
no_shards = tmp_path / "shards"
|
|
result = query(
|
|
question="0.1 + 0.2",
|
|
qa_db=qa_db,
|
|
chat_client=StubClient(""),
|
|
model_id="m",
|
|
shards_dir=no_shards,
|
|
policy={"canonical_projection_preflight": False},
|
|
)
|
|
assert result["status"] != "canonical_projection"
|
|
|
|
|
|
# ----- `arborist canon` CLI subcommand ------------------------------------
|
|
|
|
|
|
def _run_canon(*args: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
[sys.executable, "-m", "arborist.cli", "canon", *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def test_cli_canon_arithmetic():
|
|
r = _run_canon("arithmetic@v1", "0.1 + 0.2")
|
|
assert r.returncode == 0, r.stderr
|
|
assert r.stdout.strip() == "3/10"
|
|
|
|
|
|
def test_cli_canon_logic():
|
|
r = _run_canon("logic-kernel@v1", "A IMPL B")
|
|
assert r.returncode == 0, r.stderr
|
|
assert r.stdout.strip() == "(NOT A OR B)"
|
|
|
|
|
|
def test_cli_canon_time_series():
|
|
r = _run_canon(
|
|
"time-series-quantized@v1",
|
|
'{"dt":1,"dv":0.1,"samples":[[0,1.0],[1,2.0]]}',
|
|
)
|
|
assert r.returncode == 0, r.stderr
|
|
assert r.stdout.strip() == "dt=1;dv=0.1;n=2;t0=0:10|20"
|
|
|
|
|
|
def test_cli_canon_list():
|
|
r = _run_canon("--list")
|
|
assert r.returncode == 0, r.stderr
|
|
keys = {line.split("\t")[0] for line in r.stdout.strip().split("\n")}
|
|
assert "arithmetic@v1" in keys
|
|
assert "logic-kernel@v1" in keys
|
|
assert "time-series-quantized@v1" in keys
|
|
assert "code-py-ast@v1" in keys
|
|
assert "claim-lattice@v1" in keys
|
|
assert "wikitext-base@v1" in keys
|
|
|
|
|
|
def test_cli_canon_json_emits_sha256():
|
|
r = _run_canon("--json", "arithmetic@v1", "0.1 + 0.2")
|
|
assert r.returncode == 0, r.stderr
|
|
obj = json.loads(r.stdout)
|
|
assert obj["pi_star_ref"] == "arithmetic@v1"
|
|
assert obj["input"] == "0.1 + 0.2"
|
|
assert obj["canonical"] == "3/10"
|
|
assert len(obj["canonical_sha256"]) == 64
|
|
|
|
|
|
def test_cli_canon_unknown_key_returns_2():
|
|
r = _run_canon("does-not-exist@v1", "anything")
|
|
assert r.returncode == 2
|
|
assert "unknown π*" in r.stderr
|
|
|
|
|
|
def test_cli_canon_pi_star_error_returns_1():
|
|
r = _run_canon("arithmetic@v1", "x + 1") # identifier rejected
|
|
assert r.returncode == 1
|
|
assert "error" in r.stderr.lower()
|
|
|
|
|
|
def test_cli_canon_missing_args_returns_2():
|
|
r = _run_canon()
|
|
assert r.returncode == 2
|
|
assert "required" in r.stderr.lower()
|