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.
272 lines
8.4 KiB
Python
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}'
|
|
)
|