arborist/tests/test_pi_star_arithmetic.py
russell@unturf.com 6c9bc047fd
tests/pi_star_arithmetic: 56 KATs for exact-rational arithmetic kernel
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.
2026-05-10 15:54:37 -04:00

197 lines
5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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