From 6c9bc047fdddd7c895d7dd733120fb5469a40ea8 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 10 May 2026 15:54:37 -0400 Subject: [PATCH] tests/pi_star_arithmetic: 56 KATs for exact-rational arithmetic kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_pi_star_arithmetic.py | 197 +++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/test_pi_star_arithmetic.py diff --git a/tests/test_pi_star_arithmetic.py b/tests/test_pi_star_arithmetic.py new file mode 100644 index 0000000..f3a0fc5 --- /dev/null +++ b/tests/test_pi_star_arithmetic.py @@ -0,0 +1,197 @@ +"""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