arborist/tests/test_pi_star_combinatorics.py
russell@unturf.com 7b7ac3867d
ticket #000032: combinatorics@v1 π* (pure-integer counting kernel)
A new π* kernel that canonicalizes pure-integer counting
expressions and FAILS CLOSED on any input whose result isn't a
non-negative sp.Integer. Tighter domain than algebra-symbolic@v1,
which already accepts the same input surface but happily returns
symbolic / negative / non-integer outputs.

Distinguishing feature versus algebra-symbolic@v1:
  algebra-symbolic@v1: binomial(n, k) → "binomial(n, k)" (symbolic
                       passthrough)
  combinatorics@v1:    binomial(n, k) → PiStarError (fail-closed
                       on free-symbol output)

  algebra-symbolic@v1: binomial(Rational(1,2), 3) → 1/16 (rational)
  combinatorics@v1:    binomial(Rational(1,2), 3) → PiStarError
                       (output not Integer)

Boundary kept explicit: binomial(-3, 2) = 6 IS accepted because the
output is an integer 6. The fail-closed rule is on output shape
(Integer ≥ 0), not input range. Documented as
test_generalized_binomial_negative_args_accepted_when_integer.

Output format: plain decimal literal (b"10", b"5040"). Composes
with arithmetic@v1 for byte-identical agreement with the rational
route (b"10/1") so the multi-modality witness (#000028) can pin
equivalence-class agreement when both routes fire on the same
question.

Allowed surface (via SymPy primitives): binomial, factorial, ff /
rf (falling/rising), catalan, bell, partition, stirling, plus
arithmetic compositions over those primitives
(3*binomial(5,2) + factorial(4) = 54).

Coverage:
- 43 unit tests including binomial symmetry C(n,k)=C(n,n-k),
  Pascal's rule C(n,k)=C(n-1,k-1)+C(n-1,k), the C(n,k) =
  factorial(n)/(factorial(k)·factorial(n-k)) identity,
  fail-closed paths (symbolic/negative/non-integer/relational/
  parse), round-trip idempotence, composition with arithmetic@v1.
- 10 syntax + 12 semantics bench fixtures, 100% pass.
- bench/batteries/base.py PHASE_1_CARRIERS gains "combinatorics".
- Makefile bench-5s-combinatorics target.

All gate on pytest.importorskip("sympy") so a sympy-less suite
stays green. Full make test: 1537 passed / 28 skipped.

Sequencing rationale honored: this kernel lands FIRST so that
#000033 (claim-pack pillar VII for combinatorics) can bind its
records to the tighter integer kernel from day one — avoids
rebind churn on pi_star_ref fields.
2026-05-09 13:20:29 -04:00

217 lines
6.5 KiB
Python

"""combinatorics@v1 π* tests (ticket #000032).
Pure-integer counting kernel that fails closed on symbolic /
negative / non-integer outputs. 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 compose, get # noqa: E402
from arborist.pi_star.protocol import ( # noqa: E402
PiStarError,
assert_round_trip,
equivalence_class_id,
)
@pytest.fixture
def ps():
return get("combinatorics@v1")
# --- registry presence ------------------------------------------------
def test_registry_contains_combinatorics():
ps = get("combinatorics@v1")
assert ps.name == "combinatorics"
assert ps.version == "v1"
assert ps.domain == "combinatorics"
# --- positive cases — integer collapse -------------------------------
@pytest.mark.parametrize(
"input_str,expected_int",
[
("binomial(5,2)", 10),
("binomial(10,3)", 120),
("factorial(0)", 1),
("factorial(1)", 1),
("factorial(7)", 5040),
("binomial(0,0)", 1),
("binomial(7,0)", 1),
("binomial(7,7)", 1),
("binomial(5,7)", 0), # k > n → 0
("catalan(0)", 1),
("catalan(5)", 42),
("bell(5)", 52),
("partition(7)", 15),
# Arithmetic compositions over integer primitives.
("3*binomial(5,2) + factorial(4)", 54),
("2**10", 1024),
("100", 100),
],
)
def test_integer_collapse(ps, input_str, expected_int):
assert ps.canonicalize(input_str.encode()) == str(expected_int).encode()
def test_byte_format_is_plain_decimal(ps):
"""Output must be the bare decimal literal — no quotes, no
Integer(...) wrapper, no fraction-form denominator."""
out = ps.canonicalize(b"binomial(5,2)")
assert out == b"10"
assert b"/" not in out
assert b"Integer" not in out
# --- equivalence classes preserved -----------------------------------
def test_binomial_symmetry_collapses(ps):
"""C(n, k) = C(n, n-k) — same canonical bytes."""
assert ps.canonicalize(b"binomial(7, 3)") == ps.canonicalize(b"binomial(7, 4)")
assert (
equivalence_class_id(ps, b"binomial(7, 3)")
== equivalence_class_id(ps, b"binomial(7, 4)")
)
def test_pascals_rule_holds(ps):
"""Pascal's rule: C(n,k) = C(n-1,k-1) + C(n-1,k)."""
lhs = ps.canonicalize(b"binomial(7, 3)")
rhs = ps.canonicalize(b"binomial(6, 2) + binomial(6, 3)")
assert lhs == rhs
# --- distinct counts stay distinct -----------------------------------
@pytest.mark.parametrize(
"left,right",
[
# Note: binomial(5,2) = binomial(5,3) by symmetry — pick a pair
# that's actually distinct.
("binomial(5, 2)", "binomial(7, 2)"),
("factorial(5)", "factorial(6)"),
("catalan(5)", "catalan(6)"),
("partition(5)", "partition(7)"),
],
)
def test_distinct_counts_distinct(ps, left, right):
assert ps.canonicalize(left.encode()) != ps.canonicalize(right.encode())
# --- round-trip / idempotence ----------------------------------------
@pytest.mark.parametrize(
"expr",
[
"binomial(5, 2)",
"factorial(7)",
"100",
"2**10",
"3*binomial(5, 2) + factorial(4)",
],
)
def test_round_trip_idempotent(ps, expr):
assert_round_trip(ps, expr.encode())
# --- fail-closed: symbolic results -----------------------------------
@pytest.mark.parametrize(
"expr",
[
"binomial(n, k)", # both symbolic
"factorial(n)", # symbolic
"factorial(n + 1)", # symbolic
"binomial(5, k)", # one symbolic
"x + 1", # not combinatorial at all, still symbolic
],
)
def test_symbolic_input_rejected(ps, expr):
with pytest.raises(PiStarError, match="not a concrete integer"):
ps.canonicalize(expr.encode())
# --- fail-closed: non-integer / generalized inputs -------------------
def test_negative_result_rejected(ps):
"""Hand-crafted: factorial(4) - 100 = -76 → counting domain rejects."""
with pytest.raises(PiStarError, match="negative"):
ps.canonicalize(b"factorial(4) - 100")
def test_generalized_binomial_non_integer_rejected(ps):
"""binomial(Rational(1,2), 3) = 1/16 — SymPy supports the
generalized binomial via Gamma; combinatorics@v1 rejects
non-integer outputs."""
with pytest.raises(PiStarError, match="not a concrete integer"):
ps.canonicalize(b"binomial(Rational(1,2), 3)")
def test_rational_arithmetic_rejected(ps):
"""1/2 reduces to a Rational — not an Integer, rejected."""
with pytest.raises(PiStarError, match="not a concrete integer"):
ps.canonicalize(b"1/2")
def test_generalized_binomial_negative_args_accepted_when_integer(ps):
"""SymPy's binomial extends to negative integers via Gamma; the
output of binomial(-3, 2) is the integer 6. Counting-domain
semantics would arguably reject negative-argument binomials, but
the kernel's fail-closed rule is on the OUTPUT shape (integer
vs. not), not input. Document the boundary explicitly."""
# binomial(-3, 2) = 6 as an integer — the kernel accepts.
out = ps.canonicalize(b"binomial(-3, 2)")
assert out == b"6"
# --- fail-closed: parse / shape errors -------------------------------
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"$$$ % nope")
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")
# --- composition -----------------------------------------------------
def test_composes_with_arithmetic_for_byte_identical_witness():
"""combinatorics@v1 ∘ arithmetic@v1 — counting output (b"10")
becomes arithmetic-route bytes (b"10/1") so the multi-modality
witness can pin equivalence-class agreement when both routes
fire on the same question."""
chain = compose("combinatorics@v1", "arithmetic@v1")
assert chain.canonicalize(b"binomial(5, 2)") == b"10/1"
assert chain.canonicalize(b"factorial(7)") == b"5040/1"
assert chain.canonicalize(b"binomial(0, 0)") == b"1/1"