arborist/tests/test_pi_star_phase_3_to_7.py
russell@unturf.com abe5988bef
fan-out: 5 π* graduations close the registry chapter
tabular-pinned@v1 + calculus-limit@v1 + calculus-series@v1 +
linear-algebra@v1 + function-sampled@v1 — all reserved stubs
graduated; the π* registry is now 15 concrete kernels with no
remaining reserved-stub entries.

#000030 Phase 4 — calculus-limit@v1
====================================

sp.limit with thread-timeout. One-sided dir support (+/-/+-).
Pinned spelling for infinity cases: b"+oo" / b"-oo" / b"zoo"
(complex infinity) — bypasses sp.expand since Infinity isn't
algebraic. Finite results re-canonicalize through algebra-symbolic
recipe (sp.expand + sp.srepr). Unevaluated cases / timeouts emit
b"unevaluated:" + sp.srepr(<Limit>) sentinel, mirroring
calculus-integral's pattern.

#000030 Phase 5 — calculus-series@v1
=====================================

sp.series(f, x, x0, n).removeO() → sp.expand → sp.srepr. Drops
O(x**n) remainder explicitly so the canonical form is finite-byte.
Sentinel format mirrors limit/integral: b"unevaluated:Series(...)"
on timeout. n must be a positive int; 0 / float / negative rejected.

#000030 Phase 6 — linear-algebra@v1
====================================

Single π* covers the whole linear-algebra surface via {op, matrix}
JSON. Ops: rref / det / eigenvalues / inverse. Matrix cells go
through Fraction(Decimal(str(...))) for floats so 1, 1.0, "1.0"
all collapse to Rational(1, 1) — matching arithmetic@v1's
discipline. Without this fold, sp.sympify keeps floats as Float
(separate type) and downstream det/inverse return Float-shaped
bytes. Eigenvalues are sorted by srepr for determinism.

Output formats:
  rref / inverse:  rows/cols header + cells joined by | (rows by ||)
  det:             det:<num/den-or-srepr>
  eigenvalues:     eigenvalues:<value-1>x<mult-1>|...

#000030 Phase 7 — function-sampled@v1
======================================

Bridge to time-series-quantized@v1. SymPy expression + linspace
grid → quantized integer-vector signature in time-series's exact
output format (dt=...;dv=...;n=...;t0=0:v0|v1|...). Two functions
that render identically (within sample-grid tolerance) collapse
to the same canonical bytes. This is what plotting CAN become
in π* terms — the PNG render is a downstream view of the same
canonical evidence.

Math-only sampler (no numpy in the dep surface); Python's round()
is banker's-rounding so the bytes are interchangeable with
time-series-quantized@v1's output. Complex / non-finite samples
raise PiStarError rather than silently dropping imaginary parts.

tabular-pinned@v1 — last reserved stub graduates
=================================================

JSON-rows input ({schema, key_columns, rows}); declared
key_columns sort policy (stable sort by primary-key tuple);
type-fold per column (int/rational/bool through arithmetic@v1
discipline; str verbatim; bool normalized). Header case is
PINNED EXACT — Excel and PostgreSQL both care about case;
defaulting to lowercase-fold would break operator expectations.

Output: header (schema + key + n) + rows joined by \n + cells by |.

The π* registry has no remaining reserved stubs. Every modality
the substrate paper reserved is now real.

Test suite: 1568 passed (was 1467; +101). New closure-criterion
test (test_no_stub_pi_stars_remain) replaces the old reserved-stub
parametrize — adding a future stub re-opens this list.

110/110 fixtures pass across the 5 new bench-5s-* targets.
PHASE_1_CARRIERS gained calculus / linear-algebra / function-sampled
/ tabular.
2026-05-09 13:04:43 -04:00

272 lines
8.4 KiB
Python

