Backfills zero dedicated coverage on arborist/pi_star/arithmetic.py (188 lines, 2026-05-10 zero-coverage sweep). KATs mirror the docstring's equivalence-class examples: integer-literal collapse, decimal → rational exactness (0.1 + 0.2 = 3/10), lowest-terms reduction, integer exponents. Negative cones: identifier, function call, non-integer exponent, division by zero, boolean literal, empty, non-bytes, syntax error. Plus round-trip idempotence and equivalence-class-id matching across equivalent inputs.
197 lines
5 KiB
Python
197 lines
5 KiB
Python
"""arithmetic@v1 π* tests.
|
||
|
||
Exact-rational arithmetic canonicalizer over Σ* → ℚ ∪ {⊥}. KATs
|
||
mirror the docstring's equivalence-class examples, plus negative
|
||
cones (identifier, function call, non-integer exponent, division
|
||
by zero, boolean literal, empty, non-bytes, syntax error) and
|
||
round-trip idempotence.
|
||
|
||
Backfills the test-coverage gap for arborist/pi_star/arithmetic.py
|
||
identified during the 2026-05-10 zero-coverage sweep.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from arborist.pi_star import get
|
||
from arborist.pi_star.protocol import (
|
||
PiStarError,
|
||
assert_round_trip,
|
||
equivalence_class_id,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def ps():
|
||
return get("arithmetic@v1")
|
||
|
||
|
||
# --- registry presence + metadata ------------------------------------
|
||
|
||
|
||
def test_registry_contains_arithmetic():
|
||
ps = get("arithmetic@v1")
|
||
assert ps.name == "arithmetic"
|
||
assert ps.version == "v1"
|
||
assert ps.domain == "arithmetic"
|
||
|
||
|
||
# --- positive KATs (docstring equivalence classes) -------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"expr,canonical",
|
||
[
|
||
# Integer literal collapse
|
||
("1+2", "3/1"),
|
||
("(1+2)*3", "9/1"),
|
||
("100", "100/1"),
|
||
("-2", "-2/1"),
|
||
# Decimal → rational, exact
|
||
("0.1", "1/10"),
|
||
("0.5", "1/2"),
|
||
("0.1+0.2", "3/10"),
|
||
# Lowest-terms reduction
|
||
("6/4", "3/2"),
|
||
("10/5", "2/1"),
|
||
# Unary
|
||
("+7", "7/1"),
|
||
("-(3+4)", "-7/1"),
|
||
# Integer exponent
|
||
("2**10", "1024/1"),
|
||
("(-2)**3", "-8/1"),
|
||
# Mixed
|
||
("3/10 + 0.1", "2/5"),
|
||
],
|
||
)
|
||
def test_positive_kats(ps, expr, canonical):
|
||
assert ps.canonicalize(expr.encode("utf-8")) == canonical.encode("utf-8")
|
||
|
||
|
||
# --- equivalence classes preserved -----------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"a,b",
|
||
[
|
||
("1+2", "3"),
|
||
("0.1", "1/10"),
|
||
("0.5", "1/2"),
|
||
("0.1+0.2", "0.3"),
|
||
("(1+2)*3", "9"),
|
||
("6/4", "3/2"),
|
||
("-2", "-2/1"),
|
||
("2*3", "(1+2)*2"),
|
||
],
|
||
)
|
||
def test_equivalent_inputs_collapse(ps, a, b):
|
||
assert ps.canonicalize(a.encode()) == ps.canonicalize(b.encode())
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"a,b",
|
||
[
|
||
("1+2", "3"),
|
||
("0.1+0.2", "0.3"),
|
||
("6/4", "3/2"),
|
||
],
|
||
)
|
||
def test_equivalence_class_id_matches(ps, a, b):
|
||
assert equivalence_class_id(ps, a.encode()) == equivalence_class_id(
|
||
ps, b.encode()
|
||
)
|
||
|
||
|
||
# --- equivalence classes kept distinct -------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"a,b",
|
||
[
|
||
("1+2", "4"),
|
||
("0.1", "0.2"),
|
||
("0.1+0.2", "0.30000000001"), # the float-drift form is distinct
|
||
("2/3", "3/2"),
|
||
("-2", "2"),
|
||
],
|
||
)
|
||
def test_distinct_inputs_stay_distinct(ps, a, b):
|
||
assert ps.canonicalize(a.encode()) != ps.canonicalize(b.encode())
|
||
|
||
|
||
# --- round-trip idempotence ------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"expr",
|
||
["0", "1", "-1", "3/10", "0.1+0.2", "(1+2)*3", "2**10", "-(3+4)"],
|
||
)
|
||
def test_round_trip(ps, expr):
|
||
assert_round_trip(ps, expr.encode("utf-8"))
|
||
|
||
|
||
def test_output_is_valid_input(ps):
|
||
"""canonicalize output is itself valid input that re-canonicalizes."""
|
||
first = ps.canonicalize(b"6/4")
|
||
second = ps.canonicalize(first)
|
||
assert first == second == b"3/2"
|
||
|
||
|
||
# --- invalid-input cone ----------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"expr,fragment",
|
||
[
|
||
("x", "identifier"),
|
||
("x + 1", "identifier"),
|
||
("sqrt(2)", "function calls"),
|
||
("max(1, 2)", "function calls"),
|
||
("2 ** 0.5", "non-integer exponent"),
|
||
("1/0", "division by zero"),
|
||
("(2 + 3)/0", "division by zero"),
|
||
("True", "boolean"),
|
||
("False", "boolean"),
|
||
("", "empty"),
|
||
(" ", "empty"),
|
||
],
|
||
)
|
||
def test_invalid_raises(ps, expr, fragment):
|
||
with pytest.raises(PiStarError, match=fragment):
|
||
ps.canonicalize(expr.encode("utf-8"))
|
||
|
||
|
||
def test_syntax_error_raises(ps):
|
||
with pytest.raises(PiStarError, match="not valid expression"):
|
||
ps.canonicalize(b"1 + ")
|
||
|
||
|
||
def test_unsupported_op_raises(ps):
|
||
"""Comparison / bitwise / logical ops are rejected."""
|
||
for expr in ("1 < 2", "1 & 2", "1 | 2"):
|
||
with pytest.raises(PiStarError):
|
||
ps.canonicalize(expr.encode("utf-8"))
|
||
|
||
|
||
def test_non_bytes_input_raises(ps):
|
||
with pytest.raises(PiStarError, match="expects bytes"):
|
||
ps.canonicalize("3/10") # type: ignore[arg-type]
|
||
with pytest.raises(PiStarError, match="expects bytes"):
|
||
ps.canonicalize(42) # type: ignore[arg-type]
|
||
|
||
|
||
def test_bytearray_accepted(ps):
|
||
"""bytearray is an accepted subtype per the bytes/bytearray guard."""
|
||
assert ps.canonicalize(bytearray(b"6/4")) == b"3/2"
|
||
|
||
|
||
# --- determinism -----------------------------------------------------
|
||
|
||
|
||
def test_repeated_canonicalize_is_stable(ps):
|
||
expr = b"(0.1 + 0.2) * 3 - 1/2 + 7/14"
|
||
a = ps.canonicalize(expr)
|
||
b = ps.canonicalize(expr)
|
||
c = ps.canonicalize(expr)
|
||
assert a == b == c
|