From abe5988bef33268b582ee5243a25d470052d754d Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sat, 9 May 2026 13:04:43 -0400 Subject: [PATCH] =?UTF-8?q?fan-out:=205=20=CF=80*=20graduations=20close=20?= =?UTF-8?q?the=20registry=20chapter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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() 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: eigenvalues: eigenvalues:x|... #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. --- Makefile | 30 ++ arborist/pi_star/__init__.py | 20 +- arborist/pi_star/calculus_limit.py | 218 ++++++++++++ arborist/pi_star/calculus_series.py | 171 ++++++++++ arborist/pi_star/function_sampled.py | 236 +++++++++++++ arborist/pi_star/linear_algebra.py | 284 ++++++++++++++++ arborist/pi_star/tabular.py | 309 +++++++++++++++++- bench/batteries/base.py | 7 + .../5s/semantics-calculus-limit-v1.jsonl | 13 + .../5s/semantics-calculus-series-v1.jsonl | 13 + .../5s/semantics-function-sampled-v1.jsonl | 13 + .../5s/semantics-linear-algebra-v1.jsonl | 13 + bench/fixtures/5s/semantics-tabular-v1.jsonl | 13 + .../5s/syntax-calculus-limit-v1.jsonl | 11 + .../5s/syntax-calculus-series-v1.jsonl | 11 + .../5s/syntax-function-sampled-v1.jsonl | 11 + .../5s/syntax-linear-algebra-v1.jsonl | 11 + bench/fixtures/5s/syntax-tabular-v1.jsonl | 11 + docs/_source/pi-star.rst | 40 ++- tests/test_pi_star.py | 45 ++- tests/test_pi_star_phase_3_to_7.py | 272 +++++++++++++++ tests/test_pi_star_tabular.py | 233 +++++++++++++ 22 files changed, 1950 insertions(+), 35 deletions(-) create mode 100644 arborist/pi_star/calculus_limit.py create mode 100644 arborist/pi_star/calculus_series.py create mode 100644 arborist/pi_star/function_sampled.py create mode 100644 arborist/pi_star/linear_algebra.py create mode 100644 bench/fixtures/5s/semantics-calculus-limit-v1.jsonl create mode 100644 bench/fixtures/5s/semantics-calculus-series-v1.jsonl create mode 100644 bench/fixtures/5s/semantics-function-sampled-v1.jsonl create mode 100644 bench/fixtures/5s/semantics-linear-algebra-v1.jsonl create mode 100644 bench/fixtures/5s/semantics-tabular-v1.jsonl create mode 100644 bench/fixtures/5s/syntax-calculus-limit-v1.jsonl create mode 100644 bench/fixtures/5s/syntax-calculus-series-v1.jsonl create mode 100644 bench/fixtures/5s/syntax-function-sampled-v1.jsonl create mode 100644 bench/fixtures/5s/syntax-linear-algebra-v1.jsonl create mode 100644 bench/fixtures/5s/syntax-tabular-v1.jsonl create mode 100644 tests/test_pi_star_phase_3_to_7.py create mode 100644 tests/test_pi_star_tabular.py diff --git a/Makefile b/Makefile index ca63467..288e098 100644 --- a/Makefile +++ b/Makefile @@ -298,6 +298,36 @@ bench-5s-time-series: bootstrap ## 5S time-series-quantized π* (SQD §13.5; qua PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub semantics \ --fixtures bench/fixtures/5s/semantics-time-series-v1.jsonl +bench-5s-tabular: bootstrap ## 5S tabular-pinned π* (#000030/Phase tabular; declared-schema 2D structured-data canonicalizer) + PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub syntax \ + --fixtures bench/fixtures/5s/syntax-tabular-v1.jsonl + PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub semantics \ + --fixtures bench/fixtures/5s/semantics-tabular-v1.jsonl + +bench-5s-calculus-limit: bootstrap ## 5S calculus-limit π* (#000030 Phase 4) + PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub syntax \ + --fixtures bench/fixtures/5s/syntax-calculus-limit-v1.jsonl + PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub semantics \ + --fixtures bench/fixtures/5s/semantics-calculus-limit-v1.jsonl + +bench-5s-calculus-series: bootstrap ## 5S calculus-series π* (#000030 Phase 5) + PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub syntax \ + --fixtures bench/fixtures/5s/syntax-calculus-series-v1.jsonl + PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub semantics \ + --fixtures bench/fixtures/5s/semantics-calculus-series-v1.jsonl + +bench-5s-linear-algebra: bootstrap ## 5S linear-algebra π* (#000030 Phase 6) + PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub syntax \ + --fixtures bench/fixtures/5s/syntax-linear-algebra-v1.jsonl + PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub semantics \ + --fixtures bench/fixtures/5s/semantics-linear-algebra-v1.jsonl + +bench-5s-function-sampled: bootstrap ## 5S function-sampled π* (#000030 Phase 7; SymPy → time-series bridge) + PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub syntax \ + --fixtures bench/fixtures/5s/syntax-function-sampled-v1.jsonl + PYTHONUNBUFFERED=1 $(PY) -m bench.batteries.runner --battery 5s --sub semantics \ + --fixtures bench/fixtures/5s/semantics-function-sampled-v1.jsonl + bench-real-shard: bootstrap ## #000026 Phase 2 — real-shard workload baseline (latency, audit, primary-source use) PYTHONUNBUFFERED=1 $(PY) -m bench.scripts.real_shard_baseline \ --shards-dir $${ARBORIST_SHARDS_DIR:-$$HOME/.arborist/shards} \ diff --git a/arborist/pi_star/__init__.py b/arborist/pi_star/__init__.py index 68499a5..5fe21f8 100644 --- a/arborist/pi_star/__init__.py +++ b/arborist/pi_star/__init__.py @@ -22,16 +22,26 @@ Concrete π*'s shipped today (alphabetical): thread-timeout + expand). Optional; gates on ``sympy``. Returns an ``unevaluated:Integral(...)`` sentinel when no closed form exists or the timeout fires. +- ``calculus-limit@v1`` — symbolic limit (one/two-sided + + ±∞ / complex-infinity sentinels). Optional; gates on ``sympy``. +- ``calculus-series@v1`` — truncated Taylor / Maclaurin series + (drops the ``O(x**n)`` remainder for finite-byte canonical). + Optional; gates on ``sympy``. - ``claim-lattice@v1`` — claim lines → JSON parsed-claim list. - ``code-py-ast@v1`` — Python source → canonical AST S-expression. +- ``function-sampled@v1`` — symbolic expression + sample grid → + quantized integer-vector signature in time-series-quantized@v1 + format. Optional; gates on ``sympy``. +- ``linear-algebra@v1`` — matrix RREF / det / eigenvalues / inverse + via ``{op, matrix}`` JSON. Optional; gates on ``sympy``. - ``logic-kernel@v1`` — propositional Boolean → CNF (SQD §14.3). +- ``tabular-pinned@v1`` — JSON-rows → pinned-schema canonical bytes + (last reserved-stub graduated; closes the registry chapter). - ``time-series-quantized@v1`` — JSON sample array → quantized integer vector (SQD §13.5). - ``wikitext-base@v1`` — wraps :func:`arborist.wikitext.to_base`. -Last reserved stub: - -- ``tabular-pinned@v1`` (raises :class:`NotImplementedError`). +The registry has no remaining reserved stubs. Composition theory: see ``docs/pi-star-composition.md``. """ @@ -63,8 +73,12 @@ from arborist.pi_star import algebra_symbolic_simplified # noqa: F401,E402 from arborist.pi_star import arithmetic # noqa: F401,E402 from arborist.pi_star import calculus_derivative # noqa: F401,E402 from arborist.pi_star import calculus_integral # noqa: F401,E402 +from arborist.pi_star import calculus_limit # noqa: F401,E402 +from arborist.pi_star import calculus_series # noqa: F401,E402 from arborist.pi_star import claim_lattice # noqa: F401,E402 from arborist.pi_star import code # noqa: F401,E402 +from arborist.pi_star import function_sampled # noqa: F401,E402 +from arborist.pi_star import linear_algebra # noqa: F401,E402 from arborist.pi_star import logic # noqa: F401,E402 from arborist.pi_star import tabular # noqa: F401,E402 from arborist.pi_star import text # noqa: F401,E402 diff --git a/arborist/pi_star/calculus_limit.py b/arborist/pi_star/calculus_limit.py new file mode 100644 index 0000000..92bbac8 --- /dev/null +++ b/arborist/pi_star/calculus_limit.py @@ -0,0 +1,218 @@ +"""``calculus-limit@v1`` π* — symbolic-limit canonicalizer. + +Domain: ``calculus``. Computes ``lim_{x → point} f(x)``, optionally +one-sided. Wraps ``sp.limit`` with a soft timeout so pathological +inputs cannot hang the caller indefinitely. + +Why this matters +---------------- +Ticket #000030 §2.2 Phase 4. Limit is the third calculus primitive +after derivative (Phase 2) and integral (Phase 3). Same JSON-shaped +input contract; canonicalizes through ``algebra-symbolic@v1`` for +finite-result cases. Infinite-result cases pin a stable spelling +(``+oo`` / ``-oo`` / ``zoo``) so two LLM-spellings of "infinity" +collapse. + +Input shape (JSON, UTF-8) +------------------------- +:: + + {"f": "", + "x": "", + "point": "", + "dir"?: "+" | "-" | "+-", + "timeout_seconds"?: } + +- ``f`` — the function (parsed via :func:`sympy.sympify`). +- ``x`` — the variable name. +- ``point`` — the limit point. Sympify-friendly: ``"0"``, ``"oo"``, + ``"-oo"``, ``"pi"`` all work. +- ``dir`` — optional one-sided limit direction. Default is the + two-sided limit (``"+-"`` in SymPy parlance, or omitted from the + call). ``"+"`` for right-side only, ``"-"`` for left-side. +- ``timeout_seconds`` — optional, default :data:`DEFAULT_TIMEOUT`. + +Output canonical bytes +---------------------- +Three paths: + +1. **Finite result** — ``sp.srepr(sp.expand(limit_result))`` UTF-8. + Same recipe as ``algebra-symbolic@v1`` so numerical limits + (``lim sin(x)/x → 1``) compose with the rest of the math + substrate. +2. **Infinite result** — pinned spelling: ``b"+oo"`` for ``+∞``, + ``b"-oo"`` for ``-∞``, ``b"zoo"`` for SymPy's ``ComplexInfinity``. + These don't go through ``sp.expand`` since ``Infinity`` is not + an algebraic object. +3. **No closed form / timeout** — bytes are + ``b"unevaluated:" + sp.srepr()``. Mirrors + ``calculus-integral@v1``'s sentinel discipline. + +Optional dependency +------------------- +Same as ``algebra-symbolic@v1``: SymPy via the ``[math]`` extras. + +Source: ticket #000030 §2.2 Phase 4 — landed 2026-05-09. +""" + +from __future__ import annotations + +import concurrent.futures +import json +from dataclasses import dataclass + +from arborist.pi_star.protocol import PiStarError +from arborist.pi_star.registry import register + + +try: + import sympy as sp # type: ignore[import-not-found] +except ImportError: # pragma: no cover — optional extra + sp = None # type: ignore[assignment] + + +DEFAULT_TIMEOUT = 30.0 + + +_SYMPY_REQUIRED_MSG = ( + "calculus-limit@v1 requires sympy; install with " + "`pip install 'arborist[math]'`" +) + + +_VALID_DIRS = ("+", "-", "+-") + + +def _serialize_infinity(result) -> bytes | None: + """Map SymPy infinities to pinned UTF-8 byte spellings, or None + if the result isn't an infinity case.""" + if result is sp.oo or result == sp.oo: + return b"+oo" + if result is -sp.oo or result == -sp.oo: + return b"-oo" + if result is sp.zoo or result == sp.zoo: + return b"zoo" + return None + + +def _is_unevaluated(result) -> bool: + """True when ``sp.limit`` returned an unevaluated form.""" + return isinstance(result, sp.Limit) + + +def _sentinel(unevaluated) -> bytes: + return b"unevaluated:" + sp.srepr(unevaluated).encode("utf-8") + + +def _build_unevaluated(f_expr, x_sym, point_expr, dir_): + if dir_ in (None, "+-"): + return sp.Limit(f_expr, x_sym, point_expr) + return sp.Limit(f_expr, x_sym, point_expr, dir_) + + +@dataclass +class CalculusLimitV1: + name: str = "calculus-limit" + version: str = "v1" + domain: str = "calculus" + + def canonicalize(self, raw: bytes) -> bytes: + if sp is None: + raise PiStarError(_SYMPY_REQUIRED_MSG) + if not isinstance(raw, (bytes, bytearray)): + raise PiStarError( + "calculus-limit@v1 expects bytes; got " + f"{type(raw).__name__}" + ) + try: + text = raw.decode("utf-8", errors="surrogatepass") + except UnicodeDecodeError as exc: # pragma: no cover + raise PiStarError(f"input not valid UTF-8: {exc}") from exc + + try: + obj = json.loads(text) + except json.JSONDecodeError as exc: + raise PiStarError( + f"calculus-limit@v1 input is not valid JSON: {exc}" + ) from exc + if not isinstance(obj, dict): + raise PiStarError( + "calculus-limit@v1 input must be a JSON object" + ) + for required in ("f", "x", "point"): + if required not in obj: + raise PiStarError( + f"calculus-limit@v1 missing required field {required!r}" + ) + + f_text = obj["f"] + x_name = obj["x"] + point_text = obj["point"] + dir_ = obj.get("dir") + if dir_ is not None and dir_ not in _VALID_DIRS: + raise PiStarError( + f"calculus-limit@v1 dir must be one of {_VALID_DIRS}; " + f"got {dir_!r}" + ) + timeout_s = float(obj.get("timeout_seconds", DEFAULT_TIMEOUT)) + + if not isinstance(f_text, str) or not f_text.strip(): + raise PiStarError("calculus-limit@v1 f must be a non-empty string") + if not isinstance(x_name, str) or not x_name.strip(): + raise PiStarError("calculus-limit@v1 x must be a non-empty string") + + try: + x_sym = sp.Symbol(x_name) + f_expr = sp.sympify(f_text, locals={x_name: x_sym}) + # ``point`` may be "oo", "-oo", "pi", "0", etc. + point_expr = sp.sympify(point_text) + except (sp.SympifyError, SyntaxError, TypeError) as exc: + raise PiStarError( + f"calculus-limit@v1 cannot parse: {exc}" + ) from exc + + # SymPy's limit() takes the dir as a string; default + # two-sided. Use a thread + timeout for the same reason + # calculus-integral does. + def _compute(): + if dir_ in (None, "+-"): + return sp.limit(f_expr, x_sym, point_expr) + return sp.limit(f_expr, x_sym, point_expr, dir_) + + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + future = ex.submit(_compute) + try: + result = future.result(timeout=timeout_s) + except concurrent.futures.TimeoutError: + return _sentinel( + _build_unevaluated(f_expr, x_sym, point_expr, dir_) + ) + except (sp.SympifyError, ValueError, TypeError) as exc: + raise PiStarError( + f"calculus-limit@v1 evaluation failed: {exc}" + ) from exc + + # Infinity / complex-infinity: pinned spelling. + inf_bytes = _serialize_infinity(result) + if inf_bytes is not None: + return inf_bytes + + # SymPy may also return an unevaluated Limit object on weird + # inputs even without timeout firing — fall back to sentinel. + if _is_unevaluated(result): + return _sentinel(result) + + # Finite result — canonicalize through algebra-symbolic@v1's + # recipe (sp.expand + sp.srepr). + try: + expanded = sp.expand(result) + except Exception as exc: # pragma: no cover — sympy edge cases + raise PiStarError( + f"calculus-limit@v1 cannot expand result: {exc}" + ) from exc + return sp.srepr(expanded).encode("utf-8") + + +if sp is not None: + register(CalculusLimitV1()) diff --git a/arborist/pi_star/calculus_series.py b/arborist/pi_star/calculus_series.py new file mode 100644 index 0000000..e32263d --- /dev/null +++ b/arborist/pi_star/calculus_series.py @@ -0,0 +1,171 @@ +"""``calculus-series@v1`` π* — Taylor / Maclaurin series canonicalizer. + +Domain: ``calculus``. Computes the truncated Taylor series of +``f`` around ``x = x0`` to ``n`` terms via ``sp.series``. The +``O(x**n)`` remainder is dropped — that's what makes the series +finite-byte canonical. + +Why this matters +---------------- +Ticket #000030 §2.2 Phase 5. Series complete the calculus-primitive +quartet (derivative · integral · limit · series) on the SymPy +substrate. All four ride ``algebra-symbolic@v1``'s +``sp.expand + sp.srepr`` normalizer for the closed-form output, so +the four kernels compose cleanly. + +Input shape (JSON, UTF-8) +------------------------- +:: + + {"f": "", + "x": "", + "x0": "", + "n": , + "timeout_seconds"?: } + +- ``f`` — the function to expand. +- ``x`` — the variable. +- ``x0`` — the expansion point. ``"0"`` gives a Maclaurin series. +- ``n`` — number of terms (the ``n`` in SymPy's ``series(..., n=n)``). + Must be a positive integer. +- ``timeout_seconds`` — optional, default :data:`DEFAULT_TIMEOUT`. + +Output canonical bytes +---------------------- +Two paths: + +1. **Series with closed-form coefficients** — the truncated + polynomial after ``.removeO()``, fed through + ``sp.expand + sp.srepr``. Two equivalent series collapse. +2. **Timeout** — bytes are + ``b"unevaluated:" + sp.srepr()``. Mirrors + ``calculus-integral@v1`` / ``calculus-limit@v1`` sentinel + discipline. + +Optional dependency +------------------- +Same as ``algebra-symbolic@v1``: SymPy via the ``[math]`` extras. + +Source: ticket #000030 §2.2 Phase 5 — landed 2026-05-09. +""" + +from __future__ import annotations + +import concurrent.futures +import json +from dataclasses import dataclass + +from arborist.pi_star.protocol import PiStarError +from arborist.pi_star.registry import register + + +try: + import sympy as sp # type: ignore[import-not-found] +except ImportError: # pragma: no cover — optional extra + sp = None # type: ignore[assignment] + + +DEFAULT_TIMEOUT = 30.0 + + +_SYMPY_REQUIRED_MSG = ( + "calculus-series@v1 requires sympy; install with " + "`pip install 'arborist[math]'`" +) + + +def _sentinel(f_expr, x_sym, x0_expr, n: int) -> bytes: + # Build a stable sentinel from inputs (we don't have a SymPy + # "unevaluated series" class). Hash-able by srepr of the tuple. + payload = f"Series({sp.srepr(f_expr)}, {sp.srepr(x_sym)}, {sp.srepr(x0_expr)}, {n})" + return b"unevaluated:" + payload.encode("utf-8") + + +@dataclass +class CalculusSeriesV1: + name: str = "calculus-series" + version: str = "v1" + domain: str = "calculus" + + def canonicalize(self, raw: bytes) -> bytes: + if sp is None: + raise PiStarError(_SYMPY_REQUIRED_MSG) + if not isinstance(raw, (bytes, bytearray)): + raise PiStarError( + "calculus-series@v1 expects bytes; got " + f"{type(raw).__name__}" + ) + try: + text = raw.decode("utf-8", errors="surrogatepass") + except UnicodeDecodeError as exc: # pragma: no cover + raise PiStarError(f"input not valid UTF-8: {exc}") from exc + + try: + obj = json.loads(text) + except json.JSONDecodeError as exc: + raise PiStarError( + f"calculus-series@v1 input is not valid JSON: {exc}" + ) from exc + if not isinstance(obj, dict): + raise PiStarError( + "calculus-series@v1 input must be a JSON object" + ) + for required in ("f", "x", "x0", "n"): + if required not in obj: + raise PiStarError( + f"calculus-series@v1 missing required field " + f"{required!r}" + ) + + f_text = obj["f"] + x_name = obj["x"] + x0_text = obj["x0"] + n_terms = obj["n"] + timeout_s = float(obj.get("timeout_seconds", DEFAULT_TIMEOUT)) + + if not isinstance(f_text, str) or not f_text.strip(): + raise PiStarError("calculus-series@v1 f must be a non-empty string") + if not isinstance(x_name, str) or not x_name.strip(): + raise PiStarError("calculus-series@v1 x must be a non-empty string") + # JSON booleans are subclasses of int in Python; reject explicitly. + if isinstance(n_terms, bool) or not isinstance(n_terms, int) or n_terms < 1: + raise PiStarError( + f"calculus-series@v1 n must be a positive integer; got " + f"{n_terms!r}" + ) + + try: + x_sym = sp.Symbol(x_name) + f_expr = sp.sympify(f_text, locals={x_name: x_sym}) + x0_expr = sp.sympify(x0_text) + except (sp.SympifyError, SyntaxError, TypeError) as exc: + raise PiStarError( + f"calculus-series@v1 cannot parse: {exc}" + ) from exc + + def _compute(): + return sp.series(f_expr, x_sym, x0_expr, n_terms).removeO() + + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + future = ex.submit(_compute) + try: + truncated = future.result(timeout=timeout_s) + except concurrent.futures.TimeoutError: + return _sentinel(f_expr, x_sym, x0_expr, n_terms) + except (sp.SympifyError, ValueError, TypeError) as exc: + raise PiStarError( + f"calculus-series@v1 evaluation failed: {exc}" + ) from exc + + try: + expanded = sp.expand(truncated) + except Exception as exc: # pragma: no cover + raise PiStarError( + f"calculus-series@v1 cannot expand result: {exc}" + ) from exc + return sp.srepr(expanded).encode("utf-8") + + +if sp is not None: + register(CalculusSeriesV1()) diff --git a/arborist/pi_star/function_sampled.py b/arborist/pi_star/function_sampled.py new file mode 100644 index 0000000..638652c --- /dev/null +++ b/arborist/pi_star/function_sampled.py @@ -0,0 +1,236 @@ +"""``function-sampled@v1`` π* — symbolic function sampler. + +Domain: ``function-sampled``. Bridges symbolic SymPy expressions +to the existing ``time-series-quantized@v1`` integer-vector +canonical form. Two functions that render identically (within +sample-grid tolerance) collapse to the same canonical bytes. + +Why this matters +---------------- +Ticket #000030 §2.2 Phase 7. This is what plotting CAN become in +π* terms: a function is identified by its quantized samples on a +declared grid. Different libraries / DPIs / palettes don't matter +— the canonical bytes pin the value-at-each-x. The PNG render is +a downstream view of the same canonical evidence. + +Input shape (JSON, UTF-8) +------------------------- +:: + + { + "f": "", + "x": "", + "x_min": , + "x_max": , + "n_samples": , + "dv": + } + +- ``f`` — expression to sample (SymPy parsable). +- ``x`` — independent-variable name. +- ``x_min``, ``x_max`` — sample-grid endpoints (inclusive). +- ``n_samples`` — number of points on the grid (linspace). +- ``dv`` — value-quantization step. Same role as + ``time-series-quantized@v1`` ``dv``: each y-sample is rounded to + ``round(y / dv)`` (banker's rounding). + +Output canonical bytes +---------------------- +Reuses ``time-series-quantized@v1`` exactly:: + + dt=
;dv=;n=;t0=:v0|v1|v2|... + +Where ``dt = (x_max - x_min) / (n_samples - 1)``, ``t0 = 0`` (the +linspace index), and each ``v_i = round(f(x_i) / dv)``. This +makes the sampled signature byte-compatible with +``time-series-quantized@v1`` storage and inspection. + +Equivalence classes preserved +----------------------------- +- Two expressions that evaluate to the same numeric values on the + same grid → same bytes. ``sin(x)`` and ``2*sin(x)/2`` collapse + trivially; the harder cases (``sin(x)**2 + cos(x)**2`` ≡ ``1``) + don't go through SymPy `simplify` here, so they only collapse + if the sampler observes the same numeric values within the dv + tolerance — which they will. +- Different but equivalent JSON whitespace / formatting: same. + +Equivalence classes kept distinct +--------------------------------- +- Different grid (x_min, x_max, n_samples) → different canonical. + The sample-grid IS part of identity. +- Different ``dv`` (quantization tightness) → different canonical. +- Different expression that diverges at any sample → different. + +Round-trip property +------------------- +Projective. The canonical text isn't valid input JSON; rerunning +on the canonical bytes raises :class:`PiStarError`. + +Optional dependency +------------------- +SymPy via the ``[math]`` extras (lambdify needs it). NumPy is +required transitively but ships standalone via SymPy's lambdify +backend; tests use ``pytest.importorskip("sympy")``. + +Source: ticket #000030 §2.2 Phase 7 — landed 2026-05-09. +""" + +from __future__ import annotations + +import json +import math +from dataclasses import dataclass + +from arborist.pi_star.protocol import PiStarError +from arborist.pi_star.registry import register + + +try: + import sympy as sp # type: ignore[import-not-found] +except ImportError: # pragma: no cover — optional extra + sp = None # type: ignore[assignment] + + +_SYMPY_REQUIRED_MSG = ( + "function-sampled@v1 requires sympy; install with " + "`pip install 'arborist[math]'`" +) + + +def _format_num(x) -> str: + """Mirror time_series._num: int → 'N'; integer-valued float → 'N'; + otherwise repr (shortest round-trip).""" + if isinstance(x, int) or (isinstance(x, float) and x.is_integer()): + return str(int(x)) + return repr(x) + + +@dataclass +class FunctionSampledV1: + name: str = "function-sampled" + version: str = "v1" + domain: str = "function-sampled" + + def canonicalize(self, raw: bytes) -> bytes: + if sp is None: + raise PiStarError(_SYMPY_REQUIRED_MSG) + if not isinstance(raw, (bytes, bytearray)): + raise PiStarError( + "function-sampled@v1 expects bytes; got " + f"{type(raw).__name__}" + ) + try: + text = raw.decode("utf-8", errors="surrogatepass") + except UnicodeDecodeError as exc: # pragma: no cover + raise PiStarError(f"input not valid UTF-8: {exc}") from exc + + try: + obj = json.loads(text) + except json.JSONDecodeError as exc: + raise PiStarError( + f"function-sampled@v1 input is not valid JSON: {exc}" + ) from exc + if not isinstance(obj, dict): + raise PiStarError( + "function-sampled@v1 input must be a JSON object" + ) + for required in ("f", "x", "x_min", "x_max", "n_samples", "dv"): + if required not in obj: + raise PiStarError( + f"function-sampled@v1 missing required field " + f"{required!r}" + ) + + f_text = obj["f"] + x_name = obj["x"] + x_min = obj["x_min"] + x_max = obj["x_max"] + n_samples = obj["n_samples"] + dv = obj["dv"] + + if not isinstance(f_text, str) or not f_text.strip(): + raise PiStarError("function-sampled@v1 f must be non-empty string") + if not isinstance(x_name, str) or not x_name.strip(): + raise PiStarError("function-sampled@v1 x must be non-empty string") + if not isinstance(x_min, (int, float)) or isinstance(x_min, bool): + raise PiStarError("function-sampled@v1 x_min must be number") + if not isinstance(x_max, (int, float)) or isinstance(x_max, bool): + raise PiStarError("function-sampled@v1 x_max must be number") + if x_max <= x_min: + raise PiStarError( + f"function-sampled@v1 x_max must be > x_min; got " + f"x_min={x_min!r}, x_max={x_max!r}" + ) + if isinstance(n_samples, bool) or not isinstance(n_samples, int) or n_samples < 2: + raise PiStarError( + "function-sampled@v1 n_samples must be int >= 2" + ) + if isinstance(dv, bool) or not isinstance(dv, (int, float)) or dv <= 0: + raise PiStarError( + f"function-sampled@v1 dv must be positive number; got {dv!r}" + ) + + try: + x_sym = sp.Symbol(x_name) + f_expr = sp.sympify(f_text, locals={x_name: x_sym}) + except (sp.SympifyError, SyntaxError, TypeError) as exc: + raise PiStarError( + f"function-sampled@v1 cannot parse f: {exc}" + ) from exc + + # Sample without numpy — keep dependency surface small. The + # output is byte-identical to time-series-quantized@v1's + # rounding (Python's round() is banker's-rounding, matching + # numpy's default). + try: + f_callable = sp.lambdify(x_sym, f_expr, "math") + except (TypeError, ValueError) as exc: + raise PiStarError( + f"function-sampled@v1 cannot lambdify f: {exc}" + ) from exc + + dt_value = (x_max - x_min) / (n_samples - 1) + values: list[int] = [] + for i in range(n_samples): + xi = x_min + i * dt_value + try: + yi = f_callable(xi) + except (ValueError, ZeroDivisionError, OverflowError) as exc: + raise PiStarError( + f"function-sampled@v1 evaluation failed at x={xi!r}: " + f"{exc}" + ) from exc + if isinstance(yi, complex): + if abs(yi.imag) > 1e-12: + raise PiStarError( + f"function-sampled@v1 got complex value at " + f"x={xi!r}: {yi!r}" + ) + yi = yi.real + if not isinstance(yi, (int, float)): + raise PiStarError( + f"function-sampled@v1 non-numeric sample at x={xi!r}: " + f"{yi!r} (type {type(yi).__name__})" + ) + if math.isnan(yi) or math.isinf(yi): + raise PiStarError( + f"function-sampled@v1 non-finite sample at x={xi!r}: " + f"{yi!r}" + ) + values.append(int(round(yi / dv))) + + # Serialize in time-series-quantized@v1 format so the bytes + # compose identically (function-sampled output IS a quantized + # series, just one derived from a closed-form expression + # rather than an array of samples). + dt_s = _format_num(dt_value) + dv_s = _format_num(dv) + n = len(values) + body = "|".join(str(v) for v in values) + out = f"dt={dt_s};dv={dv_s};n={n};t0=0:{body}" + return out.encode("utf-8") + + +if sp is not None: + register(FunctionSampledV1()) diff --git a/arborist/pi_star/linear_algebra.py b/arborist/pi_star/linear_algebra.py new file mode 100644 index 0000000..9a7d742 --- /dev/null +++ b/arborist/pi_star/linear_algebra.py @@ -0,0 +1,284 @@ +"""``linear-algebra@v1`` π* — matrix-operation canonicalizer. + +Domain: ``linear-algebra``. One π* covers the whole linear-algebra +surface via an ``op`` field in the input JSON. Matrix entries are +folded through SymPy's exact-rational arithmetic so +``[[1, 2], [3, 4]]`` and ``[[1.0, 2.0], [3.0, 4.0]]`` collapse. + +Why this matters +---------------- +Ticket #000030 §2.2 Phase 6. Linear algebra rounds out the math +substrate alongside the calculus quartet. Same ``[math]`` extras +gate; same `srepr`-based canonical-bytes discipline as the rest +of the SymPy substrate. + +Input shape (JSON, UTF-8) +------------------------- +:: + + { + "op": "rref" | "det" | "eigenvalues" | "inverse", + "matrix": [[, , ...], ...] + } + +- ``op`` selects the operation. Each operation has its own canonical + output shape; see below. +- ``matrix`` is a row-major 2D list. Cells go through + :func:`sympy.sympify` so ``"1/2"``, ``1`` and ``1.0`` all work + and fold to the same rational (see ``arithmetic@v1`` discipline). + +Canonical output by op +---------------------- + +``op = "rref"`` — Reduced Row Echelon Form. Output: + + rref;rows=;cols=:cell00|cell01|...||cell10|... + +Cells are printed as ``num/den`` (rational lowest-terms) for +integer + rational entries; symbolic entries fall through +``sp.srepr``. + +``op = "det"`` — determinant. Output: + + det: + +``op = "eigenvalues"`` — eigenvalues with multiplicity. Output: + + eigenvalues:x|x|... + +Sorted by value's `srepr` for determinism (eigenvalues come back +as a dict from SymPy and would otherwise depend on insertion +order). + +``op = "inverse"`` — matrix inverse, same shape as ``rref``: + + inverse;rows=;cols=:cell00|... + +Equivalence classes preserved +----------------------------- +- Cell-formatting variations (``1`` ≡ ``1.0`` ≡ ``"1.0"``) collapse. +- Eigenvalue ordering is canonicalized (sorted lexically by + ``srepr``); two equivalent eigen-decompositions canonicalize + identically. + +Equivalence classes kept distinct +--------------------------------- +- Different ``op`` for same matrix → different canonical (the op + is part of identity). +- Different matrix entries → different canonical. +- Different matrix shape (rows/cols) → different canonical. + +Errors +------ +- Singular matrix on ``inverse`` → :class:`PiStarError`. +- Non-square matrix on ``det`` / ``inverse`` / ``eigenvalues`` → + :class:`PiStarError`. +- Unknown ``op`` → :class:`PiStarError`. + +Optional dependency +------------------- +Same as ``algebra-symbolic@v1``: SymPy via the ``[math]`` extras. + +Source: ticket #000030 §2.2 Phase 6 — landed 2026-05-09. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from arborist.pi_star.protocol import PiStarError +from arborist.pi_star.registry import register + + +try: + import sympy as sp # type: ignore[import-not-found] +except ImportError: # pragma: no cover — optional extra + sp = None # type: ignore[assignment] + + +_VALID_OPS = ("rref", "det", "eigenvalues", "inverse") + + +_SYMPY_REQUIRED_MSG = ( + "linear-algebra@v1 requires sympy; install with " + "`pip install 'arborist[math]'`" +) + + +def _format_cell(cell) -> str: + """Render a SymPy entry as canonical text. Rationals get + ``num/den``; symbolic values fall through ``srepr``.""" + if isinstance(cell, sp.Rational): + return f"{cell.p}/{cell.q}" + return sp.srepr(cell) + + +def _format_matrix(M) -> str: + rows = [] + for i in range(M.rows): + cells = [_format_cell(M[i, j]) for j in range(M.cols)] + rows.append("|".join(cells)) + return "||".join(rows) + + +@dataclass +class LinearAlgebraV1: + name: str = "linear-algebra" + version: str = "v1" + domain: str = "linear-algebra" + + def canonicalize(self, raw: bytes) -> bytes: + if sp is None: + raise PiStarError(_SYMPY_REQUIRED_MSG) + if not isinstance(raw, (bytes, bytearray)): + raise PiStarError( + "linear-algebra@v1 expects bytes; got " + f"{type(raw).__name__}" + ) + try: + text = raw.decode("utf-8", errors="surrogatepass") + except UnicodeDecodeError as exc: # pragma: no cover + raise PiStarError(f"input not valid UTF-8: {exc}") from exc + + try: + obj = json.loads(text) + except json.JSONDecodeError as exc: + raise PiStarError( + f"linear-algebra@v1 input is not valid JSON: {exc}" + ) from exc + if not isinstance(obj, dict): + raise PiStarError( + "linear-algebra@v1 input must be a JSON object" + ) + for required in ("op", "matrix"): + if required not in obj: + raise PiStarError( + f"linear-algebra@v1 missing required field " + f"{required!r}" + ) + + op = obj["op"] + matrix_raw = obj["matrix"] + if op not in _VALID_OPS: + raise PiStarError( + f"linear-algebra@v1 op must be one of {_VALID_OPS}; " + f"got {op!r}" + ) + if not isinstance(matrix_raw, list) or not matrix_raw: + raise PiStarError( + "linear-algebra@v1 matrix must be a non-empty 2D array" + ) + + # Sympify each cell. Floats are folded to exact Rational via + # Decimal(str(...)) so 1.0 ≡ 1 ≡ "1.0" all collapse to the + # same Rational(1, 1) — matching arithmetic@v1's discipline. + # Without this, sympy keeps floats as Float (separate type), + # and downstream det()/inverse() return Float-shaped bytes + # that don't match the int-input bytes. + from decimal import Decimal + from fractions import Fraction + sym_rows: list[list] = [] + n_cols = None + for ri, row in enumerate(matrix_raw): + if not isinstance(row, list): + raise PiStarError( + f"matrix row[{ri}] must be a JSON array" + ) + if n_cols is None: + n_cols = len(row) + elif len(row) != n_cols: + raise PiStarError( + f"matrix row[{ri}] has {len(row)} cells; " + f"row[0] has {n_cols} (jagged matrix)" + ) + sym_row = [] + for ci, c in enumerate(row): + try: + if isinstance(c, bool): + raise PiStarError( + f"matrix[{ri}][{ci}] is bool; not a number" + ) + if isinstance(c, float): + # Float → exact Fraction → SymPy Rational. Avoids + # sympy.Rational(Decimal) signature drift across + # versions and matches arithmetic@v1's discipline. + f = Fraction(Decimal(str(c))) + sym_row.append(sp.Rational(f.numerator, f.denominator)) + elif isinstance(c, int): + sym_row.append(sp.Integer(c)) + else: + sym_row.append(sp.sympify(c, rational=True)) + except (sp.SympifyError, SyntaxError, TypeError, ValueError) as exc: + raise PiStarError( + f"matrix[{ri}][{ci}] cannot sympify: {exc}" + ) from exc + sym_rows.append(sym_row) + + try: + M = sp.Matrix(sym_rows) + except (TypeError, ValueError) as exc: + raise PiStarError( + f"linear-algebra@v1 cannot build Matrix: {exc}" + ) from exc + + # Dispatch by op. + if op == "rref": + R, _pivots = M.rref() + body = ( + f"rref;rows={R.rows};cols={R.cols}:" + f"{_format_matrix(R)}" + ) + return body.encode("utf-8") + + if op == "det": + if M.rows != M.cols: + raise PiStarError( + f"linear-algebra@v1 det requires square matrix; " + f"got {M.rows}x{M.cols}" + ) + d = M.det() + return f"det:{_format_cell(d)}".encode("utf-8") + + if op == "inverse": + if M.rows != M.cols: + raise PiStarError( + f"linear-algebra@v1 inverse requires square matrix; " + f"got {M.rows}x{M.cols}" + ) + try: + Inv = M.inv() + except Exception as exc: + # SymPy raises NonInvertibleMatrixError on singular + # matrices; the import path drifts across versions + # (sympy.matrices.common in 1.13, sympy.matrices.exceptions + # in 1.14). Catch broadly + re-raise as PiStarError so + # we don't pin the import path. + raise PiStarError( + f"linear-algebra@v1 inverse failed: {exc}" + ) from exc + body = ( + f"inverse;rows={Inv.rows};cols={Inv.cols}:" + f"{_format_matrix(Inv)}" + ) + return body.encode("utf-8") + + if op == "eigenvalues": + if M.rows != M.cols: + raise PiStarError( + f"linear-algebra@v1 eigenvalues requires square matrix; " + f"got {M.rows}x{M.cols}" + ) + evs = M.eigenvals() # dict {value: multiplicity} + # Sort by srepr of the value for determinism. + ordered = sorted(evs.items(), key=lambda kv: sp.srepr(kv[0])) + parts = [f"{_format_cell(v)}x{m}" for v, m in ordered] + return f"eigenvalues:{'|'.join(parts)}".encode("utf-8") + + raise PiStarError( # pragma: no cover — _VALID_OPS exhausts + f"linear-algebra@v1 unknown op {op!r}" + ) + + +if sp is not None: + register(LinearAlgebraV1()) diff --git a/arborist/pi_star/tabular.py b/arborist/pi_star/tabular.py index 715dbcf..52be3d7 100644 --- a/arborist/pi_star/tabular.py +++ b/arborist/pi_star/tabular.py @@ -1,17 +1,99 @@ -"""``tabular-pinned@v1`` π* (stub). +"""``tabular-pinned@v1`` π* — declared-schema tabular canonicalizer. -Domain: ``tabular``. Planned semantics: declared schema (column order -+ types) + canonical row encoding (sorted by primary key, type-pinned -cells, normalized text). +Domain: ``tabular``. The last reserved-stub π* graduates: structured +2D data (rows × columns) gets first-class equivalence-class identity. + +This closes the π* registry chapter — every modality the substrate +paper reserved (text, claim_lattice, code, arithmetic, logic, +time-series, tabular, plus the math-substrate extras +algebra-symbolic, calculus-derivative) is now real. + +Input format +------------ +UTF-8 JSON object:: + + { + "schema": [ + {"name": "", "type": "str|int|rational|bool"}, + ... + ], + "key_columns": ["", ...], + "rows": [ + [, , ...], + ... + ] + } + +- ``schema`` declares column order + per-cell type. Types fold cell + values: ``int`` and ``rational`` go through SQD §14.1 rational + arithmetic (1, 1.0, "1.0" all collapse to ``1/1``). ``str`` keeps + the value verbatim; ``bool`` normalizes to ``true``/``false``. +- ``key_columns`` names the primary-key columns. Rows are sorted by + the (key_column_1, key_column_2, ...) tuple before serialization; + ties within key are stable. +- Empty ``rows`` is valid — canonicalizes to an empty body. + +Canonical output +---------------- +UTF-8 text of shape:: + + schema=col1:type1,col2:type2,... + key=col1,col2,... + n= + rows: + cell11|cell12|... + cell21|cell22|... + ... + +Type codes are the schema's declared types (``str`` / ``int`` / +``rational`` / ``bool``). Cells are joined with ``|``; rows are +joined with ``\n``. Header always present even on empty tables. + +Equivalence classes preserved +----------------------------- +- Row order: rows are sorted by ``key_columns``; differently-ordered + inputs collapse. +- Numeric formatting: ``1`` ≡ ``1.0`` ≡ ``"1.0"`` for ``int`` / ``rational`` + columns (same rational identity through arithmetic@v1). +- JSON whitespace / formatting: same logical content, same canonical. +- Boolean spelling: ``true``/``True``/``"true"`` all normalize for + ``bool`` columns. + +Equivalence classes kept distinct +--------------------------------- +- Schema (column order, types, key_columns) is part of identity. + Two tables with different declared schema → different canonical. +- Different cell values → different canonical. +- Different ``key_columns`` → different canonical (changes the sort). +- Header case: pinned exact. ``Name`` ≠ ``name``. PostgreSQL + + Excel both care about case; defaulting to lowercase-fold would + break operator expectations. + +Round-trip property +------------------- +Projective. The canonical text form is not valid JSON input — +re-canonicalizing the canonical bytes raises PiStarError. + +Versioning +---------- +``tabular-pinned@v1`` pins this serialization, sort policy, and +type-folding rules. Any change requires a new version. """ from __future__ import annotations +import json from dataclasses import dataclass +from fractions import Fraction +from decimal import Decimal, InvalidOperation +from arborist.pi_star.protocol import PiStarError from arborist.pi_star.registry import register +_VALID_TYPES = ("str", "int", "rational", "bool") + + @dataclass class TabularPinnedV1: name: str = "tabular-pinned" @@ -19,9 +101,224 @@ class TabularPinnedV1: domain: str = "tabular" def canonicalize(self, raw: bytes) -> bytes: - raise NotImplementedError( - "tabular-pinned@v1 is a stub; implementation ticket pending." + if not isinstance(raw, (bytes, bytearray)): + raise PiStarError( + "tabular-pinned@v1 expects bytes; got " + f"{type(raw).__name__}" + ) + try: + text = raw.decode("utf-8", errors="surrogatepass") + except UnicodeDecodeError as exc: # pragma: no cover + raise PiStarError(f"input not valid UTF-8: {exc}") from exc + + try: + obj = json.loads(text) + except json.JSONDecodeError as exc: + raise PiStarError( + f"tabular-pinned@v1 input is not valid JSON: {exc}" + ) from exc + + if not isinstance(obj, dict): + raise PiStarError( + "tabular-pinned@v1 input must be a JSON object" + ) + for required in ("schema", "key_columns", "rows"): + if required not in obj: + raise PiStarError( + f"tabular-pinned@v1 missing required field " + f"{required!r}" + ) + + schema = obj["schema"] + key_columns = obj["key_columns"] + rows = obj["rows"] + + if not isinstance(schema, list) or not schema: + raise PiStarError( + "tabular-pinned@v1 schema must be a non-empty JSON array" + ) + if not isinstance(key_columns, list): + raise PiStarError( + "tabular-pinned@v1 key_columns must be a JSON array" + ) + if not isinstance(rows, list): + raise PiStarError( + "tabular-pinned@v1 rows must be a JSON array" + ) + + # Validate schema entries. + col_names: list[str] = [] + col_types: list[str] = [] + for i, entry in enumerate(schema): + if not isinstance(entry, dict): + raise PiStarError( + f"schema[{i}] must be a JSON object with name + type" + ) + name = entry.get("name") + ctype = entry.get("type") + if not isinstance(name, str) or not name: + raise PiStarError( + f"schema[{i}].name must be a non-empty string" + ) + if ctype not in _VALID_TYPES: + raise PiStarError( + f"schema[{i}].type must be one of " + f"{_VALID_TYPES}; got {ctype!r}" + ) + col_names.append(name) + col_types.append(ctype) + + # Validate key_columns reference real schema columns. + col_index = {n: i for i, n in enumerate(col_names)} + if len(set(col_names)) != len(col_names): + raise PiStarError( + "tabular-pinned@v1 schema column names must be unique" + ) + key_indices: list[int] = [] + for k in key_columns: + if not isinstance(k, str): + raise PiStarError("key_columns entries must be strings") + if k not in col_index: + raise PiStarError( + f"key_column {k!r} not in schema columns " + f"{col_names!r}" + ) + key_indices.append(col_index[k]) + + # Validate + fold each row. + n_cols = len(col_names) + folded: list[list[str]] = [] + for ri, row in enumerate(rows): + if not isinstance(row, list): + raise PiStarError( + f"rows[{ri}] must be a JSON array" + ) + if len(row) != n_cols: + raise PiStarError( + f"rows[{ri}] has {len(row)} cells; schema declares " + f"{n_cols}" + ) + folded_row: list[str] = [] + for ci, cell in enumerate(row): + folded_row.append(_fold_cell(cell, col_types[ci], ri, ci)) + folded.append(folded_row) + + # Sort by key tuple (stable). Empty key_columns → preserve + # input order (some tables have no PK; the operator declares + # that explicitly). + if key_indices: + folded.sort( + key=lambda r: tuple(r[i] for i in key_indices) + ) + + # Serialize. + schema_str = ",".join( + f"{n}:{t}" for n, t in zip(col_names, col_types) ) + key_str = ",".join(key_columns) + body = "\n".join("|".join(r) for r in folded) + n = len(folded) + out = ( + f"schema={schema_str}\n" + f"key={key_str}\n" + f"n={n}\n" + f"rows:\n" + f"{body}" + ) + # Trailing newline only when rows non-empty so the empty-table + # canonical is exactly: "schema=...\nkey=...\nn=0\nrows:". + return out.encode("utf-8") + + +def _fold_cell(cell, col_type: str, ri: int, ci: int) -> str: + """Fold one cell value into its canonical string form per type.""" + if col_type == "str": + if not isinstance(cell, str): + raise PiStarError( + f"rows[{ri}][{ci}] expected str cell; got " + f"{type(cell).__name__}" + ) + return cell + + if col_type == "bool": + if isinstance(cell, bool): + return "true" if cell else "false" + if isinstance(cell, str): + normalized = cell.strip().lower() + if normalized in ("true", "1", "yes"): + return "true" + if normalized in ("false", "0", "no"): + return "false" + raise PiStarError( + f"rows[{ri}][{ci}] expected bool-shape cell; got {cell!r}" + ) + + if col_type == "int": + # Accept int, float that's integer-valued, or string that parses. + if isinstance(cell, bool): + # bool is a subtype of int in Python; reject to avoid + # silently treating True as 1. + raise PiStarError( + f"rows[{ri}][{ci}] expected int cell; got bool" + ) + if isinstance(cell, int): + return str(cell) + if isinstance(cell, float): + if not cell.is_integer(): + raise PiStarError( + f"rows[{ri}][{ci}] expected int cell; got " + f"non-integer float {cell!r}" + ) + return str(int(cell)) + if isinstance(cell, str): + try: + f = Fraction(Decimal(cell.strip())) + except (ValueError, InvalidOperation) as exc: + raise PiStarError( + f"rows[{ri}][{ci}] cannot parse int from {cell!r}" + ) from exc + if f.denominator != 1: + raise PiStarError( + f"rows[{ri}][{ci}] expected int cell; got " + f"non-integer rational {cell!r}" + ) + return str(f.numerator) + raise PiStarError( + f"rows[{ri}][{ci}] cannot fold {cell!r} as int" + ) + + if col_type == "rational": + # Fold to num/den lowest-terms form, sharing arithmetic@v1's + # contract (Decimal(str(...)) for floats avoids drift). + if isinstance(cell, bool): + raise PiStarError( + f"rows[{ri}][{ci}] expected rational cell; got bool" + ) + try: + if isinstance(cell, int): + f = Fraction(cell, 1) + elif isinstance(cell, float): + f = Fraction(Decimal(str(cell))) + elif isinstance(cell, str): + stripped = cell.strip() + if "/" in stripped: + f = Fraction(stripped) + else: + f = Fraction(Decimal(stripped)) + else: + raise PiStarError( + f"rows[{ri}][{ci}] cannot fold {cell!r} as rational" + ) + except (ValueError, InvalidOperation, ZeroDivisionError) as exc: + raise PiStarError( + f"rows[{ri}][{ci}] cannot parse rational from {cell!r}: " + f"{exc}" + ) from exc + return f"{f.numerator}/{f.denominator}" + + raise PiStarError( # pragma: no cover — _VALID_TYPES exhausts above + f"rows[{ri}][{ci}] unknown type {col_type!r}" + ) register(TabularPinnedV1()) diff --git a/bench/batteries/base.py b/bench/batteries/base.py index 9662911..ce1f07f 100644 --- a/bench/batteries/base.py +++ b/bench/batteries/base.py @@ -122,6 +122,13 @@ PHASE_1_CARRIERS = frozenset({ # Calculus-derivative shares this carrier (its output is itself an # algebraic expression). "symbolic_algebra", + # Calculus / linear-algebra / function-sampled / tabular — ticket + # #000030 Phases 4-7 + last-stub graduation. Each closes one more + # modality the substrate paper reserved. + "calculus", + "linear-algebra", + "function-sampled", + "tabular", }) diff --git a/bench/fixtures/5s/semantics-calculus-limit-v1.jsonl b/bench/fixtures/5s/semantics-calculus-limit-v1.jsonl new file mode 100644 index 0000000..db80ead --- /dev/null +++ b/bench/fixtures/5s/semantics-calculus-limit-v1.jsonl @@ -0,0 +1,13 @@ +{"_meta": {"battery": "5s", "sub_battery": "semantics", "version": "v1", "task_count": 12, "notes": "calculus-limit@v1 semantics — same limit collapses regardless of f spelling; oo-direction-distinct."}} +{"id": "5s-sem-lim-001", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"sin(x)/x\", \"x\": \"x\", \"point\": \"0\"}", "input_b": "{\"f\": \"sin(x)*1/x\", \"x\": \"x\", \"point\": \"0\"}", "expected_equivalent": true} +{"id": "5s-sem-lim-002", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"2*sin(x)/x\", \"x\": \"x\", \"point\": \"0\"}", "input_b": "{\"f\": \"2*sin(x)/x\", \"x\": \"x\", \"point\": \"0\"}", "expected_equivalent": true} +{"id": "5s-sem-lim-003", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"x\", \"x\": \"x\", \"point\": \"0\"}", "input_b": "{\"f\": \"x\", \"x\": \"x\", \"point\": \"1\"}", "expected_equivalent": false} +{"id": "5s-sem-lim-004", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"sin(x)/x\", \"x\": \"x\", \"point\": \"0\"}", "input_b": "{\"f\": \"cos(x)/x\", \"x\": \"x\", \"point\": \"0\"}", "expected_equivalent": false} +{"id": "5s-sem-lim-005", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"1/x\", \"x\": \"x\", \"point\": \"0\", \"dir\": \"+\"}", "input_b": "{\"f\": \"1/x\", \"x\": \"x\", \"point\": \"0\", \"dir\": \"+\"}", "expected_equivalent": true} +{"id": "5s-sem-lim-006", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"1/x\", \"x\": \"x\", \"point\": \"0\", \"dir\": \"+\"}", "input_b": "{\"f\": \"1/x\", \"x\": \"x\", \"point\": \"0\", \"dir\": \"-\"}", "expected_equivalent": false} +{"id": "5s-sem-lim-007", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"x**2\", \"x\": \"x\", \"point\": \"3\"}", "input_b": "{\"f\": \"x*x\", \"x\": \"x\", \"point\": \"3\"}", "expected_equivalent": true} +{"id": "5s-sem-lim-008", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"x\", \"x\": \"x\", \"point\": \"oo\"}", "input_b": "{\"f\": \"x\", \"x\": \"x\", \"point\": \"-oo\"}", "expected_equivalent": false} +{"id": "5s-sem-lim-009", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"0\", \"x\": \"x\", \"point\": \"0\"}", "input_b": "{\"f\": \"0\", \"x\": \"x\", \"point\": \"5\"}", "expected_equivalent": true} +{"id": "5s-sem-lim-010", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"y**2\", \"x\": \"y\", \"point\": \"2\"}", "input_b": "{\"f\": \"x**2\", \"x\": \"x\", \"point\": \"2\"}", "expected_equivalent": true} +{"id": "5s-sem-lim-011", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"sin(x)/x\", \"x\": \"x\", \"point\": \"0\"}", "input_b": "{\"f\": \"1/x\", \"x\": \"x\", \"point\": \"oo\"}", "expected_equivalent": false} +{"id": "5s-sem-lim-012", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input_a": "{\"f\": \"log(x)\", \"x\": \"x\", \"point\": \"1\"}", "input_b": "{\"f\": \"0\", \"x\": \"x\", \"point\": \"5\"}", "expected_equivalent": true} diff --git a/bench/fixtures/5s/semantics-calculus-series-v1.jsonl b/bench/fixtures/5s/semantics-calculus-series-v1.jsonl new file mode 100644 index 0000000..ac22fda --- /dev/null +++ b/bench/fixtures/5s/semantics-calculus-series-v1.jsonl @@ -0,0 +1,13 @@ +{"_meta": {"battery": "5s", "sub_battery": "semantics", "version": "v1", "task_count": 12, "notes": "calculus-series@v1 semantics — truncation identity; algebraic fold; n/x0/f distinct."}} +{"id": "5s-sem-ser-001", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}", "input_b": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}", "expected_equivalent": true} +{"id": "5s-sem-ser-002", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}", "input_b": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 6}", "expected_equivalent": false} +{"id": "5s-sem-ser-003", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}", "input_b": "{\"f\": \"cos(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}", "expected_equivalent": false} +{"id": "5s-sem-ser-004", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}", "input_b": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x0\": \"pi\", \"n\": 4}", "expected_equivalent": false} +{"id": "5s-sem-ser-005", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"2*x\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}", "input_b": "{\"f\": \"x+x\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}", "expected_equivalent": true} +{"id": "5s-sem-ser-006", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"x**3\", \"x\": \"x\", \"x0\": \"0\", \"n\": 5}", "input_b": "{\"f\": \"x**3\", \"x\": \"x\", \"x0\": \"0\", \"n\": 5}", "expected_equivalent": true} +{"id": "5s-sem-ser-007", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"x**3\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}", "input_b": "{\"f\": \"x**3\", \"x\": \"x\", \"x0\": \"0\", \"n\": 5}", "expected_equivalent": false} +{"id": "5s-sem-ser-008", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}", "input_b": "{\"f\": \"sin(y)\", \"x\": \"y\", \"x0\": \"0\", \"n\": 4}", "expected_equivalent": false} +{"id": "5s-sem-ser-009", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"exp(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}", "input_b": "{\"f\": \"exp(2*x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}", "expected_equivalent": false} +{"id": "5s-sem-ser-010", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"5\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}", "input_b": "{\"f\": \"5\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}", "expected_equivalent": true} +{"id": "5s-sem-ser-011", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"0\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}", "input_b": "{\"f\": \"0\", \"x\": \"x\", \"x0\": \"5\", \"n\": 7}", "expected_equivalent": true} +{"id": "5s-sem-ser-012", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input_a": "{\"f\": \"1/(1-x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}", "input_b": "{\"f\": \"1/(1-x)\", \"x\": \"x\", \"x0\": \"1/2\", \"n\": 3}", "expected_equivalent": false} diff --git a/bench/fixtures/5s/semantics-function-sampled-v1.jsonl b/bench/fixtures/5s/semantics-function-sampled-v1.jsonl new file mode 100644 index 0000000..e19b0ce --- /dev/null +++ b/bench/fixtures/5s/semantics-function-sampled-v1.jsonl @@ -0,0 +1,13 @@ +{"_meta": {"battery": "5s", "sub_battery": "semantics", "version": "v1", "task_count": 12, "notes": "function-sampled@v1 semantics — same numeric profile collapses; grid/dv/f distinct."}} +{"id": "5s-sem-fs-001", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 1, \"n_samples\": 5, \"dv\": 0.01}", "input_b": "{\"f\": \"2*sin(x)/2\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 1, \"n_samples\": 5, \"dv\": 0.01}", "expected_equivalent": true} +{"id": "5s-sem-fs-002", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "input_b": "{\"f\": \"x*1\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "expected_equivalent": true} +{"id": "5s-sem-fs-003", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "input_b": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 5, \"n_samples\": 5, \"dv\": 1}", "expected_equivalent": false} +{"id": "5s-sem-fs-004", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "input_b": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 9, \"dv\": 1}", "expected_equivalent": false} +{"id": "5s-sem-fs-005", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "input_b": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 0.5}", "expected_equivalent": false} +{"id": "5s-sem-fs-006", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"x**2\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "input_b": "{\"f\": \"x*x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "expected_equivalent": true} +{"id": "5s-sem-fs-007", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "input_b": "{\"f\": \"x+1\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "expected_equivalent": false} +{"id": "5s-sem-fs-008", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"y\", \"x\": \"y\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "input_b": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "expected_equivalent": true} +{"id": "5s-sem-fs-009", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "input_b": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": -4, \"x_max\": 0, \"n_samples\": 5, \"dv\": 1}", "expected_equivalent": false} +{"id": "5s-sem-fs-010", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"0\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "input_b": "{\"f\": \"0\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "expected_equivalent": true} +{"id": "5s-sem-fs-011", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"0\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "input_b": "{\"f\": \"1\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}", "expected_equivalent": false} +{"id": "5s-sem-fs-012", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input_a": "{\"f\": \"sin(x)+sin(x)\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 1, \"n_samples\": 11, \"dv\": 0.01}", "input_b": "{\"f\": \"2*sin(x)\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 1, \"n_samples\": 11, \"dv\": 0.01}", "expected_equivalent": true} diff --git a/bench/fixtures/5s/semantics-linear-algebra-v1.jsonl b/bench/fixtures/5s/semantics-linear-algebra-v1.jsonl new file mode 100644 index 0000000..0c62952 --- /dev/null +++ b/bench/fixtures/5s/semantics-linear-algebra-v1.jsonl @@ -0,0 +1,13 @@ +{"_meta": {"battery": "5s", "sub_battery": "semantics", "version": "v1", "task_count": 12, "notes": "linear-algebra@v1 semantics — int/float/string cell fold; eigenvalue ordering; op-distinct."}} +{"id": "5s-sem-la-001", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"det\", \"matrix\": [[1, 2], [3, 4]]}", "input_b": "{\"op\": \"det\", \"matrix\": [[1.0, 2.0], [3.0, 4.0]]}", "expected_equivalent": true} +{"id": "5s-sem-la-002", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"det\", \"matrix\": [[1, 2], [3, 4]]}", "input_b": "{\"op\": \"det\", \"matrix\": [[\"1\", \"2\"], [\"3\", \"4\"]]}", "expected_equivalent": true} +{"id": "5s-sem-la-003", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"det\", \"matrix\": [[2, 0], [0, 2]]}", "input_b": "{\"op\": \"rref\", \"matrix\": [[2, 0], [0, 2]]}", "expected_equivalent": false} +{"id": "5s-sem-la-004", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"det\", \"matrix\": [[1, 2], [3, 4]]}", "input_b": "{\"op\": \"det\", \"matrix\": [[1, 2], [3, 5]]}", "expected_equivalent": false} +{"id": "5s-sem-la-005", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"det\", \"matrix\": [[1, 0], [0, 1]]}", "input_b": "{\"op\": \"det\", \"matrix\": [[\"1\", \"0\"], [\"0\", \"1.0\"]]}", "expected_equivalent": true} +{"id": "5s-sem-la-006", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"det\", \"matrix\": [[1, 0], [0, 1]]}", "input_b": "{\"op\": \"det\", \"matrix\": [[1, 0, 0], [0, 1, 0], [0, 0, 1]]}", "expected_equivalent": true} +{"id": "5s-sem-la-007", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"rref\", \"matrix\": [[2, 4], [1, 2]]}", "input_b": "{\"op\": \"rref\", \"matrix\": [[1, 2], [2, 4]]}", "expected_equivalent": true} +{"id": "5s-sem-la-008", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"eigenvalues\", \"matrix\": [[2, 0], [0, 3]]}", "input_b": "{\"op\": \"eigenvalues\", \"matrix\": [[3, 0], [0, 2]]}", "expected_equivalent": true} +{"id": "5s-sem-la-009", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"eigenvalues\", \"matrix\": [[1, 0], [0, 2]]}", "input_b": "{\"op\": \"eigenvalues\", \"matrix\": [[1, 0], [0, 3]]}", "expected_equivalent": false} +{"id": "5s-sem-la-010", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"inverse\", \"matrix\": [[1, 0], [0, 1]]}", "input_b": "{\"op\": \"inverse\", \"matrix\": [[1, 0], [0, 1]]}", "expected_equivalent": true} +{"id": "5s-sem-la-011", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"inverse\", \"matrix\": [[2, 0], [0, 2]]}", "input_b": "{\"op\": \"inverse\", \"matrix\": [[1, 0], [0, 1]]}", "expected_equivalent": false} +{"id": "5s-sem-la-012", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input_a": "{\"op\": \"det\", \"matrix\": [[\"1/2\", 0], [0, \"1/2\"]]}", "input_b": "{\"op\": \"det\", \"matrix\": [[0.5, 0], [0, 0.5]]}", "expected_equivalent": true} diff --git a/bench/fixtures/5s/semantics-tabular-v1.jsonl b/bench/fixtures/5s/semantics-tabular-v1.jsonl new file mode 100644 index 0000000..9c776ab --- /dev/null +++ b/bench/fixtures/5s/semantics-tabular-v1.jsonl @@ -0,0 +1,13 @@ +{"_meta": {"battery": "5s", "sub_battery": "semantics", "version": "v1", "task_count": 12, "notes": "tabular-pinned@v1 semantics — row-order/cell-fold collapses; schema/key/case distinct."}} +{"id": "5s-sem-tab-001", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"k\", \"type\": \"int\"}], \"key_columns\": [\"k\"], \"rows\": [[2], [1]]}", "input_b": "{\"schema\": [{\"name\": \"k\", \"type\": \"int\"}], \"key_columns\": [\"k\"], \"rows\": [[1], [2]]}", "expected_equivalent": true} +{"id": "5s-sem-tab-002", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"v\", \"type\": \"int\"}], \"key_columns\": [], \"rows\": [[1]]}", "input_b": "{\"schema\": [{\"name\": \"v\", \"type\": \"int\"}], \"key_columns\": [], \"rows\": [[1.0]]}", "expected_equivalent": true} +{"id": "5s-sem-tab-003", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"v\", \"type\": \"int\"}], \"key_columns\": [], \"rows\": [[1]]}", "input_b": "{\"schema\": [{\"name\": \"v\", \"type\": \"int\"}], \"key_columns\": [], \"rows\": [[\"1.0\"]]}", "expected_equivalent": true} +{"id": "5s-sem-tab-004", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"r\", \"type\": \"rational\"}], \"key_columns\": [], \"rows\": [[0.5]]}", "input_b": "{\"schema\": [{\"name\": \"r\", \"type\": \"rational\"}], \"key_columns\": [], \"rows\": [[\"1/2\"]]}", "expected_equivalent": true} +{"id": "5s-sem-tab-005", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"f\", \"type\": \"bool\"}], \"key_columns\": [], \"rows\": [[true]]}", "input_b": "{\"schema\": [{\"name\": \"f\", \"type\": \"bool\"}], \"key_columns\": [], \"rows\": [[\"true\"]]}", "expected_equivalent": true} +{"id": "5s-sem-tab-006", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"Name\", \"type\": \"str\"}], \"key_columns\": [], \"rows\": [[\"x\"]]}", "input_b": "{\"schema\": [{\"name\": \"name\", \"type\": \"str\"}], \"key_columns\": [], \"rows\": [[\"x\"]]}", "expected_equivalent": false} +{"id": "5s-sem-tab-007", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"v\", \"type\": \"int\"}], \"key_columns\": [], \"rows\": [[1]]}", "input_b": "{\"schema\": [{\"name\": \"v\", \"type\": \"int\"}], \"key_columns\": [], \"rows\": [[2]]}", "expected_equivalent": false} +{"id": "5s-sem-tab-008", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"a\", \"type\": \"int\"}], \"key_columns\": [], \"rows\": [[1]]}", "input_b": "{\"schema\": [{\"name\": \"b\", \"type\": \"int\"}], \"key_columns\": [], \"rows\": [[1]]}", "expected_equivalent": false} +{"id": "5s-sem-tab-009", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"a\", \"type\": \"int\"}, {\"name\": \"b\", \"type\": \"int\"}], \"key_columns\": [\"a\"], \"rows\": [[2, 1], [1, 2]]}", "input_b": "{\"schema\": [{\"name\": \"a\", \"type\": \"int\"}, {\"name\": \"b\", \"type\": \"int\"}], \"key_columns\": [\"b\"], \"rows\": [[2, 1], [1, 2]]}", "expected_equivalent": false} +{"id": "5s-sem-tab-010", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"x\", \"type\": \"int\"}], \"key_columns\": [], \"rows\": []}", "input_b": "{\"schema\": [{\"name\": \"x\", \"type\": \"int\"}], \"key_columns\": [], \"rows\": []}", "expected_equivalent": true} +{"id": "5s-sem-tab-011", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"x\", \"type\": \"int\"}], \"key_columns\": [\"x\"], \"rows\": [[1]]}", "input_b": "{\"schema\": [{\"name\": \"x\", \"type\": \"int\"}], \"key_columns\": [\"x\"], \"rows\": [[1], [2]]}", "expected_equivalent": false} +{"id": "5s-sem-tab-012", "battery": "5s", "sub_battery": "semantics", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input_a": "{\"schema\": [{\"name\": \"r\", \"type\": \"rational\"}], \"key_columns\": [], \"rows\": [[\"1/2\"]]}", "input_b": "{\"schema\": [{\"name\": \"r\", \"type\": \"rational\"}], \"key_columns\": [], \"rows\": [[\"2/4\"]]}", "expected_equivalent": true} diff --git a/bench/fixtures/5s/syntax-calculus-limit-v1.jsonl b/bench/fixtures/5s/syntax-calculus-limit-v1.jsonl new file mode 100644 index 0000000..2161807 --- /dev/null +++ b/bench/fixtures/5s/syntax-calculus-limit-v1.jsonl @@ -0,0 +1,11 @@ +{"_meta": {"battery": "5s", "sub_battery": "syntax", "version": "v1", "task_count": 10, "notes": "calculus-limit@v1 syntax — sin(x)/x at 0; 1/x at 0±; e at oo."}} +{"id": "5s-syn-lim-001", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input": "{\"f\": \"sin(x)/x\", \"x\": \"x\", \"point\": \"0\"}"} +{"id": "5s-syn-lim-002", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input": "{\"f\": \"x**2 + 1\", \"x\": \"x\", \"point\": \"3\"}"} +{"id": "5s-syn-lim-003", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input": "{\"f\": \"1/x\", \"x\": \"x\", \"point\": \"oo\"}"} +{"id": "5s-syn-lim-004", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input": "{\"f\": \"1/x\", \"x\": \"x\", \"point\": \"0\", \"dir\": \"+\"}"} +{"id": "5s-syn-lim-005", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input": "{\"f\": \"1/x\", \"x\": \"x\", \"point\": \"0\", \"dir\": \"-\"}"} +{"id": "5s-syn-lim-006", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input": "{\"f\": \"exp(-x)\", \"x\": \"x\", \"point\": \"oo\"}"} +{"id": "5s-syn-lim-007", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input": "{\"f\": \"log(x)\", \"x\": \"x\", \"point\": \"1\"}"} +{"id": "5s-syn-lim-008", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input": "{\"f\": \"(1+1/x)**x\", \"x\": \"x\", \"point\": \"oo\"}"} +{"id": "5s-syn-lim-009", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input": "{\"f\": \"tan(x)\", \"x\": \"x\", \"point\": \"0\"}"} +{"id": "5s-syn-lim-010", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-limit@v1", "input": "{\"f\": \"x*sin(1/x)\", \"x\": \"x\", \"point\": \"0\"}"} diff --git a/bench/fixtures/5s/syntax-calculus-series-v1.jsonl b/bench/fixtures/5s/syntax-calculus-series-v1.jsonl new file mode 100644 index 0000000..30ddd81 --- /dev/null +++ b/bench/fixtures/5s/syntax-calculus-series-v1.jsonl @@ -0,0 +1,11 @@ +{"_meta": {"battery": "5s", "sub_battery": "syntax", "version": "v1", "task_count": 10, "notes": "calculus-series@v1 syntax — Maclaurin/Taylor truncation."}} +{"id": "5s-syn-ser-001", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}"} +{"id": "5s-syn-ser-002", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input": "{\"f\": \"cos(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}"} +{"id": "5s-syn-ser-003", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input": "{\"f\": \"exp(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}"} +{"id": "5s-syn-ser-004", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input": "{\"f\": \"1/(1-x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}"} +{"id": "5s-syn-ser-005", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input": "{\"f\": \"log(1+x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}"} +{"id": "5s-syn-ser-006", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x0\": \"pi\", \"n\": 3}"} +{"id": "5s-syn-ser-007", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input": "{\"f\": \"x**3 + 2*x\", \"x\": \"x\", \"x0\": \"0\", \"n\": 5}"} +{"id": "5s-syn-ser-008", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input": "{\"f\": \"1/(1+x**2)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 4}"} +{"id": "5s-syn-ser-009", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input": "{\"f\": \"tan(x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}"} +{"id": "5s-syn-ser-010", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "calculus", "domain": "json_object", "pi_star_ref": "calculus-series@v1", "input": "{\"f\": \"sqrt(1+x)\", \"x\": \"x\", \"x0\": \"0\", \"n\": 3}"} diff --git a/bench/fixtures/5s/syntax-function-sampled-v1.jsonl b/bench/fixtures/5s/syntax-function-sampled-v1.jsonl new file mode 100644 index 0000000..4f3298e --- /dev/null +++ b/bench/fixtures/5s/syntax-function-sampled-v1.jsonl @@ -0,0 +1,11 @@ +{"_meta": {"battery": "5s", "sub_battery": "syntax", "version": "v1", "task_count": 10, "notes": "function-sampled@v1 syntax — SymPy expr → quantized sample grid."}} +{"id": "5s-syn-fs-001", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}"} +{"id": "5s-syn-fs-002", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input": "{\"f\": \"x**2\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 4, \"n_samples\": 5, \"dv\": 1}"} +{"id": "5s-syn-fs-003", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input": "{\"f\": \"sin(x)\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 1, \"n_samples\": 11, \"dv\": 0.01}"} +{"id": "5s-syn-fs-004", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input": "{\"f\": \"2*x + 1\", \"x\": \"x\", \"x_min\": -2, \"x_max\": 2, \"n_samples\": 5, \"dv\": 1}"} +{"id": "5s-syn-fs-005", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input": "{\"f\": \"exp(x)\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 1, \"n_samples\": 11, \"dv\": 0.1}"} +{"id": "5s-syn-fs-006", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input": "{\"f\": \"cos(x)\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 3.14159, \"n_samples\": 4, \"dv\": 0.1}"} +{"id": "5s-syn-fs-007", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input": "{\"f\": \"x**3\", \"x\": \"x\", \"x_min\": -1, \"x_max\": 1, \"n_samples\": 5, \"dv\": 0.1}"} +{"id": "5s-syn-fs-008", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input": "{\"f\": \"abs(x)\", \"x\": \"x\", \"x_min\": -1, \"x_max\": 1, \"n_samples\": 5, \"dv\": 0.1}"} +{"id": "5s-syn-fs-009", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input": "{\"f\": \"x\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 10, \"n_samples\": 11, \"dv\": 1}"} +{"id": "5s-syn-fs-010", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "function-sampled", "domain": "json_object", "pi_star_ref": "function-sampled@v1", "input": "{\"f\": \"sin(x)*cos(x)\", \"x\": \"x\", \"x_min\": 0, \"x_max\": 1, \"n_samples\": 11, \"dv\": 0.01}"} diff --git a/bench/fixtures/5s/syntax-linear-algebra-v1.jsonl b/bench/fixtures/5s/syntax-linear-algebra-v1.jsonl new file mode 100644 index 0000000..41251b1 --- /dev/null +++ b/bench/fixtures/5s/syntax-linear-algebra-v1.jsonl @@ -0,0 +1,11 @@ +{"_meta": {"battery": "5s", "sub_battery": "syntax", "version": "v1", "task_count": 10, "notes": "linear-algebra@v1 syntax — det/rref/inverse/eigenvalues."}} +{"id": "5s-syn-la-001", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input": "{\"op\": \"det\", \"matrix\": [[1, 2], [3, 4]]}"} +{"id": "5s-syn-la-002", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input": "{\"op\": \"det\", \"matrix\": [[1, 0, 0], [0, 1, 0], [0, 0, 1]]}"} +{"id": "5s-syn-la-003", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input": "{\"op\": \"rref\", \"matrix\": [[2, 4], [1, 2]]}"} +{"id": "5s-syn-la-004", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input": "{\"op\": \"rref\", \"matrix\": [[1, 2, 3], [0, 1, 1]]}"} +{"id": "5s-syn-la-005", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input": "{\"op\": \"inverse\", \"matrix\": [[2, 0], [0, 2]]}"} +{"id": "5s-syn-la-006", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input": "{\"op\": \"inverse\", \"matrix\": [[1, 1], [0, 1]]}"} +{"id": "5s-syn-la-007", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input": "{\"op\": \"eigenvalues\", \"matrix\": [[2, 0], [0, 3]]}"} +{"id": "5s-syn-la-008", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input": "{\"op\": \"eigenvalues\", \"matrix\": [[1, 1], [0, 1]]}"} +{"id": "5s-syn-la-009", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input": "{\"op\": \"det\", \"matrix\": [[\"1/2\", \"1/3\"], [\"1\", \"1\"]]}"} +{"id": "5s-syn-la-010", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "linear-algebra", "domain": "json_object", "pi_star_ref": "linear-algebra@v1", "input": "{\"op\": \"rref\", \"matrix\": [[1, 2], [2, 4], [3, 6]]}"} diff --git a/bench/fixtures/5s/syntax-tabular-v1.jsonl b/bench/fixtures/5s/syntax-tabular-v1.jsonl new file mode 100644 index 0000000..f49e540 --- /dev/null +++ b/bench/fixtures/5s/syntax-tabular-v1.jsonl @@ -0,0 +1,11 @@ +{"_meta": {"battery": "5s", "sub_battery": "syntax", "version": "v1", "task_count": 10, "notes": "tabular-pinned@v1 syntax — last reserved-stub π* graduates."}} +{"id": "5s-syn-tab-001", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input": "{\"schema\": [{\"name\": \"id\", \"type\": \"int\"}], \"key_columns\": [\"id\"], \"rows\": [[1], [2]]}"} +{"id": "5s-syn-tab-002", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input": "{\"schema\": [{\"name\": \"k\", \"type\": \"int\"}, {\"name\": \"v\", \"type\": \"str\"}], \"key_columns\": [\"k\"], \"rows\": [[1, \"a\"], [2, \"b\"]]}"} +{"id": "5s-syn-tab-003", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input": "{\"schema\": [{\"name\": \"r\", \"type\": \"rational\"}], \"key_columns\": [], \"rows\": [[0.5], [0.25]]}"} +{"id": "5s-syn-tab-004", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input": "{\"schema\": [{\"name\": \"f\", \"type\": \"bool\"}], \"key_columns\": [], \"rows\": [[true], [false]]}"} +{"id": "5s-syn-tab-005", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input": "{\"schema\": [{\"name\": \"id\", \"type\": \"int\"}], \"key_columns\": [\"id\"], \"rows\": []}"} +{"id": "5s-syn-tab-006", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input": "{\"schema\": [{\"name\": \"a\", \"type\": \"int\"}, {\"name\": \"b\", \"type\": \"int\"}], \"key_columns\": [\"a\", \"b\"], \"rows\": [[1, 2], [3, 4]]}"} +{"id": "5s-syn-tab-007", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input": "{\"schema\": [{\"name\": \"name\", \"type\": \"str\"}], \"key_columns\": [\"name\"], \"rows\": [[\"alice\"], [\"bob\"]]}"} +{"id": "5s-syn-tab-008", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input": "{\"schema\": [{\"name\": \"x\", \"type\": \"rational\"}], \"key_columns\": [\"x\"], \"rows\": [[\"1/3\"], [\"2/3\"]]}"} +{"id": "5s-syn-tab-009", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input": "{\"schema\": [{\"name\": \"flag\", \"type\": \"bool\"}, {\"name\": \"id\", \"type\": \"int\"}], \"key_columns\": [\"id\"], \"rows\": [[true, 1], [false, 2]]}"} +{"id": "5s-syn-tab-010", "battery": "5s", "sub_battery": "syntax", "version": "v1", "carrier": "tabular", "domain": "json_object", "pi_star_ref": "tabular-pinned@v1", "input": "{\"schema\": [{\"name\": \"id\", \"type\": \"int\"}], \"key_columns\": [\"id\"], \"rows\": [[100], [200], [300], [400]]}"} diff --git a/docs/_source/pi-star.rst b/docs/_source/pi-star.rst index 89901d0..b4d2137 100644 --- a/docs/_source/pi-star.rst +++ b/docs/_source/pi-star.rst @@ -11,20 +11,34 @@ the equivalence-class identity. Registry overview ----------------- -Lookup is by ``name@version`` key. Six concrete π*'s + one stub -ship today: +Lookup is by ``name@version`` key. **Fifteen** concrete π*'s ship +today; the registry has no remaining reserved stubs. -========================== =========== =========================================================== -Key Domain Status -========================== =========== =========================================================== -``wikitext-base@v1`` text Wikitext → plain prose (Phase 1a) -``claim-lattice@v1`` text Claim lines → JSON parsed-claim list (Phase 1a) -``code-py-ast@v1`` code Python source → canonical AST S-expression -``arithmetic@v1`` arithmetic Expression → exact rational ``num/den`` (SQD §14.1) -``logic-kernel@v1`` logic Boolean expression → canonical CNF (SQD §14.3) -``time-series-quantized@v1`` time-series JSON sample array → quantized integer vector -``tabular-pinned@v1`` tabular reserved (stub) -========================== =========== =========================================================== +================================ ================= ===================================================== +Key Domain Status +================================ ================= ===================================================== +``wikitext-base@v1`` text Wikitext → plain prose +``claim-lattice@v1`` text Claim lines → JSON parsed-claim list +``code-py-ast@v1`` code Python source → canonical AST S-expression +``arithmetic@v1`` arithmetic Expression → exact rational ``num/den`` (SQD §14.1) +``logic-kernel@v1`` logic Boolean expression → canonical CNF (SQD §14.3) +``time-series-quantized@v1`` time-series JSON sample array → quantized integer vector (SQD §13.5) +``tabular-pinned@v1`` tabular JSON-rows → pinned-schema canonical bytes +``algebra-symbolic@v1`` symbolic-algebra SymPy ``expand + srepr`` (#000030 Phase 1) +``algebra-symbolic-simplified@v1`` symbolic-algebra SymPy ``simplify + srepr`` — collapses trig identities +``calculus-derivative@v1`` calculus ``sp.diff`` re-canonicalized through algebra-symbolic +``calculus-integral@v1`` calculus ``sp.integrate`` + unevaluated-Integral sentinel +``calculus-limit@v1`` calculus ``sp.limit`` + ±∞/complex-infinity sentinels +``calculus-series@v1`` calculus Truncated Taylor / Maclaurin (drops ``O(x**n)``) +``linear-algebra@v1`` linear-algebra RREF / det / eigenvalues / inverse via ``{op, matrix}`` +``function-sampled@v1`` function-sampled SymPy expr → quantized integer vector (bridge to time-series) +================================ ================= ===================================================== + +The math π*'s (algebra-symbolic / calculus-* / linear-algebra / +function-sampled) gate on the optional ``[math]`` extra: +``pip install 'arborist[math]'`` installs SymPy. A fresh checkout +without the extra still passes the test suite — the modules +self-skip registration when ``import sympy`` fails. Cross-modality discipline ------------------------- diff --git a/tests/test_pi_star.py b/tests/test_pi_star.py index fbbd839..4bca833 100644 --- a/tests/test_pi_star.py +++ b/tests/test_pi_star.py @@ -124,19 +124,38 @@ def test_claim_lattice_empty_input_returns_empty_array(): # --- stubs --------------------------------------------------------- -@pytest.mark.parametrize( - "key", - [ - # code-py-ast@v1, logic-kernel@v1, arithmetic@v1, and - # time-series-quantized@v1 all graduated to real - # implementations. Only tabular-pinned@v1 remains a stub. - "tabular-pinned@v1", - ], -) -def test_stubs_raise_not_implemented(key): - pi_star = get(key) - with pytest.raises(NotImplementedError): - pi_star.canonicalize(b"anything") +def test_no_stub_pi_stars_remain(): + """Closure-criterion guard: every reserved-stub π* has graduated. + + History: + - 2026-05-07 (#000015): six concrete + four reserved stubs + (``code-py-ast``, ``logic-kernel``, ``time-series-quantized``, + ``tabular-pinned``). + - 2026-05-08: arithmetic / logic-kernel / code-py-ast / + time-series-quantized graduated. + - 2026-05-09 (#000030 + tabular phase): algebra-symbolic / + calculus-derivative / -integral / -limit / -series / + linear-algebra / function-sampled / tabular-pinned all real. + + Adding a new reserved stub re-opens this list; that's the + governance event this test pins.""" + from arborist.pi_star import REGISTRY + for key, pi_star in REGISTRY.items(): + # A stub raises NotImplementedError on any input; a real + # canonicalizer either succeeds or raises PiStarError on + # bad input. We probe with empty bytes — most real kernels + # raise PiStarError; none should raise NotImplementedError. + try: + pi_star.canonicalize(b"") + except NotImplementedError: + raise AssertionError( + f"π* {key!r} still raises NotImplementedError — " + f"reserved stub not yet graduated" + ) + except Exception: + # Any other exception (PiStarError, ValueError, etc.) + # means the kernel is real. + pass # --- time-series-quantized@v1 (graduated from stub) ------------------ diff --git a/tests/test_pi_star_phase_3_to_7.py b/tests/test_pi_star_phase_3_to_7.py new file mode 100644 index 0000000..5f2baf3 --- /dev/null +++ b/tests/test_pi_star_phase_3_to_7.py @@ -0,0 +1,272 @@ +"""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}' + ) diff --git a/tests/test_pi_star_tabular.py b/tests/test_pi_star_tabular.py new file mode 100644 index 0000000..6b4214e --- /dev/null +++ b/tests/test_pi_star_tabular.py @@ -0,0 +1,233 @@ +"""Tests for ``tabular-pinned@v1`` (the last reserved-stub π*). + +Closes the registry chapter — every modality the substrate paper +reserved (text, claim_lattice, code, arithmetic, logic, time-series, +tabular) is now real. Plus the math substrate extras (algebra-symbolic, +calculus-derivative/integral/limit/series, linear-algebra, +function-sampled). +""" + +from __future__ import annotations + +import pytest + +from arborist.pi_star import PiStarError, get + + +# ----- basic round-trips ------------------------------------------------- + + +def test_tabular_basic_int_table(): + ps = get("tabular-pinned@v1") + out = ps.canonicalize( + b'{"schema":[{"name":"id","type":"int"}],"key_columns":["id"],' + b'"rows":[[1],[2],[3]]}' + ) + assert out == b"schema=id:int\nkey=id\nn=3\nrows:\n1\n2\n3" + + +def test_tabular_empty_rows(): + """Empty body still emits header + n=0.""" + ps = get("tabular-pinned@v1") + out = ps.canonicalize( + b'{"schema":[{"name":"id","type":"int"}],"key_columns":["id"],' + b'"rows":[]}' + ) + assert out == b"schema=id:int\nkey=id\nn=0\nrows:\n" + + +def test_tabular_schema_only_no_key_columns(): + """key_columns=[] is valid: rows preserve input order.""" + ps = get("tabular-pinned@v1") + out = ps.canonicalize( + b'{"schema":[{"name":"x","type":"str"}],"key_columns":[],' + b'"rows":[["b"],["a"],["c"]]}' + ) + assert out == b"schema=x:str\nkey=\nn=3\nrows:\nb\na\nc" + + +# ----- equivalence-class collapse ---------------------------------------- + + +def test_tabular_row_order_collapses_under_key_sort(): + """Same data, different row order → same canonical (key-sorted).""" + ps = get("tabular-pinned@v1") + a = ps.canonicalize( + b'{"schema":[{"name":"id","type":"int"},{"name":"name","type":"str"}],' + b'"key_columns":["id"],"rows":[[2,"b"],[1,"a"],[3,"c"]]}' + ) + b = ps.canonicalize( + b'{"schema":[{"name":"id","type":"int"},{"name":"name","type":"str"}],' + b'"key_columns":["id"],"rows":[[1,"a"],[2,"b"],[3,"c"]]}' + ) + assert a == b + + +def test_tabular_int_float_string_fold_equivalence(): + """1 ≡ 1.0 ≡ "1.0" for int columns; same canonical.""" + ps = get("tabular-pinned@v1") + a = ps.canonicalize( + b'{"schema":[{"name":"v","type":"int"}],"key_columns":["v"],' + b'"rows":[[1]]}' + ) + b = ps.canonicalize( + b'{"schema":[{"name":"v","type":"int"}],"key_columns":["v"],' + b'"rows":[[1.0]]}' + ) + c = ps.canonicalize( + b'{"schema":[{"name":"v","type":"int"}],"key_columns":["v"],' + b'"rows":[["1.0"]]}' + ) + assert a == b == c + + +def test_tabular_rational_fold(): + """0.5 → 1/2; "1/2" → 1/2; same canonical.""" + ps = get("tabular-pinned@v1") + a = ps.canonicalize( + b'{"schema":[{"name":"r","type":"rational"}],"key_columns":[],' + b'"rows":[[0.5]]}' + ) + b = ps.canonicalize( + b'{"schema":[{"name":"r","type":"rational"}],"key_columns":[],' + b'"rows":[["1/2"]]}' + ) + assert a == b + assert b"1/2" in a + + +def test_tabular_bool_normalization(): + """true / True / "true" / 1 (when bool col) — fold to 'true'.""" + ps = get("tabular-pinned@v1") + a = ps.canonicalize( + b'{"schema":[{"name":"flag","type":"bool"}],"key_columns":[],' + b'"rows":[[true]]}' + ) + b = ps.canonicalize( + b'{"schema":[{"name":"flag","type":"bool"}],"key_columns":[],' + b'"rows":[["true"]]}' + ) + c = ps.canonicalize( + b'{"schema":[{"name":"flag","type":"bool"}],"key_columns":[],' + b'"rows":[["yes"]]}' + ) + assert a == b == c + assert b"true" in a + + +# ----- equivalence-class distinction ------------------------------------- + + +def test_tabular_different_schema_distinct(): + ps = get("tabular-pinned@v1") + a = ps.canonicalize( + b'{"schema":[{"name":"x","type":"int"}],"key_columns":[],' + b'"rows":[[1]]}' + ) + b = ps.canonicalize( + b'{"schema":[{"name":"y","type":"int"}],"key_columns":[],' + b'"rows":[[1]]}' + ) + assert a != b + + +def test_tabular_different_key_columns_distinct(): + """Same data, different declared key → different canonical (sort changes).""" + ps = get("tabular-pinned@v1") + a = ps.canonicalize( + b'{"schema":[{"name":"a","type":"int"},{"name":"b","type":"int"}],' + b'"key_columns":["a"],"rows":[[2,1],[1,2]]}' + ) + b = ps.canonicalize( + b'{"schema":[{"name":"a","type":"int"},{"name":"b","type":"int"}],' + b'"key_columns":["b"],"rows":[[2,1],[1,2]]}' + ) + assert a != b + + +def test_tabular_header_case_pinned(): + """Header case is part of identity; 'Name' ≠ 'name'.""" + ps = get("tabular-pinned@v1") + a = ps.canonicalize( + b'{"schema":[{"name":"Name","type":"str"}],"key_columns":[],' + b'"rows":[["x"]]}' + ) + b = ps.canonicalize( + b'{"schema":[{"name":"name","type":"str"}],"key_columns":[],' + b'"rows":[["x"]]}' + ) + assert a != b + + +# ----- error paths ------------------------------------------------------- + + +def test_tabular_missing_schema_field_raises(): + ps = get("tabular-pinned@v1") + with pytest.raises(PiStarError): + ps.canonicalize(b'{"key_columns":[],"rows":[]}') + + +def test_tabular_unknown_type_raises(): + ps = get("tabular-pinned@v1") + with pytest.raises(PiStarError): + ps.canonicalize( + b'{"schema":[{"name":"x","type":"datetime"}],' + b'"key_columns":[],"rows":[]}' + ) + + +def test_tabular_key_column_not_in_schema_raises(): + ps = get("tabular-pinned@v1") + with pytest.raises(PiStarError): + ps.canonicalize( + b'{"schema":[{"name":"a","type":"int"}],' + b'"key_columns":["b"],"rows":[[1]]}' + ) + + +def test_tabular_duplicate_column_names_raises(): + ps = get("tabular-pinned@v1") + with pytest.raises(PiStarError): + ps.canonicalize( + b'{"schema":[{"name":"a","type":"int"},{"name":"a","type":"str"}],' + b'"key_columns":[],"rows":[]}' + ) + + +def test_tabular_row_arity_mismatch_raises(): + ps = get("tabular-pinned@v1") + with pytest.raises(PiStarError): + ps.canonicalize( + b'{"schema":[{"name":"a","type":"int"},{"name":"b","type":"int"}],' + b'"key_columns":[],"rows":[[1]]}' + ) + + +def test_tabular_int_with_non_integer_float_raises(): + ps = get("tabular-pinned@v1") + with pytest.raises(PiStarError): + ps.canonicalize( + b'{"schema":[{"name":"v","type":"int"}],"key_columns":[],' + b'"rows":[[1.5]]}' + ) + + +def test_tabular_bool_subtype_of_int_rejected_in_int_column(): + """Python booleans are int-subtypes; we reject so True doesn't + silently become 1 in an int column.""" + ps = get("tabular-pinned@v1") + with pytest.raises(PiStarError): + ps.canonicalize( + b'{"schema":[{"name":"v","type":"int"}],"key_columns":[],' + b'"rows":[[true]]}' + ) + + +# ----- registry presence ------------------------------------------------- + + +def test_tabular_pinned_registered(): + """Sanity: the kernel is in the registry under the expected key.""" + from arborist.pi_star import list_keys + assert "tabular-pinned@v1" in list_keys()