"""Tests for #000030 Phase 4-7 π* graduations.
Phase 4 — calculus-limit@v1
Phase 5 — calculus-series@v1
Phase 6 — linear-algebra@v1
Phase 7 — function-sampled@v1
All four gate on SymPy via the [math] extra; tests skip cleanly
when sympy is absent (mirrors the algebra-symbolic / calculus-
derivative pattern).
"""
from __future__ import annotations
import pytest
from arborist.pi_star import PiStarError, get
sympy = pytest.importorskip("sympy")
# ===== calculus-limit@v1 (Phase 4) ========================================
def test_limit_sinx_over_x_at_zero_is_one():
ps = get("calculus-limit@v1")
out = ps.canonicalize(b'{"f":"sin(x)/x","x":"x","point":"0"}')
assert out == sympy.srepr(sympy.Integer(1)).encode("utf-8")
def test_limit_polynomial_at_finite_point():
ps = get("calculus-limit@v1")
out = ps.canonicalize(b'{"f":"x**2 + 1","x":"x","point":"3"}')
# 9 + 1 = 10
assert out == sympy.srepr(sympy.Integer(10)).encode("utf-8")
def test_limit_one_over_x_at_zero_plus_is_oo():
ps = get("calculus-limit@v1")
out = ps.canonicalize(b'{"f":"1/x","x":"x","point":"0","dir":"+"}')
assert out == b"+oo"
def test_limit_one_over_x_at_zero_minus_is_minus_oo():
ps = get("calculus-limit@v1")
out = ps.canonicalize(b'{"f":"1/x","x":"x","point":"0","dir":"-"}')
assert out == b"-oo"
def test_limit_at_infinity():
ps = get("calculus-limit@v1")
out = ps.canonicalize(b'{"f":"1/x","x":"x","point":"oo"}')
assert out == sympy.srepr(sympy.Integer(0)).encode("utf-8")
def test_limit_invalid_dir_raises():
ps = get("calculus-limit@v1")
with pytest.raises(PiStarError):
ps.canonicalize(b'{"f":"x","x":"x","point":"0","dir":"invalid"}')
def test_limit_missing_f_raises():
ps = get("calculus-limit@v1")
with pytest.raises(PiStarError):
ps.canonicalize(b'{"x":"x","point":"0"}')
# ===== calculus-series@v1 (Phase 5) =======================================
def test_series_sinx_maclaurin_n4():
"""Taylor series of sin(x) at x=0 to 4 terms is x - x³/6."""
ps = get("calculus-series@v1")
out = ps.canonicalize(b'{"f":"sin(x)","x":"x","x0":"0","n":4}')
# Should canonicalize to x - x³/6 via expand+srepr.
expected = sympy.srepr(sympy.expand(
sympy.Symbol("x") - sympy.Rational(1, 6) * sympy.Symbol("x") ** 3
)).encode("utf-8")
assert out == expected
def test_series_exp_maclaurin_n3():
"""exp(x) Taylor at 0, n=3 → 1 + x + x²/2."""
ps = get("calculus-series@v1")
out = ps.canonicalize(b'{"f":"exp(x)","x":"x","x0":"0","n":3}')
x = sympy.Symbol("x")
expected = sympy.srepr(sympy.expand(
1 + x + sympy.Rational(1, 2) * x ** 2
)).encode("utf-8")
assert out == expected
def test_series_n_must_be_positive():
ps = get("calculus-series@v1")
with pytest.raises(PiStarError):
ps.canonicalize(b'{"f":"sin(x)","x":"x","x0":"0","n":0}')
def test_series_n_must_be_int():
ps = get("calculus-series@v1")
with pytest.raises(PiStarError):
ps.canonicalize(b'{"f":"sin(x)","x":"x","x0":"0","n":3.5}')
def test_series_missing_field_raises():
ps = get("calculus-series@v1")
with pytest.raises(PiStarError):
ps.canonicalize(b'{"f":"sin(x)","x":"x","n":4}')
# ===== linear-algebra@v1 (Phase 6) ========================================
def test_linalg_det_2x2():
ps = get("linear-algebra@v1")
# det [[1,2],[3,4]] = 1*4 - 2*3 = -2
out = ps.canonicalize(b'{"op":"det","matrix":[[1,2],[3,4]]}')
assert out == b"det:-2/1"
def test_linalg_det_3x3():
ps = get("linear-algebra@v1")
# Identity 3x3 has det 1.
out = ps.canonicalize(
b'{"op":"det","matrix":[[1,0,0],[0,1,0],[0,0,1]]}'
)
assert out == b"det:1/1"
def test_linalg_rref_collapses_dependent_rows():
"""RREF of [[2,4],[1,2]] is [[1,2],[0,0]] — one pivot."""
ps = get("linear-algebra@v1")
out = ps.canonicalize(b'{"op":"rref","matrix":[[2,4],[1,2]]}')
assert out == b"rref;rows=2;cols=2:1/1|2/1||0/1|0/1"
def test_linalg_inverse_2x2():
ps = get("linear-algebra@v1")
# [[2,0],[0,2]]^-1 = [[1/2,0],[0,1/2]]
out = ps.canonicalize(b'{"op":"inverse","matrix":[[2,0],[0,2]]}')
assert out == b"inverse;rows=2;cols=2:1/2|0/1||0/1|1/2"
def test_linalg_eigenvalues_diagonal():
"""Diagonal matrix has its diagonal entries as eigenvalues."""
ps = get("linear-algebra@v1")
out = ps.canonicalize(
b'{"op":"eigenvalues","matrix":[[3,0],[0,2]]}'
)
# Sorted by srepr → 2 first then 3.
assert out == b"eigenvalues:2/1x1|3/1x1"
def test_linalg_inverse_singular_raises():
"""Singular matrix has no inverse."""
ps = get("linear-algebra@v1")
with pytest.raises(PiStarError):
ps.canonicalize(b'{"op":"inverse","matrix":[[1,1],[1,1]]}')
def test_linalg_det_non_square_raises():
ps = get("linear-algebra@v1")
with pytest.raises(PiStarError):
ps.canonicalize(b'{"op":"det","matrix":[[1,2,3],[4,5,6]]}')
def test_linalg_unknown_op_raises():
ps = get("linear-algebra@v1")
with pytest.raises(PiStarError):
ps.canonicalize(b'{"op":"transpose","matrix":[[1,2],[3,4]]}')
def test_linalg_jagged_matrix_raises():
ps = get("linear-algebra@v1")
with pytest.raises(PiStarError):
ps.canonicalize(b'{"op":"det","matrix":[[1,2],[3,4,5]]}')
def test_linalg_cell_format_folds():
"""1, 1.0, '1.0', '1/1' all sympify to the same SymPy Integer/Rational
so the canonical bytes for det should match."""
ps = get("linear-algebra@v1")
a = ps.canonicalize(b'{"op":"det","matrix":[[1,2],[3,4]]}')
b = ps.canonicalize(b'{"op":"det","matrix":[[1.0,2.0],[3.0,4.0]]}')
c = ps.canonicalize(b'{"op":"det","matrix":[["1","2"],["3","4"]]}')
assert a == b == c
# ===== function-sampled@v1 (Phase 7) ======================================
def test_function_sampled_basic_polynomial():
"""x² sampled at 0,1,2,3,4 with dv=1 → 0|1|4|9|16."""
ps = get("function-sampled@v1")
out = ps.canonicalize(
b'{"f":"x**2","x":"x","x_min":0,"x_max":4,"n_samples":5,"dv":1}'
)
assert out == b"dt=1;dv=1;n=5;t0=0:0|1|4|9|16"
def test_function_sampled_equivalent_expressions_collapse():
"""sin(x) and 2*sin(x)/2 are textually different but evaluate
identically. Same canonical bytes."""
ps = get("function-sampled@v1")
a = ps.canonicalize(
b'{"f":"sin(x)","x":"x","x_min":0,"x_max":1,"n_samples":11,"dv":0.01}'
)
b = ps.canonicalize(
b'{"f":"2*sin(x)/2","x":"x","x_min":0,"x_max":1,"n_samples":11,"dv":0.01}'
)
assert a == b
def test_function_sampled_different_grid_distinct():
"""Different sample grid → different canonical."""
ps = get("function-sampled@v1")
a = ps.canonicalize(
b'{"f":"x","x":"x","x_min":0,"x_max":4,"n_samples":5,"dv":1}'
)
b = ps.canonicalize(
b'{"f":"x","x":"x","x_min":0,"x_max":4,"n_samples":9,"dv":1}'
)
assert a != b
def test_function_sampled_output_format_byte_compatible_with_time_series():
"""Output starts with the same 'dt=...;dv=...;n=...;t0=...:' header
as time-series-quantized@v1 so storage paths can treat both
interchangeably."""
ps = get("function-sampled@v1")
out = ps.canonicalize(
b'{"f":"x","x":"x","x_min":0,"x_max":2,"n_samples":3,"dv":1}'
).decode("utf-8")
assert out.startswith("dt=")
assert ";dv=" in out
assert ";n=" in out
assert ";t0=0:" in out
def test_function_sampled_x_max_must_exceed_x_min():
ps = get("function-sampled@v1")
with pytest.raises(PiStarError):
ps.canonicalize(
b'{"f":"x","x":"x","x_min":1,"x_max":1,"n_samples":3,"dv":1}'
)
def test_function_sampled_n_samples_minimum_two():
ps = get("function-sampled@v1")
with pytest.raises(PiStarError):
ps.canonicalize(
b'{"f":"x","x":"x","x_min":0,"x_max":1,"n_samples":1,"dv":1}'
)
def test_function_sampled_dv_must_be_positive():
ps = get("function-sampled@v1")
with pytest.raises(PiStarError):
ps.canonicalize(
b'{"f":"x","x":"x","x_min":0,"x_max":1,"n_samples":3,"dv":0}'
)
def test_function_sampled_complex_value_raises():
"""sqrt(x) at negative x is complex; should raise rather than
silently drop the imaginary part."""
ps = get("function-sampled@v1")
with pytest.raises(PiStarError):
ps.canonicalize(
b'{"f":"sqrt(x)","x":"x","x_min":-1,"x_max":1,"n_samples":3,"dv":0.1}'
)