# Ticket #000032 — combinatorics@v1 π*

**Status:** closed · landed 2026-05-09
**Opened:** 2026-05-09
**Scope:** A new π* kernel that canonicalizes pure-integer
counting expressions — binomials, factorials, permutations,
partitions, Catalan, Bell, Stirling numbers — and **fails closed
on any input whose result is not a non-negative integer**. Tighter
domain than `algebra-symbolic@v1` (which already accepts the same
input surface but happily returns symbolic / negative / rational
results).
**Audience:** maintainers of arborist's π* layer + downstream
consumers (canonical-projection witness, math/logic preflight,
benchmark batteries, claim-pack pillar VII if #000033 lands).
**Hard constraint:** **fail-closed integer discipline.** Any input
whose canonical SymPy reduction is not a `sp.Integer ≥ 0` raises
`PiStarError`. This is the distinguishing feature versus
`algebra-symbolic@v1`. Operators choose the kernel by what they
want rejected, not by what they want accepted.

---

## 1. Problem statement

Today, combinatorial primitives (`binomial(5, 2)`, `factorial(7)`,
`catalan(5)`, etc.) already canonicalize through
`algebra-symbolic@v1` because SymPy understands them as
expressions. Confirmed in #000030 Phase 1+2 commit:

```
binomial(5,2)                 → Integer(10)
factorial(7)                  → Integer(5040)
3*binomial(5,2) + factorial(4) → Integer(54)
binomial(n,k)                 → binomial(Symbol('n'), Symbol('k'))   ← symbolic
binomial(-3, 2)               → Integer(6)                            ← negative-arg accepted
```

This works — but it accepts inputs that combinatorial-counting
semantics should reject:

1. **Symbolic results** (`binomial(n,k)`) — counting only makes
   sense once n,k are bound to concrete non-negative integers.
   `algebra-symbolic@v1` returns the symbolic form unchanged,
   which silently moves "the question is unanswerable" into
   "the answer is this opaque expression." That's a false
   confidence the witness flow can't catch without a separate
   integer assertion.
2. **Generalized binomials** (`binomial(-3, 2) = 6`,
   `binomial(0.5, 3) = 0.0625`) — SymPy's combinatorial
   functions extend to negative integers, rationals, and complex
   numbers via the Gamma function. The CLASSICAL counting
   interpretation rejects all of these. A counting kernel
   should fail closed.
3. **Composability with `arithmetic@v1`.** The output bytes of
   `algebra-symbolic@v1` are `srepr(Integer(10))`. The output
   bytes of `arithmetic@v1` are `b"10/1"`. Two callers asking
   "how many ways to choose 2 from 5?" via different routes get
   different canonical bytes for the same answer. A
   combinatorics kernel that emits `b"10"` (or `arithmetic@v1`-
   compatible `b"10/1"`) closes that discrepancy.

The witness flow (#000028) becomes more useful when modalities
agree on the SHAPE of the answer. STRICT-WITNESSED for "how many
ways to choose 2 from 5" should not depend on which kernel the
preflight happened to dispatch to.

## 2. Design choices

### 2.1 Output format

**A. Integer string (RECOMMENDED).** ``b"10"``, ``b"5040"``.
Compact, human-readable, and `arithmetic@v1`'s
``"<num>/<den>"`` recipe accepts it as ``10/1`` after one
re-canonicalization. Cross-modality agreement with
`arithmetic@v1` is one canonicalization step away — the
multi-modality witness can pin them as the same equivalence
class via composition.

**B. SymPy srepr integer.** ``b"Integer(10)"``. Matches the
algebra-symbolic@v1 fast-path output. Less readable; requires
SymPy to re-parse for any downstream comparison. Loses the
"witness can pin equivalence with arithmetic@v1" argument.

**C. Rational form.** ``b"10/1"`` via the existing
`arithmetic@v1` recipe. Bytes-identical to what
`arithmetic@v1` would output. Cleanest cross-kernel
agreement. Costs a denominator on every output (visual
clutter for pure counts).

→ **A.** Compact, distinguishes counting outputs from rational
arithmetic at the byte level (`b"10"` vs `b"10/1"`),
canonicalizes round-trip-cleanly through `arithmetic@v1` if
operators want bytes-identical agreement.

### 2.2 Allowed input surface

Same parser as `algebra-symbolic@v1` (SymPy `sympify`); the
domain narrowing happens at the OUTPUT stage. Inputs that the
parser accepts but the kernel rejects:

- Result is symbolic (any free symbols remain): reject.
- Result is non-integer: reject.
- Result is negative integer: reject (counting-domain rule;
  generalized binomial doesn't qualify).

Inputs the parser already rejects (PiStarError from sympify,
syntax error, relational/boolean): reject same as
`algebra-symbolic@v1`.

### 2.3 Composition

`combinatorics@v1` outputs bytes that look like a non-negative
integer. The natural composition target is `arithmetic@v1`:

```python
from arborist.pi_star import compose
# combinatorics@v1 output "10" → arithmetic@v1 reads as 10 → "10/1"
chain = compose("combinatorics@v1", "arithmetic@v1")
chain.canonicalize(b"binomial(5,2)") == b"10/1"
```

Auto-registers as `combinatorics-then-arithmetic@v1` per the
existing composition algebra. The witness flow can use this
composition to pin equivalence-class agreement between
combinatorial and rational answers.

### 2.4 Preflight routing

Algebra route already accepts inputs like `binomial(5,2)` and
returns `Integer(10)`. Adding a combinatorics route ahead of
algebra would require a sniff that distinguishes counting
expressions (`binomial`, `factorial`, …) from general algebra.
Two options:

**A. Don't wire to preflight.** Let `algebra-symbolic@v1` keep
catching combinatorial inputs at the preflight layer. Operators
who want the tighter integer discipline call
`combinatorics@v1` directly (or via composition). This preserves
the existing preflight order; no new sniff complexity.

**B. Add a combinatorics sniff.** Detect known combinatorial
function names (`binomial`, `factorial`, `catalan`, `bell`,
`partition`, `stirling`) at the start of the input and route
accordingly. Adds a new regex + branch.

→ **A.** Keep preflight simple. The kernel exists to enforce
discipline at call sites that want it; the preflight layer
already works for the question-shape case via algebra.

### 2.5 Allowed function set

SymPy combinatorial primitives that produce non-negative integer
outputs on integer inputs:

| Name (SymPy)                                      | Input              | Output |
|---------------------------------------------------|--------------------|--------|
| ``sp.binomial(n, k)``                             | n, k integers ≥ 0 | C(n, k), 0 if k > n |
| ``sp.factorial(n)``                               | n integer ≥ 0     | n!     |
| ``sp.ff(x, k)`` (falling factorial)               | x, k integers ≥ 0 | x·(x-1)…(x-k+1) |
| ``sp.rf(x, k)`` (rising factorial / Pochhammer)   | x, k integers ≥ 0 | x·(x+1)…(x+k-1) |
| ``sp.catalan(n)``                                 | n integer ≥ 0     | n-th Catalan |
| ``sp.bell(n)``                                    | n integer ≥ 0     | n-th Bell |
| ``sp.functions.combinatorial.numbers.partition(n)`` | n integer ≥ 0   | p(n)   |
| ``sp.functions.combinatorial.numbers.stirling(n, k)`` | n, k integers ≥ 0 | S2(n, k) |

These are all addressable by name through SymPy's standard
parser; users write `binomial(5,2)`, `partition(7)` etc. directly.
Operators with arithmetic compositions (e.g.
`3*binomial(5,2) + factorial(4)`) get a single integer output,
54 in this case.

## 3. Recommendation

Implement Phase 1 + Phase 2 together as one commit:

- **Phase 1** — `arborist/pi_star/combinatorics.py` with the
  `CombinatoricsV1` class, integer-only output discipline, and
  registration. ~120 LOC.
- **Phase 2** — Tests covering integer-output cases (collapse),
  symbolic rejection, negative rejection, generalized-binomial
  rejection, composition with `arithmetic@v1`. Bench fixtures
  pair (syntax + semantics). ~80 LOC tests + ~12 fixtures.

## 4. Implementation sketch

### 4.1 New file: `arborist/pi_star/combinatorics.py`

```python
try:
    import sympy as sp
except ImportError:
    sp = None


@dataclass
class CombinatoricsV1:
    name: str = "combinatorics"
    version: str = "v1"
    domain: str = "combinatorics"

    def canonicalize(self, raw: bytes) -> bytes:
        if sp is None:
            raise PiStarError("requires sympy; pip install 'arborist[math]'")
        # ... bytes/UTF-8/empty checks (mirror algebra_symbolic.py) ...
        try:
            expr = sp.sympify(text)
        except (sp.SympifyError, SyntaxError, TypeError) as exc:
            raise PiStarError(f"cannot parse {text!r}: {exc}") from exc
        if not isinstance(expr, sp.Expr):
            raise PiStarError(f"rejects non-expression {text!r}")
        # Reduce — sp.simplify on combinatorial primitives evaluates
        # them to integer literals (or leaves them symbolic).
        try:
            reduced = sp.simplify(expr)
        except (TypeError, ValueError) as exc:
            raise PiStarError(f"simplify failed: {exc}") from exc
        # Domain check: must be a non-negative concrete integer.
        if not isinstance(reduced, sp.Integer):
            raise PiStarError(
                f"combinatorics@v1 result is not a concrete integer: "
                f"{reduced!r}"
            )
        if reduced < 0:
            raise PiStarError(
                f"combinatorics@v1 result is negative: {int(reduced)}"
            )
        return str(int(reduced)).encode("utf-8")


if sp is not None:
    register(CombinatoricsV1())
```

### 4.2 Tests (`tests/test_pi_star_combinatorics.py`)

```python
sympy = pytest.importorskip("sympy")

def test_basic_counts(ps):
    assert ps.canonicalize(b"binomial(5,2)") == b"10"
    assert ps.canonicalize(b"factorial(7)") == b"5040"
    assert ps.canonicalize(b"catalan(5)") == b"42"
    assert ps.canonicalize(b"bell(5)") == b"52"
    assert ps.canonicalize(b"partition(7)") == b"15"

def test_arithmetic_composition_inside_input(ps):
    # Mixing counting + arithmetic still must reduce to integer.
    assert ps.canonicalize(b"3*binomial(5,2) + factorial(4)") == b"54"

def test_symbolic_input_rejected(ps):
    with pytest.raises(PiStarError, match="not a concrete integer"):
        ps.canonicalize(b"binomial(n, k)")

def test_negative_result_rejected(ps):
    # Hand-crafted: 5 - factorial(4) = -19 → counting domain rejects.
    with pytest.raises(PiStarError, match="negative"):
        ps.canonicalize(b"5 - factorial(4)")

def test_non_integer_result_rejected(ps):
    # Generalized binomial(0.5, 3) = 0.0625 — SymPy supports it,
    # combinatorics@v1 rejects.
    with pytest.raises(PiStarError, match="not a concrete integer"):
        ps.canonicalize(b"binomial(Rational(1,2), 3)")

def test_composes_with_arithmetic(ps):
    from arborist.pi_star import compose
    chain = compose("combinatorics@v1", "arithmetic@v1")
    assert chain.canonicalize(b"binomial(5, 2)") == b"10/1"
```

### 4.3 Bench fixtures

`bench/fixtures/5s/syntax-combinatorics-v1.jsonl` —
10 inputs that produce non-negative integers (parse-pass).

`bench/fixtures/5s/semantics-combinatorics-v1.jsonl` —
12 equivalence-class fixtures:

- `binomial(5, 2)` ≡ `10`
- `binomial(5, 3)` ≡ `binomial(5, 2)` (symmetry)
- `factorial(0)` ≡ `1`
- `binomial(7, 0)` ≡ `binomial(7, 7)` ≡ `1`
- distinct: `factorial(5)` ≢ `factorial(6)`, etc.

### 4.4 Carrier whitelist + Makefile

`bench/batteries/base.py PHASE_1_CARRIERS` gains
`"combinatorics"`. `Makefile` gains `bench-5s-combinatorics`.

## 5. Hard constraints (re-stated)

1. **Fail-closed integer discipline.** Any input whose reduction
   is not a non-negative `sp.Integer` raises `PiStarError`. No
   silently-symbolic outputs. No negative-counting outputs. No
   rational/float outputs.
2. **Optional dependency.** Same pattern as `algebra-symbolic@v1`:
   `try/except` import, register only when sympy is present, tests
   gate on `pytest.importorskip("sympy")`.
3. **No cache_key invariant changes.** Schema, chunker,
   canonicalization versions stay pinned. New π* registers a new
   key (`combinatorics@v1`); existing cache rows unaffected.
4. **No preflight wiring.** The kernel is direct-call; the
   preflight algebra route already catches counting-shaped inputs
   without a sniff change.

## 6. Risks / things to watch

- **`sp.simplify` cost.** Same risk as
  `algebra-symbolic-simplified@v1`: pathological inputs can grow
  exponentially. Combinatorial primitives are usually fast (≤ 10
  ms even for `partition(1000)`), but composition with
  `algebra-symbolic` shapes can hit the slow path. No in-π*
  timeout in v1 (matches Phase 1b); operators size budget at
  call sites.
- **`stirling(n, k)` ambiguity.** SymPy distinguishes Stirling
  numbers of the first vs second kind via a kwarg
  (`stirling(n, k, kind=1)`). The default is second kind. Document
  in module docstring; fixtures pin the kind explicitly.
- **`partition` namespace.** `sp.partition` exists at
  `sp.functions.combinatorial.numbers.partition` but NOT on the
  top-level `sp.` namespace as a standalone callable in some
  SymPy versions. Verify `sympify("partition(7)")` resolves
  consistently across SymPy ≥ 1.13; fall back to the longer
  `sp.functions...` path inside the parser's `local_dict` if
  needed.
- **Catalan-number test fragility.** Some SymPy versions return
  `catalan(5) → 42` directly; older versions might return an
  unevaluated `catalan(5)` Function call. Pin the version
  expectation in `[math]` extra (`sympy>=1.13`).

## 7. Forward links

- **#000030** — Math π* expansion (SymPy substrate). This ticket
  is its sibling, narrower-domain.
- **#000033** — Claim-pack pillar VII (combinatorics). If both
  land, the pillar's records cite this kernel as its formal
  projection (analogous to how Phase III arithmetic theorems
  cite `arithmetic@v1`).
- **#000028** — Multi-modality witness. Combinatorial questions
  become STRICT-WITNESSED-eligible once the kernel + the algebra
  preflight produce equivalent canonical forms via composition.

## 8. Status

**Landed 2026-05-09 in a single commit.**

Landed scope:

- `arborist/pi_star/combinatorics.py` — `CombinatoricsV1` class.
  Parser is `sp.sympify`; reduction is `sp.simplify`; output filter
  is `isinstance(reduced, sp.Integer) and reduced >= 0`. Emits a
  plain decimal literal (`b"10"`).
- `arborist/pi_star/__init__.py` — registers the module at package
  load, alphabetical between `code` and `function_sampled`.
- `bench/fixtures/5s/syntax-combinatorics-v1.jsonl` — 10 fixtures.
- `bench/fixtures/5s/semantics-combinatorics-v1.jsonl` — 12
  fixtures 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), and the C(n,k) =
  factorial(n)/(factorial(k)·factorial(n-k)) identity.
- `bench/batteries/base.py PHASE_1_CARRIERS` gains
  `"combinatorics"`.
- `Makefile bench-5s-combinatorics` target — 100% pass on both
  syntax and semantics fixtures.
- `tests/test_pi_star_combinatorics.py` — 43 tests covering
  integer collapse, equivalence-class behavior (binomial
  symmetry, Pascal's rule), distinct-counts-stay-distinct,
  round-trip idempotence, all fail-closed paths (symbolic /
  negative / non-integer / parse / shape), and the
  `combinatorics@v1 ∘ arithmetic@v1` byte-identical-witness
  composition.
- Full test suite: 1537 passed / 28 skipped.

One design decision worth noting: `binomial(-3, 2) = 6` is
ACCEPTED by the kernel because the OUTPUT is integer 6, not the
input. The fail-closed rule is on output shape (Integer ≥ 0), not
input range. Documented as an explicit boundary test in
`test_generalized_binomial_negative_args_accepted_when_integer`.
A stricter "classical-counting-domain" variant could reject
negative arguments at the input layer; reserved for a future
ticket if a consumer needs it.

Phase 2 (claim-pack pillar VII binding) lives in #000033, which
can now bind records to `combinatorics@v1` from day one.
