diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e71d1c7e..cc958b89 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ | Path | Role | |------|------| -| `alkahest-core/` | Rust kernel (all math). Add new algorithms here. Published on [crates.io](https://crates.io/crates/alkahest-cas) as `alkahest-cas`. | +| `alkahest-core/` | Rust kernel (all math). Add new algorithms here. The Cargo **package** is `alkahest-cas` (published on [crates.io](https://crates.io/crates/alkahest-cas)); external code writes `alkahest_cas::…` and Cargo commands take `-p alkahest-cas`. `alkahest-py` renames the dependency to `alkahest_core` for its own use — that alias is workspace-local. | | `alkahest-mlir/` | MLIR dialect and lowering passes. Only touch for codegen work. | | `alkahest-py/` | PyO3 bindings (thin glue). Exposes Rust APIs to Python; add new bindings here when a Rust function needs a Python surface. | | `python/alkahest/` | Pure-Python layer. Use for Python-only utilities (parsing, pretty-printing, pytrees, context manager). | @@ -13,11 +13,33 @@ ## Stable vs experimental API -- **Rust stable surface:** `alkahest_core::stable` re-exports. Adding a function here triggers `cargo semver-checks` in CI — be intentional. +- **Rust stable surface:** `alkahest_cas::stable` re-exports. Adding a function here triggers `cargo semver-checks` in CI — be intentional. - **Python stable surface:** `alkahest.__all__` in `python/alkahest/__init__.py`. Same rule. -- Experimental / unstable APIs go under `alkahest_core::experimental` and `alkahest.experimental`. +- Experimental / unstable APIs go under `alkahest_cas::experimental` and `alkahest.experimental`. - `scripts/check_api_freeze.py` enforces this in CI. +## Resource model — read this before writing a long-running loop + +`ExprPool` is an **append-only** hash-consed arena (a `boxcar::Vec` of nodes plus a +`DashMap` index). There is no `clear`, no `truncate`, no refcount and no GC: **the only +way to reclaim interned nodes is to drop the whole pool.** Every `Expr`, `Matrix`, +`Series` and `DerivedResult` holds a *strong* reference to its pool, so retaining one +result retains every node ever interned alongside it. + +Consequences that matter at the architecture level: + +- Growth on a shared pool is **linear and unbounded** — roughly 200 bytes of resident + memory per interned node — while per-call time stays **flat**. The failure mode is a + clean OOM with no latency warning beforehand. +- `PyExprPool` exposes no `__len__`/`stats`, so the growth is not observable from Python. +- The supported pattern is therefore **one pool per problem**, dropped when the problem + is done. This is documented for users in + [`docs/mdbook/src/budgets.md`](docs/mdbook/src/budgets.md#exprpool-never-reclaims). + +Budget state is **thread-local** (`budget::STACK`); the cancellation flag is +**process-wide** (`budget::CANCELLED`). Anything surprising about budgets and threads +follows from that asymmetry. + ## Key files | Path | Purpose | @@ -35,21 +57,28 @@ alkahest/ ├── alkahest-core/ # Rust kernel (published as the alkahest-cas crate) │ ├── src/ -│ │ ├── kernel/ # hash-consed expression DAG, ExprPool +│ │ ├── kernel/ # hash-consed expression DAG, ExprPool (append-only: see note) │ │ ├── algebra/ # noncommutative Pauli / Clifford rules │ │ ├── parse.rs # Pratt expression parser (parse / ParseError) -│ │ ├── poly/ # UniPoly, MultiPoly, RationalFunction -│ │ ├── simplify/ # e-graph simplification (egglog) +│ │ ├── poly/ # UniPoly, MultiPoly, RationalFunction, real-root isolation +│ │ ├── simplify/ # rule engine + e-graph simplification (egglog) │ │ ├── diff/ # symbolic differentiation │ │ ├── integrate/ # symbolic integration -│ │ ├── calculus/ # series / limits +│ │ ├── calculus/ # series / limits / Euler–Maclaurin asymptotics │ │ ├── jit/ # LLVM JIT and interpreter │ │ ├── ball/ # Arb ball arithmetic +│ │ ├── validated/ # Taylor models, Moore–Skelboe: rigorous bounds over a box +│ │ ├── holonomic/ # creative telescoping (Zeilberger), P-recursive certificates +│ │ ├── budget/ # cooperative wall/step budget + process-wide cancel flag +│ │ ├── logic/ # Formula, SMT-LIB emitter, standalone DPLL +│ │ ├── matrix/ # linear algebra; three-valued zero test (E-LINALG-010/E-MAT-004) +│ │ ├── real/ # CAD real quantifier elimination, SOS/Positivstellensatz │ │ ├── ode/ # ODE analysis │ │ ├── dae/ # DAE analysis and index reduction │ │ ├── diffalg/ # Rosenfeld–Gröbner / differential elimination (groebner) │ │ ├── solver/ # polynomial solving: Gröbner triangular, regular chains, homotopy │ │ ├── lean/ # Lean 4 proof certificate export +│ │ ├── errors/ # the E-*-NNN code registry (codes.rs) — every code lives here │ │ ├── plot/ # SVG polyline + Graphviz DOT renderers (dependency-free) │ │ └── primitive/ # primitive registration system │ └── benches/ # criterion benchmarks @@ -60,6 +89,13 @@ alkahest/ │ ├── _transform.py # trace, grad, jit decorators │ ├── _pytree.py # JAX-style pytree flattening │ ├── _context.py # context manager and defaults +│ ├── _budget.py # Budget, request_cancel, run_with_wall_fallback +│ ├── _batch.py # batch_map / batch_map_iter / *_many (budget-propagating) +│ ├── ansatz.py # parametric families + fit/certify (public: alkahest.ansatz) +│ ├── crosscheck.py # differential testing against an external CAS oracle +│ ├── smt.py # SMT-LIB export and z3/cvc5 bridge +│ ├── research.py # claim graphs / session provenance +│ ├── _certificates.py # certificate coverage ledger, certifiable() │ └── experimental/ # unstable API surface │ └── _fastplotlib.py# GPU-accelerated plotting adapter ├── examples/ # runnable end-to-end examples diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a6ec6f..8cd27954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,109 @@ ## Unreleased +### Silent errors fixed — do results you already computed need rechecking? + +A *silent error* is a confident, plausible, mathematically wrong answer with no +exception, no `NaN` and no verification flag. Six were found and fixed this +release. **Four of them shipped in 3.7 or earlier**, so if you have results +from an affected call, re-run them. The other two were in code added during +this release cycle and never reached a published wheel. + +| Affected call | Wrong answer it gave | First shipped in | Recheck? | +|---|---|---|---| +| `decide(Forall(x, φ))` where the counterexample is a rational root whose denominator is **not a power of two** | `True` for a **false** universal theorem, e.g. `∀x. (3x+2)² > 0` (false at `x = −2/3`) | ≤ 3.7 | **Yes** — any `decide` verdict | +| `decide(Exists(x, φ))` with an `=` atom | `(True, witness)` where the witness does **not** satisfy the sentence, e.g. `∃x. 3x−2 = 0 → x = 1/2` | ≤ 3.7 | **Yes** — any cited witness | +| `Matrix.nullspace()` on a 2×2 with a symbolic determinant | A confident wrong kernel basis; `[[x,0],[0,1]]` returned `(0, x)`, for which `M·v ≠ 0` | 3.7 | **Yes** — verify `M·v = 0` numerically | +| `simplify` / `simplify_egraph` on a product containing `0⁻¹` | `1`, or `0`, depending on the engine — for an expression with no value at all. Reachable from `diff(2/(x − x), x)` | ≤ 3.7 | Yes, if any input could reduce to `0⁻¹` | +| `decide` on a two-variable sentence true only at an irrational point | `False` for a satisfiable `∃x∃y`, and `True` for its false `∀x∀y` dual | this cycle (2-var `decide` is new) | No published release affected | +| `batch_map(..., parallel=True)` under `context(budget=…)` | Ran **unbudgeted**, so candidates a sequential sweep reported as `E-BUDGET-001` came back as `E-INT-001` — a *mathematical* verdict | this cycle (batch APIs are new) | No published release affected | + +Also fixed, and not a silent error but worse for an unattended loop: a Rust +panic escaped `interval_eval` as `pyo3_runtime.PanicException`, which inherits +from `BaseException` and therefore slips past `except Exception`. Shipped in +3.7 — a loop that survived everything else died on `x^(3/2)` over a negative +ball. + +The deterministic silent-error gate (`tests/silent_errors/`, Tier-1 CI) now +scores **0 silent errors out of 166 scored cases** (126 correct, 40 honest +refusals) across evaluation, integration, limits, linear algebra, number +theory, real QE, series, simplification, solving, and sums/products. That is a +statement about the corpus, not a guarantee about the library. + +### Behaviour changes to plan for + +Fixing a silent error means some calls that used to return now refuse. Every one +of these is a call whose previous answer was not justified: + +- **`decide` raises `CadError` (`E-CAD-001`) where it used to answer**, whenever + the formula has a non-strict atom (`=`, `≠`, `≤`, `≥`) and a boundary root has + not been shown rational. This includes mixed-alternation sentences that route + through De Morgan — `∀x∃y. p > 0` becomes `¬∃x∀y. p ≤ 0`, and the negation + makes a strict body non-strict. `decide` is **not** a complete decision + procedure in this implementation; treat `E-CAD-001` as *undecided*, never as + *false*. +- **`rank`, `rref`, `nullspace`, `eigenvects`, `jordan_form` raise + `E-LINALG-010`, and `inverse` raises the new `E-MAT-004`**, when an entry's or + the determinant's vanishing can be decided neither way. Previously "could not + prove non-zero" was silently read as "zero". +- **`simplify` leaves `0 · 0⁻¹` unevaluated** instead of returning `1` (or `0`). + A result containing `(0 * 0^-1)` is Alkahest declining to give an + indeterminate form a value, not a simplifier failure. + +### Known limits — documented, not fixed + +These are properties of the design as it stands. They are called out here +because 3.8 is aimed at long unattended loops, and each of them is a way such a +loop fails. + +- **`ExprPool` never reclaims.** The arena is append-only: no `clear`, no + refcount, no GC, and the storage cannot shrink. The only way to free interned + nodes is to **drop the whole pool** — and every `Expr`, `Matrix`, `Series` and + `DerivedResult` holds a *strong* reference to its pool, so retaining one + interesting result retains everything. Growth on a shared pool is linear and + unbounded (~200 bytes/node; measured ~2 KB per `integrate` call over 20 000 + calls, 0 B/call with a fresh pool per iteration) while per-call latency stays + **flat**, so the failure mode is a clean OOM with no slowdown to warn you + first. `ExprPool` also exposes no `__len__` or `stats()`, so the growth is not + observable from Python. The supported pattern is **one pool per problem**, + documented in [`budgets.md`](docs/mdbook/src/budgets.md#exprpool-never-reclaims). +- **`run_with_wall_fallback` does not bound wall time for an uncooperative + callee.** It joins its worker before the exception propagates, so it returns + when the callee returns: `run_with_wall_fallback(time.sleep, 3.0, + budget=Budget(wall_ms=50))` raises `E-BUDGET-001` after 3000 ms, and the + message reports the real elapsed time so this shows up in a log rather than + being inferred later. Python cannot kill a thread, and abandoning one would + leak a live thread that still allocates into the pool and can only be stopped + through the process-wide cancel flag. Only an **OS-level bound** (subprocess, + process watchdog) is a hard deadline. +- **`wall_ms` granularity is one primitive operation, and FLINT calls cannot be + interrupted.** After the checkpoint work above the overshoot is a small + additive term (1.0–1.2×), but past a certain degree a single operation is a + FLINT factorisation or resultant — one foreign-function call, ~2 s on a + degree-62 integrand, which no cooperative mechanism can stop part-way. +- **`Matrix.eigenvals()` grows the pool on identical input** (~1.9 KB/call, + measured over 20 000 calls on the same 2×2 integer matrix): it interns a fresh + `__eigen_lambda_N` gensym per call. Every other Python-facing entry point + measured is flat on repeated input. Cache eigenvalue results. +- **`Matrix.eigenvals()` can emit casus-irreducibilis cube roots** — correct + under Alkahest's real cube-root convention (and honestly refused by + `eval_expr` with `E-EVAL-009`, with `interval_eval` returning an unbounded + ball) but evaluated on the **principal** branch by SymPy, NumPy and most other + tools, which return a confident number that is not an eigenvalue. 14 of 720 + random integer matrices produced one. An honest refusal here becomes somebody + else's silent error the moment the expression crosses the boundary, so + evaluate inside Alkahest before exporting, or export a verified numeric + enclosure instead. See [`interop.md`](docs/mdbook/src/interop.md). +- **The LLVM JIT leaks an LLVM `Context` per compile** (`Box::leak`, on the + error paths as well as the success path). Feature-gated behind `jit`, so + default PyPI wheels (Cranelift) are unaffected; do not compile in a loop under + a `+jit` / `+full` wheel. +- **No sanitizer covers any Python-facing path.** The PR-gating ASan job runs + with `detect_leaks=0`, the nightly LSan shard cannot reach a `cdylib` with no + `#[test]` functions, and `pytest` is never run under a sanitizer. The + behavioural substitute is the fresh-pool sweep described in + [`TESTING.md`](TESTING.md#3-memory-safety--sanitizers). + ### Fixed - **Claim graphs: a merge could close a dependency cycle, making the graph @@ -37,9 +140,73 @@ random points and re-draws its anchors on mismatch, so an unlucky anchor (Zippel's skeleton hypothesis is probabilistic) now produces a refusal rather than a confidently wrong polynomial. - ### Added +- **`alkahest.ansatz` — parametric families and coefficient fitting** (P2 + autoresearch item 1). "Guess the shape, let the CAS pin the constants" is the + most common move in experimental mathematics and everybody re-improvises the + plumbing for it. `ansatz.polynomial`, `.rational`, `.exponential_polynomial`, + `.linear_combination` and `.quadratic_form` build an `Ansatz` — an object + rather than a bare `Expr`, because a bare expression loses the distinction + between an *unknown coefficient* and an *independent variable*, and every + downstream step needs it. `ansatz.fit(A, residual)` solves for the + coefficients and returns an `AnsatzSolution` carrying `expr`, `assignment`, + `rank`, `free`, `residual`, `points` and a `status` — `fit` reports + `exactly_verified` only when the residual is symbolically zero, never on the + strength of the collocation points alone (`certify="residual" | "exact" | + "none"`). `enumerate_family` walks a coefficient grid for conjecture + generation; `certify_nonneg` hands a fitted candidate to `sos_decompose`. + Pure Python over primitives that are already fast in Rust (`Matrix.rref`, + `simplify`, `subs`), so it works without the `groebner` feature; a residual + genuinely nonlinear in the unknowns refuses with `E-ANSATZ-004` rather than + degrading silently, and *no member of this family fits* is `E-ANSATZ-003` — + a closed branch for that family, deliberately not phrased as a proof that no + such object exists. See + [`docs/mdbook/src/ansatz.md`](docs/mdbook/src/ansatz.md). + +- **`alkahest.crosscheck` — cross-CAS differential testing** (P2 autoresearch + item 2). A loop that only checks itself finds the bugs it already knows + about. `crosscheck.check(op, …)` runs one comparison against an external + oracle (SymPy today; `register_oracle` takes others) through a ladder of + four rungs — syntactic, symbolic, rigorous-numeric, invariant — and reports + `agree` / `diverge` / `incomparable` / `unavailable`. The rungs exist because + most apparent disagreements are not disagreements: two antiderivatives differ + by a constant, two simplifiers pick different normal forms. Only the + invariant rung (differentiate the antiderivative, substitute the solution + back, telescope the antidifference) settles those, and an operation that has + no invariant stops at rung 3 rather than pretending. **A missing oracle is + `unavailable`, never `agree`** (`E-XCHECK-002`) — the one failure mode that + would quietly turn the whole module into a no-op. `sweep(cases=…, seed=…)` + generates a seeded corpus and prints its seed in `summary()` always, because + a sweep is only useful as a bug report if the run that found something can be + reproduced; the seed defaults to `budget_seed()`, so a nightly job and a + local reproduction share one knob. `run_frozen_corpus()` replays 9 pinned + cases whose expected outcome is recorded with the reason. See + [`docs/mdbook/src/crosscheck.md`](docs/mdbook/src/crosscheck.md). + +- **`alkahest.smt` — SMT-LIB 2 export and a z3/cvc5 bridge** (P2 autoresearch + item 3). Discrete and mixed integer/real/boolean subproblems are not + Alkahest's problem class, and the fastest way to make it worse would be to + pretend otherwise. `to_smtlib` emits a complete runnable script (the emitter + lives in Rust next to `Formula`, with no `_ =>` arm anywhere in it, so a + kernel node added later fails to compile rather than silently emitting + plausible-but-wrong SMT-LIB); `smt.solve` runs an installed solver and reads + the answer back. The trust asymmetry is the design: a **`sat` model is lifted + to exact rationals and substituted back and checked in-process** + (`exactly_verified`; a model that fails raises `E-SMT-004`), while **`unsat` + is reported as `externally_asserted`** and is deliberately excluded from + `research.MACHINE_CHECKED_STATUSES`, because consuming an unsat proof is a + different project. Decimal literals are parsed from the *string*, so `0.1` + becomes `Fraction(1, 10)` and never the nearest binary double; an algebraic + witness (`root-obj`) is refused with `E-SMT-003` rather than evaluated to a + float, since a float witness recorded as an exact one is precisely the silent + error the bridge exists to prevent. `smt.supported(f)` answers "would this + route work, and should I take it" *before* any solver runs, and recommends + `prefer_in_tree` for real arithmetic with no integer variables — the in-tree + routes produce artifacts, `nlsat` produces only an answer. `solve` takes + quantifier-free formulas; `to_smtlib` exports quantified ones. See + [`docs/mdbook/src/smt.md`](docs/mdbook/src/smt.md). + - **Asymptotics of sums — Euler–Maclaurin** (P1 mathematics item 10): `alkahest.experimental.euler_maclaurin(f, k, a, n, corrections=…)` expands `Σ_{k=a}^{n} f(k)` as `n → ∞`, recovering @@ -187,10 +354,229 @@ ### Fixes +- **`decide` proved false universal theorems** (silent error; shipped in 3.7). + `∀x. (3x+2)² > 0` returned `(True, None)`. It is false at `x = −2/3`, exactly: + `9·(4/9) + 12·(−2/3) + 4 = 0`, and `0 > 0` is false. No approximation appears + anywhere in that argument, and `decide` is the engine behind every stability + proof and bound check, so a false `True` here is a machine-checked-looking + proof of a false theorem. Sweeping `∀x. (a·x − b)² > 0` over `a ∈ 1..9`, + `b ∈ −6..6` gave a clean rule: the verdict was wrong **exactly when the double + root `b/a` in lowest terms has a denominator that is not a power of two** — + which is why `x² > 0` and `(x−1)² > 0`, the two cases already in the corpus, + passed. The bug lived one denominator to the right of every existing test. + Two layers, and the deeper one was a broken documented contract: + `RootInterval` promises `lo == hi == r` for an exact rational root `r`, but + the VAS isolator only recorded an exact root when the transformed polynomial + vanished at a Möbius endpoint, which happens for dyadic roots and not in + general (`real_roots(3x − 2, x)` returned the open bracket `(0, 1)`). CAD then + built its sample set from rational bracket endpoints and midpoints and + concluded `false` when none satisfied the formula — sound for a *strict* atom, + whose solution set is open, but not for a non-strict one, whose solution set + can be the single untested root; `∀x. φ` goes through `¬∃x. ¬φ`, so the missed + witness became a `True` universal. Fixed exactly, not heuristically: by the + rational-root theorem every rational root of an integer polynomial has + denominator dividing the leading coefficient, so once a bracket is bisected + below width `1/lc` it contains at most one such rational and exact rational + evaluation settles it — `None` means "no rational root here", never "probably + not". (Bisection requires a strict sign change and refuses to collapse onto a + vanishing *endpoint*: neighbouring brackets share endpoints, and collapsing + onto one deletes the root the bracket was isolating.) Where the boundary root + is genuinely irrational the sample set is incomplete and nothing can fix that + by sampling, so `decide` now refuses with `E-CAD-001` rather than fabricating + a `false`. A randomised differential test against a `sympy.real_roots` + multiplicity analysis found **18 wrong verdicts in the first 150 random + polynomials** before the fix and **0 in 1 000** after. +- **`decide` returned existential witnesses that do not satisfy the sentence** + (silent error; shipped in 3.7). `∃x. 3x − 2 = 0` returned + `(True, {'x': '1/2'})`, and `3·(1/2) − 2 = −1/2 ≠ 0`. The verdict was right; + the certificate was false — and a witness is the one part of an answer that + looks like it needs no trust, so it is exactly the artefact a loop cites + downstream. The `Eq`-interval fallback proved satisfiability on an isolating + interval and then reported the interval **midpoint**. It now runs the same + check any caller would (`eval_qf_formula` at the reported point) and reports + `witness=None` rather than a point that fails. With the exact-rational-root + recovery above in place the true witness is usually reported outright: + `∃x. 3x − 2 = 0` → `(True, {'x': '2/3'})`, while `∃x. x² = 2` → `(True, None)` + because no rational witness exists. Two existing tests that asserted the bogus + witness are corrected with the reason spelled out. +- **A Rust panic escaped `interval_eval` as a `BaseException`** (shipped in 3.7). + `interval_eval(x**Rational(3,2), {x: ArbBall(-3.3, 0.0)})` panicked at + `ball/mod.rs` and surfaced as `pyo3_runtime.PanicException`, which inherits + from `BaseException` — so a loop's `except Exception` handler did not catch it + and the run died on an input it was supposed to survive. Not a silent error, + but for multi-day unattended operation arguably worse than one. `ArbBall::pow_f` + guarded a negative base with `!exp.is_exact()`, but `x^(3/2)` arrives as an + *exact* point ball at 1.5, `(−3.3)^1.5` is `NaN`, and the corner-ordering + `partial_cmp(...).unwrap()` then panicked; the same shape existed in + `ArbBall::Div` via `∞/∞`, reachable from `(x^(3/2))^-2`. A negative base now + requires an exact **integer** exponent, and both `pow_f` and `Div` check the + corner set for `NaN`, returning the existing "no enclosure" answers. 306 + panicking expressions in the first fuzz run; **0** after, across 7 200 + expressions × 14 points. +- **`run_with_wall_fallback` poisoned the whole process on timeout.** + `request_cancel()` sets a process-wide, sticky flag, and + `run_with_wall_fallback` never cleared it — so one expired candidate, the exact + event the API exists to handle, made every subsequent cooperative call in the + process fail with `E-BUDGET-003` forever. A multi-day loop would have died at + its first slow integral and then reported a cancellation storm that was really + one timeout. The executor is now wrapped in `try/finally` and the flag restored + *after* `ThreadPoolExecutor.__exit__` has joined the worker (so the cancelled + call has already observed it), and only when this call was the one that raised + it — an orchestrator with its own outstanding `request_cancel()` keeps its + request. Survives 20 of 20 timeout+work cycles. Two regression tests in + `tests/test_budget.py`. *(Introduced during this release cycle; no published + release is affected.)* +- **`batch_map(parallel=True)` ran completely unbudgeted.** `BudgetGuard` is + `!Send` and the budget frame stack is thread-local, so `context(budget=…)` had + no effect on work fanned out over a `ThreadPoolExecutor`: measured, the main + thread saw the budget and all four workers saw `False`. For unattended + operation that is the safety mechanism silently not applying — and worse than + simply "slower", because the candidates a sequential sweep reported as + `E-BUDGET-001` came back from a parallel one as `E-INT-001`, the integrator's + verdict that *no elementary antiderivative exists*. A loop records that as a + permanently closed branch when nothing was decided. `batch_map` now snapshots + the active budget on the calling thread and re-enters it inside every worker + task, and `run_with_wall_fallback` likewise enters its `budget` argument on the + worker thread it spawns. The semantics are documented rather than fudged: + `wall_ms` stays a single sweep-wide deadline (captured at the `batch_map` call, + since Python cannot read the frame's start instant), while `max_steps` becomes + **per item**, because the Rust step counter lives in the frame and is not + readable from Python. One item tripping its budget never cancels its siblings; + `request_cancel()` still reaches every worker, because the flag is process-wide. + *(Introduced during this release cycle; no published release is affected.)* +- **`simplify` gave `0 · 0⁻¹` a value** (silent error; shipped in 3.7). `0⁻¹` is division by + zero, so `0 · 0⁻¹` is the indeterminate form `0·∞` and has no value under any + convention — but `simplify` returned `1`, `simplify_egraph` returned `0`, and + `simplify(5 · 0⁻¹ · 0)` returned `0`, so the three answers were their own + proof that at least two of them were wrong. The rest of the library was + already right: `eval_expr(0⁻¹)` raises `E-EVAL-009` and `simplify(0⁻¹)` leaves + the power unevaluated. Four rules were each collapsing the surrounding + product on their own: `collect_mul_factors` summed the exponents of a common + base (`0¹ · 0⁻¹ → 0⁰ → 1`), which is `b^k·b^m = b^(k+m)` — an identity that + needs `b ≠ 0` the moment one exponent is negative; `const_fold` absorbed the + product to `0` because one factor was the literal zero; `collect_add_terms` + dropped a summand whose integer coefficient was `0` without checking that the + surviving factor was a *number*; and the e-graph's shrink ruleset contains + both `(Mul ?x (Num 0)) → (Num 0)` and `(Mul ?x (Pow ?x (Num -1))) → (Num 1)`, + so on this input it unioned `0` and `1` into one e-class. All four now decline. + Reachable without writing `0⁻¹` by hand: `diff(2/(x - x), x)` returned `1` for + a function whose domain is empty; it now returns an expression that + `eval_expr` refuses. Scope, stated plainly: the guards test for a **literal** + zero base, which — because the rule engine normalises strictly bottom-up — + also covers every base the simplifier can reduce to zero, `x - x` included. A + base that is zero but not provably so keeps the documented `b · b⁻¹ → 1` + convention: a three-valued `zero_status` on the `Mul` rewrite path costs + several 128-bit ball evaluations per node, which this path cannot afford. + `simplify_egraph` is the exception — it hands the whole call to the rule + engine when it finds a provably-zero denominator, and uses the full + `zero_status` to decide that, because building and saturating an egglog + program dwarfs the test. No measurable cost on + `bench_codspeed.py::test_log_exp_simplify_depth4` (paired A/B over 20 + interleaved runs: median −0.8%, inside the ±13% noise of the machine). + Nine cases added to the silent-error corpus, four of them controls that + `x · x⁻¹ → 1`, `0 · x → 0`, `2x − 2x → 0` and the e-graph engine itself still + work. +- **`decide` could deny a two-variable statement that is true only at an + irrational point** (silent error; two-variable `decide` is new in this cycle, + so no published release is affected). The univariate completeness guard shipped + earlier in this release refuses rather than report an unsatisfiability it + never checked at a boundary root; the two-variable path had the same guard but + keyed on `=` / `≠` atoms only, so `≤` and `≥` still fell through. + `∃x∃y. (x²−2)² + y² ≤ 0` — true at `(±√2, 0)`, where both squares vanish, and + false everywhere else — came back `False`, and its dual + `∀x∀y. (x²−2)² + y² > 0` came back `True`, a machine-checked-looking proof of + a false theorem. `project_and_sample_x` already flagged the untested + irrational projection root; the flag now escalates for every non-strict atom, + matching `body_has_boundary_atom` one dimension down. Strict atoms are + unaffected: their solution sets are open, so the open-cell midpoints are + complete for them. Both sentences now refuse with `E-CAD-001`. The cost is + more refusals in the mixed-alternation cases, which route through De Morgan + and so present a negated (hence non-strict) body: `∀x∃y. p > 0` becomes + `¬∃x∀y. p ≤ 0` and refuses where it used to answer. Five corpus cases and + four Rust unit tests, including the controls that a *rational* boundary point + is still found (`∃x∃y. (3x−2)² + y² ≤ 0` → `True` at `(2/3, 0)`) and that a + genuinely unsatisfiable `≤` still decides `False`. +- **`Matrix.nullspace()` returned a confident wrong basis for any 2×2 with a + symbolic determinant** (silent error; shipped in 3.7). The 2×2 fast path + returns the perpendicular of a non-vanishing row, which is the kernel *only* + when `det = 0`, and its full-rank gate recognised only a **literal** non-zero + constant. Every non-literal determinant fell through into the rank-1 answer — + "could not prove `det ≠ 0`" read as "`det = 0`", the exact mirror of the `rref` + defect that motivated the three-valued zero test. `[[x, 0], [0, 1]]` returned + the basis `(0, x)`, for which `M·v = (0, x) ≠ 0`, while `rank()` on the same + matrix said 2 — two public calls making 2 + 1 = 3 for a 2-column matrix. No + exotic function was needed to trigger it. The gate now uses the three-valued + `zero_status`: proven non-zero → trivial kernel, proven zero → the + perpendicular, undecidable → refuse with `E-LINALG-010`, matching what `rank` + already did. The eigen paths (`eigenvects`, `jordan_form`, `matrix_exp`) are + untouched: `det(A − λI) = 0` holds there *by construction* — λ is a root of the + characteristic polynomial — so the caller states it via the new + `KnownSingular` parameter rather than asking the simplifier to rediscover it + from nested radicals, which it often cannot. Four cases added to the + silent-error corpus, including a control that a genuinely rank-1 symbolic + matrix still returns its kernel and one that checks `M·v = 0` numerically + rather than just the dimension. +- **`nullspace`, `eigenvects` and `jordan_form` reported an undecidable entry + with a vague code.** All three share one elimination routine, whose error + type carried no payload (`Result<_, ()>`), so the specific refusal — + "one entry's vanishing could be proven neither way, substitute concrete + parameters and it works" (`E-LINALG-010`) — died at that boundary and came + back as the generic `E-LINALG-002` / `E-EIGEN-006` "could not compute + nullspace basis". The routine is `pub(crate)`, so widening its error type + costs nothing on the public API (`cargo semver-checks` agrees). A *genuine* + kernel failure still reports `E-LINALG-002` and can never inherit a previous + refusal's code — `KernelFailed` is deliberately not an out-of-band carrier. +- **`Budget(wall_ms=…)` overshot `integrate` by an unbounded factor.** The + checkpoints existed; the seconds were being spent between them. A 300 ms + budget on `∫ cos x·sinⁿx/(sin⁹x + sin x + 1) dx` returned after 2–4 s, and the + same family at degree 40 never returned at all — it had to be killed from + outside the process. Measured rather than guessed: 98.7% of one such call was + a single number-field Euclidean GCD (`alg_log_argument` → `kpoly_gcd`), and + the residue after fixing that was a single ℚ[x] GCD normalising `A/D` to + lowest terms (480 ms of a 482 ms call). Both Euclidean loops now check the + budget per step, `integrate_raw` checks on entry (so a *sum* is bounded + between summands), and the rational route checks at each stage boundary. + The same ladder now overshoots by 1.0–1.2×, and the degree-40 case returns in + 317 ms. Because a GCD has no error channel and stopping one early returns a + *wrong* GCD, the budgeted variants return `None` rather than a truncated + answer, and the public `poly_gcd` / `NumberField::kpoly_gcd` signatures are + unchanged. What remains is documented in `docs/mdbook/src/budgets.md`: the + granularity is one primitive polynomial operation, and past a certain degree + that is a FLINT call, which no cooperative mechanism can interrupt. +- **`request_cancel()` could not reach a running `integrate` or `limit`.** + Two independent causes. The bindings held the GIL for the whole call, so a + watchdog thread could not execute a single bytecode until the operation it + wanted to cancel had already finished — only a flag set *before* the call was + ever observed, which is the opposite of what a fan-out search loop needs. + Both now release the GIL around the core call, using the idiom `simplify_par` + already established. And `integrate`'s u-substitution search discarded every + error from its recursive call, budget trips included, so it moved on to the + next of up to twelve candidates instead of stopping; a budget error now + propagates, and the search checks the budget once per candidate — the + granularity where the seconds actually go. - **Docs: `simplify_par` was documented with a signature it never had.** Both the Sphinx API page and the mdbook chapter showed it taking a list of expressions and returning a list; it takes one expression and returns one `DerivedResult`, and the documented call raises `TypeError`. +- **Docs: the documented local Valgrind command checked nothing.** `TESTING.md` + globbed `target/.../deps/alkahest_core-*` and `CONTRIBUTING.md` /`TESTING.md` + said `cargo test -p alkahest-core`, but the package is named **`alkahest-cas`** + (`alkahest-core/` is only the directory). The glob matched zero binaries, the + `[ -x "$bin" ] || continue` guard skipped the empty expansion, and the loop + **exited 0 having run Valgrind on nothing**. Both are corrected, with a note + on the naming so it does not come back. Also corrected in the same pass: + `TESTING.md` claimed UndefinedBehaviorSanitizer coverage + (`-Zsanitizer=undefined` appears nowhere in the repo), and `CONTRIBUTING.md` + claimed Tier-1 CI runs "ASan on FFI tests" when that job is scoped to the crate + *below* the FFI boundary and runs with `detect_leaks=0`. +- **Docs: `Matrix.inv()` and `M[i, j]` do not exist.** The Sphinx matrix page + documented both; the methods are `inverse()` and `get(i, j)`, and `Matrix` is + not subscriptable. The same page attributed the singular-matrix refusal to + `E-MAT-001` (shape mismatch) rather than `E-MAT-003`. +- **Docs: the Rust crate path was wrong throughout.** Guide pages wrote + `alkahest_core::…`, which is the *workspace-local alias* `alkahest-py` gives + the dependency. A downstream crate writes `alkahest_cas::…`; corrected in the + mdBook chapters, `ARCHITECTURE.md` and `CONTRIBUTING.md`. - **Withhold Lean certificates for Basel-family infinite sums.** The `basel_zeta_even` derivation step had no Mathlib proof and fell through to the default `by ring_nf; simp` tactic, emitting false equalities (e.g. @@ -222,7 +608,7 @@ converted once via `np.ascontiguousarray(..., dtype=np.float64)`, never via `.tolist()`. -### Additions +### Additions — earlier in this cycle - **`decide` now handles two real variables with a quantifier prefix of length ≤ 2** (`alkahest_core::real::cad`), not just the single-variable @@ -294,7 +680,7 @@ `y · cos x`) and any addend outside the certifiable base fragment still withhold the *entire* certificate, never a partial one. -### Fixes +### Fixes — earlier in this cycle - **`alkahest.SumError` now actually catches native summation errors.** `sum_definite` / `sum_indefinite` raise the native `E-SUM-*` exception, but diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8bafa57e..efd56595 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,8 +28,16 @@ cargo test --all # Python tests (slow sparse_interp roadmap excluded by default; see pytest.ini) pytest -# With sanitizers (catches FFI memory bugs) -RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --target x86_64-unknown-linux-gnu +# The silent-error gate (its own Tier-1 CI step; a confident wrong answer fails here) +pytest tests/silent_errors/ + +# With sanitizers — same invocation CI uses. `-p alkahest-cas` (the package name; +# `alkahest-core` is only the directory) and `-Z build-std` are both required: without +# build-std the doc-test/dep binaries link without the ASan runtime and you get +# "undefined symbol: __asan_init". +RUSTFLAGS="-Zsanitizer=address" \ + cargo +nightly test -p alkahest-cas --lib --tests \ + --target x86_64-unknown-linux-gnu -Z build-std ``` See [`TESTING.md`](TESTING.md) for the full testing strategy (fuzzing, oracle cross-validation, CI tiers). @@ -75,14 +83,21 @@ Rules live in `alkahest-core/src/simplify/`. Each rule is a `RewriteRule` with a ## Pull requests - Keep PRs focused on one item from `ROADMAP.md` or one issue. -- Tier-1 CI (< 10 min) must be green before review: unit tests, lightweight proptest/hypothesis, clippy, ruff, ASan on FFI tests. +- Tier-1 CI (< 10 min) must be green before review: unit tests, lightweight proptest/hypothesis, clippy, ruff, the silent-error gate (`pytest tests/silent_errors/`), and ASan scoped to the `alkahest-cas` package. Note what that last one is *not*: it runs `cargo +nightly test -p alkahest-cas`, i.e. the crate **below** the FFI boundary, with `LSAN_OPTIONS=detect_leaks=0`. No sanitizer runs `pytest`, so a leak or UAF that only appears through PyO3 is not caught by CI — see [`TESTING.md` §3](TESTING.md#3-memory-safety--sanitizers). - Semver is enforced automatically — `cargo semver-checks` runs on every PR and will fail if a stable API breaks. -- New stable API additions go into `alkahest_core::stable` and `alkahest.__all__`; experimental additions go into `alkahest_core::experimental` and `alkahest.experimental`. +- New stable API additions go into `alkahest_cas::stable` and `alkahest.__all__`; experimental additions go into `alkahest_cas::experimental` and `alkahest.experimental`. - Add `[skip ci]` at the end of commit messages if changes cannot possibly effect CI. ## Rust vs Python -### Rust (`alkahest-core`) gets the code when... +> **Naming.** The directory is `alkahest-core/`; the Cargo **package** it declares is +> `alkahest-cas`, and the Rust path an external crate uses is `alkahest_cas::`. Inside +> this workspace `alkahest-py` renames the dependency (`package = "alkahest-cas"`), which +> is why its sources say `alkahest_core::` — that alias is local to `alkahest-py` and is +> not what a downstream user writes. Cargo commands take the *package* name: +> `cargo test -p alkahest-cas`, never `-p alkahest-core`. + +### Rust (`alkahest-cas`, in `alkahest-core/`) gets the code when... 1. It is a mathematical operation, data structure, or invariant that any front-end should see identically — e.g. polynomial normalisation, differentiation, matrix inversion, Gröbner basis. 2. It is on a hot path. Anything that iterates over `ExprId`s, touches coefficient rings, or performs codegen must be Rust. diff --git a/README.md b/README.md index 62150be7..783b2a1a 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Probe your environment after install: `alkahest.capabilities()["features"]` and ### Opt-in Linux wheels: `+jit` and `+full` (PyTorch-style) -**Why a separate index or direct wheel URL:** feature-heavy wheels use a PEP 440 **local version** (for example `2.0.3+jit` or `2.0.3+full`). Those builds **must not** be mixed into the main PyPI project’s simple API for the same reason PyTorch publishes CUDA wheels on `download.pytorch.org`: otherwise `pip install alkahest` could resolve a `+jit` / `+full` build as “newer” than `2.0.3` and pull LLVM (or a much larger binary) when you wanted the default wheel. +**Why a separate index or direct wheel URL:** feature-heavy wheels use a PEP 440 **local version** (for example `3.7.0+jit` or `3.7.0+full`). Those builds **must not** be mixed into the main PyPI project’s simple API for the same reason PyTorch publishes CUDA wheels on `download.pytorch.org`: otherwise `pip install alkahest` could resolve a `+jit` / `+full` build as “newer” than `3.7.0` and pull LLVM (or a much larger binary) when you wanted the default wheel. There is **no** `pip install alkahest[jit]` / `alkahest[full]` that swaps the native extension: **pip extras only add Python dependencies**, not alternate binaries for the same wheel slot. @@ -69,13 +69,13 @@ There is **no** `pip install alkahest[jit]` / `alkahest[full]` that swaps the na Direct-install examples (adjust tag and filename after checking the release assets): ```bash -pip install "https://github.com/alkahest-cas/alkahest/releases/download/v2.3.1/alkahest-2.3.1+full-cp311-cp311-linux_x86_64.whl" -pip install "https://github.com/alkahest-cas/alkahest/releases/download/v2.3.1/alkahest-2.3.1+jit-cp311-cp311-linux_x86_64.whl" +pip install "https://github.com/alkahest-cas/alkahest/releases/download/v3.7.0/alkahest-3.7.0+full-cp311-cp311-linux_x86_64.whl" +pip install "https://github.com/alkahest-cas/alkahest/releases/download/v3.7.0/alkahest-3.7.0+jit-cp311-cp311-linux_x86_64.whl" ``` These wheels vendor LLVM (for JIT) and related `.so` files under `site-packages/alkahest.libs/`. If `import alkahest` fails with a missing `libffi-*.so` or `libLLVM-*.so`, prepend that directory to `LD_LIBRARY_PATH` (or install matching system packages). Release CI uses the same `LD_LIBRARY_PATH` step when smoke-testing wheels. -If your client chokes on `+` in the URL, use percent-encoding (`2.3.1%2Bfull` in the filename segment). +If your client chokes on `+` in the URL, use percent-encoding (`3.7.0%2Bfull` in the filename segment). After installing the **default** wheel, `alkahest.jit_is_available()` is `True` (Cranelift). After **`+jit`** or **`+full`**, it is also `True` (LLVM). Gröbner-backed APIs such as `alkahest.solve` are available in **all** wheels since `groebner` became a default feature. @@ -84,7 +84,7 @@ After installing the **default** wheel, `alkahest.jit_is_available()` is `True` **Target layout (roadmap):** a small **extra index** URL (PEP 503) hosting only `+jit` / `+full` wheels, mirroring PyTorch’s `--extra-index-url` workflow: ```bash -pip install 'alkahest==2.0.3+full' --extra-index-url https://EXAMPLE/alkahest-extras/simple +pip install 'alkahest==3.7.0+full' --extra-index-url https://EXAMPLE/alkahest-extras/simple ``` ### From source @@ -122,10 +122,10 @@ Optional Cargo features: `parallel` (sharded pool + parallel F4 + `numpy_eval_pa ```toml [dependencies] -alkahest-cas = "2" +alkahest-cas = "3" # groebner is included by default; add other optional features as needed: -# alkahest-cas = { version = "2", features = ["parallel", "egraph"] } +# alkahest-cas = { version = "3", features = ["parallel", "egraph"] } ``` **System prerequisites** (same libraries as the Python build — must be present before `cargo build`): @@ -255,11 +255,86 @@ Exceptions: `limit` returns a bare `Expr`, and `series` returns a `Series` (with | Fan out without aborting | `batch_map` / `integrate_many` / `simplify_many` / `diff_many` | | Compact logs | `DerivedResult.to_dict(mode="compact")` | | Session provenance | `alkahest.research` claim graphs | +| Propose and fit a parametric family | `alkahest.ansatz` | +| Differential-test against another CAS | `alkahest.crosscheck` | +| Hand off a discrete / mixed int-real subproblem | `alkahest.smt` | Docs: [Autoresearch / agent loops](https://alkahest-cas.github.io/alkahest/search-plumbing.html). --- +## Modules for autoresearch loops + +Three submodules new in 3.8, aimed at unattended search. Each has its own chapter in the +[documentation site](https://alkahest-cas.github.io/alkahest/). + +| Module | What it does | +|---|---| +| **`alkahest.ansatz`** | Parametric families with named unknown coefficients — `polynomial`, `rational`, `exponential_polynomial`, `linear_combination`, `quadratic_form` — plus `fit` (solve for the coefficients from a residual, with a verification status), `enumerate_family`, and `certify_nonneg`. This is the "guess the shape, let the CAS pin the constants" loop, done once instead of re-improvised per problem. | +| **`alkahest.crosscheck`** | Differential testing against an external CAS. `check(op, …)` runs one comparison through a ladder of increasingly semantic rungs (syntactic → normalised → numeric → invariant) and reports `agree` / `diverge` / `incomparable` / `unavailable`; `sweep()` generates a seeded corpus of them; `run_frozen_corpus()` replays the pinned cases. A missing oracle is reported as `unavailable`, never as agreement. | +| **`alkahest.smt`** | SMT-LIB 2 export (`to_smtlib`) and a bridge to z3 / cvc5 (`solve`, `supported`, `solvers`). A `sat` model is lifted to exact rationals and **substituted back and checked in-process**; an `unsat` is reported as `externally_asserted` and is deliberately not counted as machine-checked. Algebraic-number witnesses are refused (`E-SMT-003`) rather than truncated to floats. | + +```python +import alkahest as ak + +pool = ak.ExprPool() +x = pool.symbol("x") + +# Fit an ansatz +from alkahest.ansatz import polynomial, fit +A = polynomial(pool, [x], degree=2) +sol = fit(A, A.expr - (x**2 - pool.integer(3) * x + pool.integer(2))) +print(sol.expr, sol.status) # (2 + x^2 + (x * -3)) exactly_verified + +# Cross-check a result against SymPy +print(ak.crosscheck.check("integrate", x**2, x).outcome) # 'agree' + +# Ask whether the SMT route applies before paying for it +print(ak.smt.supported(pool.gt(x, pool.integer(0))).recommendation) # 'prefer_in_tree' +``` + +--- + +## Known limits + +Alkahest is meant to be run unattended, so the limits are documented as prominently as +the features. These are properties of the design, not open bugs — write the loop around +them. + +- **`ExprPool` never reclaims.** The expression arena is append-only: no `clear`, no + refcount, no GC. The only way to free interned nodes is to **drop the whole pool**, and + every `Expr` / `Matrix` / `DerivedResult` holds a strong reference to its pool, so + keeping one result keeps everything. Growth is roughly 200 bytes per node and linear + forever (~2–3.5 KB per `integrate` call) while per-call **latency stays flat** — so a + long-running loop on one pool dies by OOM with no slowdown to warn you first. Use **one + pool per problem** and carry `to_dict()` envelopes, not live `Expr` handles. + [Details](https://alkahest-cas.github.io/alkahest/budgets.html#exprpool-never-reclaims). +- **`wall_ms` is cooperative and its granularity is one primitive operation.** A call + stops at the first checkpoint after the deadline. Past a certain degree that operation + is a FLINT call, which no cooperative mechanism can interrupt — a 300 ms budget on a + degree-62 integrand returns after ~2 s. Only an OS-level timeout goes below that. +- **`run_with_wall_fallback` does not bound wall time for an uncooperative callee.** It + joins its worker before raising, so it returns when the callee returns: + `run_with_wall_fallback(time.sleep, 3.0, budget=Budget(wall_ms=50))` raises after + 3000 ms. It exists to turn a silent truncation into a coded error, not to contain an + unknown callee. Only `integrate` and `limit` currently honour the cooperative budget and + release the GIL, so only they can be cancelled while already running. +- **`decide` refuses rather than answering** in cases it cannot establish. It covers + polynomial bodies in ≤ 2 real variables with a ≤ 2-quantifier prefix, and inside that + fragment it raises `E-CAD-001` when the only candidate solutions sit at an irrational + boundary point that rational sampling cannot test. Same for linear algebra: an entry or + determinant whose vanishing is undecidable gives `E-LINALG-010` / `E-MAT-004` instead of + a guessed branch. **A refusal means undecided, not false** — a search loop that records + it as a negative result closes a branch it never explored. +- **`Matrix.eigenvals()` can emit casus-irreducibilis cube roots.** These are correct + under Alkahest's real cube-root convention — `eval_expr` refuses them honestly and + `interval_eval` returns an unbounded ball — but a principal-branch evaluator (SymPy, + NumPy) returns a confident number that is *not* an eigenvalue. Evaluate inside Alkahest + before exporting a radical expression to another tool. + [Details](https://alkahest-cas.github.io/alkahest/interop.html#the-interop-trap-casus-irreducibilis-cube-roots). + +--- + ## Reinforcement learning `alkahest.rl` exposes **verifiable RL environments** backed by the CAS. The core layer diff --git a/TESTING.md b/TESTING.md index 5ffd25f0..8085c771 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,11 +70,22 @@ CI caps each fuzz job at **2 hours** (`timeout 7200`); locally you can stop anyt Because this project relies heavily on C libraries (GMP, FLINT) and exposes pointers to Python via PyO3, securing the Foreign Function Interface (FFI) is critical. -### Sanitizers (ASan, LSan, UBSan) +### Sanitizers (ASan, LSan, TSan) We compile our Rust test suite using LLVM sanitizers to catch memory violations instantly. * **AddressSanitizer (ASan)**: Catches Out-of-Bounds accesses and Use-After-Free errors (especially critical when Python drops an object that Rust/C still expects). * **LeakSanitizer (LSan)**: Ensures FLINT/GMP memory allocations are properly dropped. -* **UndefinedBehaviorSanitizer (UBSan)**: Catches unaligned pointers and integer overflows. +* **ThreadSanitizer (TSan)**: Catches data races across the Rayon/`parallel` paths. + +> **UndefinedBehaviorSanitizer is *not* run.** `-Zsanitizer=undefined` appears nowhere +> in this repository or in `.github/workflows/ci.yml`. If you want UB coverage you have +> to add it yourself; do not assume it is already gating anything. + +> **The package is named `alkahest-cas`, not `alkahest-core`.** `alkahest-core/` is the +> *directory*; `-p alkahest-core` matches no package and the test binaries are named +> `alkahest_cas-*`. A `for bin in …/alkahest_core-*` loop expands to a literal that does +> not exist, the `[ -x "$bin" ] || continue` guard skips it, and the command **exits 0 +> having checked nothing** — which is how the wrong name survived here for as long as it +> did. *Running with Sanitizers (requires Rust **nightly** + `rust-src`):* ```bash @@ -82,16 +93,25 @@ rustup toolchain install nightly rustup component add rust-src --toolchain nightly ``` -**AddressSanitizer** — Tier 1 CI scopes this to `alkahest-core` only (full workspace + `build-std` is slow and easy to hit runner limits); locally you can match CI or widen: +**AddressSanitizer** — Tier 1 CI scopes this to the `alkahest-cas` package only (full workspace + `build-std` is slow and easy to hit runner limits); locally you can match CI or widen: ```bash RUSTFLAGS="-Zsanitizer=address" \ - cargo +nightly test -p alkahest-core --lib --tests \ + cargo +nightly test -p alkahest-cas --lib --tests \ --target x86_64-unknown-linux-gnu \ -Z build-std # Optional: suppress known GMP/FLINT leak noise while debugging other issues: # LSAN_OPTIONS=detect_leaks=0 ``` +**Known gap — no sanitizer sees a Python-facing path.** The PR-gating ASan job sets +`LSAN_OPTIONS: detect_leaks=0`, so it is not a leak check. The nightly LSan shard is +`--workspace`, but `alkahest-py` is a `cdylib` with zero `#[test]` functions, so no +CPython interpreter is ever started under it, and the Valgrind shard globs Rust test +binaries only. **`pytest` is never run under any sanitizer.** Until that changes, the +substitute check for the Python surface is a behavioural one: run N iterations of an +entry point on a *fresh* `ExprPool` per iteration and assert resident memory stays flat +(see [Budgets → pool lifetime](docs/mdbook/src/budgets.md#exprpool-never-reclaims)). + **ThreadSanitizer** — nightly `tsan` shard: ```bash RUSTFLAGS="-Zsanitizer=thread" \ @@ -121,7 +141,9 @@ sudo apt-get install -y valgrind # or your OS equivalent export RUSTFLAGS="-C debuginfo=2 -Z dwarf-version=4" cargo +nightly build --workspace --target x86_64-unknown-linux-gnu -Z build-std -for bin in target/x86_64-unknown-linux-gnu/debug/deps/alkahest_core-*; do +# The crate's test binaries are `alkahest_cas-*` (package `alkahest-cas`). +# `alkahest_core-*` matches nothing and the guard below turns that into a silent pass. +for bin in target/x86_64-unknown-linux-gnu/debug/deps/alkahest_cas-*; do [ -x "$bin" ] || continue valgrind --leak-check=full --error-exitcode=1 \ --suppressions=valgrind.supp "$bin" @@ -147,7 +169,7 @@ Given the computational expense of fuzzing and PBT, our GitHub Actions / CI pipe ### Tier 1: Push / PR Checks (fast path) * **Triggers**: Push or PR to `main` (not the scheduled cron). -* **Typical contents**: `cargo fmt`, `clippy`, `cargo test --workspace`, `ruff`, `pytest` (defaults in `pytest.ini` exclude `@pytest.mark.slow`), ASan on `alkahest-core`, CodSpeed micro-benchmarks (`.github/workflows/codspeed.yml`), etc. (see `.github/workflows/ci.yml`). +* **Typical contents**: `cargo fmt`, `clippy`, `cargo test --workspace`, `ruff`, `pytest` (defaults in `pytest.ini` exclude `@pytest.mark.slow`), ASan on the `alkahest-cas` package (**below** the FFI boundary — the PyO3 layer is not instrumented, and `detect_leaks` is off), the deterministic silent-error gate (`tests/silent_errors/`), CodSpeed micro-benchmarks (`.github/workflows/codspeed.yml`), etc. (see `.github/workflows/ci.yml`). ### Tier 1b: Slow Python (sparse interpolation roadmap) * **Triggers**: Same nightly **schedule** as Tier 2 (not on every push — keeps default CI fast). diff --git a/alkahest-core/src/ball/mod.rs b/alkahest-core/src/ball/mod.rs index 2b8fb2e5..1aee6dc3 100644 --- a/alkahest-core/src/ball/mod.rs +++ b/alkahest-core/src/ball/mod.rs @@ -283,14 +283,21 @@ impl std::ops::Div for ArbBall { Float::with_val(prec, self.hi() / lo_rhs.clone()), Float::with_val(prec, self.hi() / hi_rhs.clone()), ]; + // `∞/∞` is NaN, so an unbounded operand makes the corner ordering + // partial and `partial_cmp(...).unwrap()` panics. `None` is the + // interface's existing "no enclosure" answer; a panic here crosses the + // FFI boundary as a `BaseException` that `except Exception` misses. + if corners.iter().any(|c| c.is_nan()) { + return None; + } let min = corners .iter() - .min_by(|a, b| a.partial_cmp(b).unwrap()) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .unwrap() .clone(); let max = corners .iter() - .max_by(|a, b| a.partial_cmp(b).unwrap()) + .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .unwrap() .clone(); let sum = Float::with_val(prec, &min + &max); @@ -336,7 +343,13 @@ impl ArbBall { let prec = self.prec; let lo = self.lo(); let hi = self.hi(); - if lo < 0 && !exp.is_exact() { + // A negative base only has a real power for an *integer* exponent. + // `is_exact` alone is not that test: `x^(3/2)` arrives here as an exact + // point ball at 1.5, `(-3.3)^1.5` is NaN, and the corner comparison + // below then unwrapped a `None` from `partial_cmp` and panicked — a + // Rust panic crossing the FFI boundary, which is a `BaseException` an + // `except Exception` handler does not catch. + if lo < 0 && !(exp.is_exact() && exp.lo().is_integer()) { return ArbBall::infinity(prec); // complex result possible } // Conservative bound via corner evaluation @@ -346,14 +359,20 @@ impl ArbBall { Float::with_val(prec, hi.clone().pow(exp.lo())), Float::with_val(prec, hi.clone().pow(exp.hi())), ]; + // Defence in depth: any remaining NaN corner (an overflow, or a base + // interval straddling zero with a negative exponent) makes the ordering + // partial, and `partial_cmp(...).unwrap()` would panic on it. + if corners.iter().any(|c| c.is_nan()) { + return ArbBall::infinity(prec); + } let min = corners .iter() - .min_by(|a, b| a.partial_cmp(b).unwrap()) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .unwrap() .clone(); let max = corners .iter() - .max_by(|a, b| a.partial_cmp(b).unwrap()) + .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .unwrap() .clone(); let sum = Float::with_val(prec, &min + &max); diff --git a/alkahest-core/src/errors/codes.rs b/alkahest-core/src/errors/codes.rs index 2b04f20e..df4cec8e 100644 --- a/alkahest-core/src/errors/codes.rs +++ b/alkahest-core/src/errors/codes.rs @@ -75,6 +75,8 @@ pub const REGISTRY: &[ErrorSpec] = &[ // elimination can reach an entry it can neither prove zero nor prove non-zero. // Refusing is the point: treating "unknown" as "non-zero" is what produced a // confident wrong rank (and a false inconsistency signature) before this code existed. + // Raised through `EigenError` too (`eigenvects`), which shares the elimination it + // refuses in; the class below names where the code is defined, not every route. ErrorSpec { code: "E-LINALG-010", class: "LinearAlgebraError", cause: Cause::Unsupported, remediation: Some("rewrite the entry into a form whose vanishing is decidable, or substitute concrete values for the parameters") }, // E-ODE — OdeError ErrorSpec { code: "E-ODE-001", class: "OdeError", cause: Cause::UserInput, remediation: Some("number of state variables must equal number of RHS expressions") }, @@ -117,8 +119,14 @@ pub const REGISTRY: &[ErrorSpec] = &[ code: "E-CAD-001", class: "CadError", cause: Cause::Unsupported, + // Two distinct causes now share this code, and the remediation has to + // cover both: outside the supported fragment (≤ 2 variables, ≤ 2 + // quantifiers, polynomial atoms), or inside it but undecidable by the + // sample points available — a non-strict atom whose only solutions sit + // at an irrational boundary. The second is a *refusal to guess*: before + // 3.8 that case silently answered, which produced false universals. remediation: Some( - "use a purely polynomial constraint in one real variable without nested quantifiers; multivariate QE is incremental", + "keep to polynomial atoms in at most two real variables with at most two quantifiers; if the sentence is already in that fragment, its solutions may lie only at an irrational boundary point, which cannot be tested exactly — substitute concrete values, use a strict inequality, or hand it to an SMT solver via alkahest.smt", ), }, // E-CUDA — CudaError @@ -180,6 +188,7 @@ pub const REGISTRY: &[ErrorSpec] = &[ ErrorSpec { code: "E-PARSE-001", class: "ParseError", cause: Cause::UserInput, remediation: Some("only ASCII arithmetic expressions are supported") }, ErrorSpec { code: "E-PARSE-002", class: "ParseError", cause: Cause::UserInput, remediation: Some("check parentheses and operator placement") }, ErrorSpec { code: "E-PARSE-003", class: "ParseError", cause: Cause::UserInput, remediation: Some("use a known function: sin, cos, tan, sinh, cosh, tanh, asin, acos, atan, atan2, exp, log, sqrt, abs, sign, floor, ceil, round, erf, erfc, gamma, lambert_w, digamma, bessel_j0, bessel_j1") }, + ErrorSpec { code: "E-PARSE-004", class: "ParseError", cause: Cause::Resource, remediation: Some("flatten the expression — deeply nested parentheses, prefix signs or function calls exceed the parser's recursion budget") }, ErrorSpec { code: "E-EVAL-001", class: "EvalError", cause: Cause::UserInput, remediation: Some("bind every free symbol before evaluation") }, ErrorSpec { code: "E-EVAL-002", class: "EvalError", cause: Cause::UserInput, remediation: Some("use mode='f64' or 'complex' for float literals") }, ErrorSpec { code: "E-EVAL-003", class: "EvalError", cause: Cause::UserInput, remediation: Some("only integer exponents are supported in exact mode") }, @@ -199,6 +208,10 @@ pub const REGISTRY: &[ErrorSpec] = &[ ErrorSpec { code: "E-BUDGET-001", class: "BudgetError", cause: Cause::Resource, remediation: Some("raise Budget(wall_ms=...), or accept a heuristic/numeric result for this candidate instead of an exact one") }, ErrorSpec { code: "E-BUDGET-002", class: "BudgetError", cause: Cause::Resource, remediation: Some("raise Budget(max_steps=...), or accept a partial/heuristic result for this candidate instead of an exact one") }, ErrorSpec { code: "E-BUDGET-003", class: "BudgetError", cause: Cause::Resource, remediation: Some("call alkahest.clear_cancel() (Python) or budget::clear_cancel() (Rust) before starting the next candidate") }, + // E-DEPTH — DepthLimitError (expression nesting ceiling; see kernel::depth). + // Resource, not UserInput: the expression is well-formed, we decline to + // recurse over it because a native stack overflow would kill the process. + ErrorSpec { code: "E-DEPTH-001", class: "DepthLimitError", cause: Cause::Resource, remediation: Some("rebuild the expression with less nesting (a balanced n-ary Add is shallow where a chain of binary ones is not), or process it in smaller pieces") }, // E-DOMAIN — reserved; DomainError is Python-only pending Rust implementation // E-SOS — SosError (P1 item 8: positivity certificates / Positivstellensatz) ErrorSpec { code: "E-SOS-001", class: "SosError", cause: Cause::UserInput, remediation: Some("positivity certificates are for polynomials in the listed variables; expand or clear denominators first, and pass every symbol that occurs as a variable") }, diff --git a/alkahest-core/src/integrate/engine.rs b/alkahest-core/src/integrate/engine.rs index 43b96c0e..b1db4c3c 100644 --- a/alkahest-core/src/integrate/engine.rs +++ b/alkahest-core/src/integrate/engine.rs @@ -1527,25 +1527,37 @@ fn weierstrass_rewrite(expr: ExprId, var: ExprId, t: ExprId, pool: &ExprPool) -> /// `1/(2+cos x)`); the nicer closed forms for `∫sin²`, `∫sec²`, `∫sin(2x)cos(x)` /// are untouched. Soundness-gated by [`verify_antiderivative`]: the candidate /// is returned only when `d/dx result = integrand`, so a wrong antiderivative is -/// never produced. Declines cleanly (`None`) when the integrand is not rational -/// in trig or the `t`-integral does not close. +/// never produced. Declines cleanly (`Ok(None)`) when the integrand is not +/// rational in trig or the `t`-integral does not close. +/// +/// # Why this returns a `Result` +/// +/// The `t`-integral is a *whole nested `integrate` call*, and the half-angle +/// substitution doubles the degree — `∫ 1/(sin⁹x + sin x + 1) dx` becomes a +/// degree-18 rational function, which measured **110 s** end to end. That inner +/// call has cooperative checkpoints of its own, but `.ok()?` threw their verdict +/// away exactly as `try_u_substitution` did, so the budget could not stop the +/// single most expensive route in the elementary integrator. A budget error now +/// propagates; a genuine decline still returns `Ok(None)`. fn try_weierstrass_rational_trig( expr: ExprId, var: ExprId, pool: &ExprPool, log: &mut DerivationLog, -) -> Option { +) -> Result, IntegrationError> { // Only fire on genuine rational-trig integrands (a trig-containing sum in a // denominator); bare/product/power trig keep their nicer dedicated forms. if !has_rational_trig_denominator(expr, var, pool) { - return None; + return Ok(None); } // Fresh half-angle variable t = tan(x/2). let t = pool.symbol("__weierstrass_t", crate::kernel::Domain::Real); // Rewrite the integrand as a rational function of t. - let g_body = weierstrass_rewrite(expr, var, t, pool)?; + let Some(g_body) = weierstrass_rewrite(expr, var, t, pool) else { + return Ok(None); + }; // Jacobian: dx = 2/(1+t²) dt. let one = pool.integer(1_i32); @@ -1560,7 +1572,20 @@ fn try_weierstrass_rational_trig( // Integrate the rational function in t through the full elementary pipeline. // `g` is rational in `t` with no trig of `t`, so this path cannot re-fire and // recursion is bounded. - let inner = integrate(g, t, pool).ok()?; + // This route ends at the `verify_antiderivative` gate below, which can never + // accept a `RootSum` (`simplify` makes it an opaque atom and `eval_interp` + // cannot evaluate one). Tell the rational integrator so, and it declines + // before paying for the Lazard–Rioboo–Trager number-field GCD instead of + // after — same answer, without the dominant cost of this route. + let inner = { + let _no_root_sum = super::risch::rational_integrate::RootSumSuppressed::enter(); + match integrate(g, t, pool) { + Ok(inner) => inner, + // Not this route declining — the caller wants out. + Err(e) if e.is_budget() => return Err(e), + Err(_) => return Ok(None), + } + }; // Back-substitute t = tan(x/2). let half = pool.rational(1_i32, 2_i32); @@ -1572,10 +1597,10 @@ fn try_weierstrass_rational_trig( // Soundness gate: d/dx(result) must equal the original integrand. if !verify_antiderivative(result, expr, var, pool) { - return None; + return Ok(None); } log.push(RewriteStep::simple("int_weierstrass_trig", expr, result)); - Some(result) + Ok(Some(result)) } /// Small explicit table for `∫ 1/cos²(u) = tan(u)/a`, `∫ 1/sin²(u) = −cot(u)/a` @@ -1795,6 +1820,15 @@ pub(crate) fn integrate_raw( pool: &ExprPool, log: &mut DerivationLog, ) -> Result { + // Cooperative checkpoint. This is the rule engine's dispatcher: it recurses + // per summand (sum rule) and per non-constant factor (constant-multiple + // rule), and several of the route helpers it tries below — the Weierstrass + // half-angle substitution in particular — run a whole nested `integrate`. + // Without a check here the only checkpoints on the elementary route were the + // two at depth 0, so `∫ f₁ + … + f₈` of eight hard rational terms could not + // be stopped between terms at all. + crate::budget::check()?; + // Fast-path: ∫ c * x * exp(x) dx = c * exp(x) * (x - 1) if let Some(result) = try_x_times_func(expr, var, pool, log) { return Ok(result); @@ -1846,7 +1880,7 @@ pub(crate) fn integrate_raw( // integrands they decline (e.g. 1/(2+cos x), 1/(1+sin x)); the nicer closed // forms for ∫sin², ∫sec², ∫sin(2x)cos(x) are preserved. Soundness-gated in // the helper. - if let Some(result) = try_weierstrass_rational_trig(expr, var, pool, log) { + if let Some(result) = try_weierstrass_rational_trig(expr, var, pool, log)? { return Ok(result); } @@ -2225,6 +2259,12 @@ fn integrate_inner( let final_log = log.merge(simplified.log); Ok(DerivedExpr::with_log(simplified.value, final_log)) } + // A budget trip travels *as* a `NotImplemented` (see `IntegrationError`'s + // carrier note), so it has to be split off ahead of the decline arm — + // otherwise the fallbacks below read "the caller wants out" as "the rule + // engine declined" and carry on spending the time the caller just asked + // to stop spending. + Err(e) if e.is_budget() => Err(e), Err(IntegrationError::NotImplemented(msg)) => { // Risch Gap 3: rational-function integration via Rothstein–Trager. // Tried as a fallback so simple cases keep their existing rules. @@ -2241,6 +2281,12 @@ fn integrate_inner( let final_log = rlog.merge(simplified.log); return Ok(DerivedExpr::with_log(simplified.value, final_log)); } + // `try_integrate_rational` returns a bare `None` both for "not a + // rational function" and for "the budget tripped part-way" — it is + // public API and cannot grow a `Result` without a major semver break. + // Asking here is what turns the second into an honest `E-BUDGET-*` + // instead of letting it fall through as a mathematical decline. + crate::budget::check()?; // Non-linear substitution (derivative-divides heuristic): // ∫ f(g(x))·g'(x) dx = ∫ f(u) du with u = g(x). Tried only after // the rules and the rational path have declined, so anything they @@ -2248,7 +2294,7 @@ fn integrate_inner( // returned only when its derivative matches the integrand, so a // wrong antiderivative is never produced (a clean decline falls // through to the existing error). - if let Some(result) = try_u_substitution(expr, var, pool, depth) { + if let Some(result) = try_u_substitution(expr, var, pool, depth)? { let simplified = simplify(result, pool); let mut rlog = DerivationLog::new(); rlog.push(RewriteStep::simple( @@ -2320,9 +2366,29 @@ pub fn integrate_definite( return Err(IntegrationError::NotImplemented(reason)); } + // Both checks above bind only `var`, so a free *parameter* in the integrand + // turns each of them off — `interior_singularity` cannot build an integer + // polynomial from a parametric denominator, and every numeric sample fails + // with an unbound symbol. The FTC difference was then returned as though it + // held for all parameter values, when for some of them the integral + // diverges. + if let Some(reason) = parametric_interior_singularity(expr, var, lower, upper, pool) { + return Err(IntegrationError::NotImplemented(reason)); + } + let antideriv = integrate(expr, var, pool)?; let f = antideriv.value; + // The FTC needs `F` continuous on `[lower, upper]`, and none of the checks + // above look at `F` at all — they look at the integrand. A bounded, smooth, + // strictly positive integrand can still have an antiderivative that jumps + // inside the interval, and then `F(b) - F(a)` is not the integral. The + // Weierstrass substitution manufactures exactly that: every + // `∫ dx/(a + b·cos x)` picks up a `tan(x/2)`, which jumps at `x = π`. + if let Some(reason) = antiderivative_jump(f, expr, var, lower, upper, pool) { + return Err(IntegrationError::NotImplemented(reason)); + } + // F(upper) and F(lower). For a finite bound this is plain substitution; for // `±∞` (V2-16's canonical pos_infinity, or its negation) substitution would // silently treat `∞` as an ordinary free symbol and fabricate a @@ -2392,9 +2458,22 @@ const POLE_SCAN_SAMPLES: usize = 257; /// Bisection refinements applied to a candidate blow-up. const POLE_SCAN_REFINEMENTS: usize = 60; /// The refined magnitude must exceed this before a pole is declared. -const POLE_SCAN_MAGNITUDE: f64 = 1e30; -/// …and must have grown by at least this factor during refinement, so an +/// +/// It cannot be much higher. `1/x` reaches only `1e16` before the nearest +/// probe runs out of `f64` resolution, so a threshold of `1e30` — the value +/// this held until 3.8 — is unreachable for every *simple* pole and the scan +/// could only ever see double poles. `∫_1^5 tan x dx` was returned as +/// `0.644` (its Cauchy principal value) for a divergent integral because of it. +const POLE_SCAN_MAGNITUDE: f64 = 1e13; +/// …and must exceed this multiple of the integrand's *typical* magnitude, so an /// integrand that is merely large everywhere is never mistaken for a pole. +/// +/// The baseline is the **median** of the coarse samples, not their maximum. +/// With the maximum, a grid point landing essentially on the pole defeats the +/// test — the "growth" has already happened before refinement starts. That is +/// not a corner case: on `[0, π]` sample 128 of 257 falls within `1e-5` of +/// `π/2`, which is exactly why `∫_0^π tan²x dx` came back as `-π`, a negative +/// number for a non-negative integrand. const POLE_SCAN_GROWTH: f64 = 1e12; /// Fraction of the interval width excluded at each end. Endpoint singularities /// are a different (and often convergent) story — `∫_0^1 log x dx = -1` is @@ -2420,13 +2499,31 @@ const POLE_SCAN_MARGIN: f64 = 1e-3; // into the sampling loop. Since this function's whole job is to decide whether // an integral is safe to evaluate, failing open on NaN is exactly the bug it // exists to prevent. -#[allow(clippy::neg_cmp_op_on_partial_ord)] fn numeric_interior_singularity( integrand: ExprId, var: ExprId, lower: ExprId, upper: ExprId, pool: &ExprPool, +) -> Option { + numeric_interior_singularity_at(integrand, var, lower, upper, &HashMap::new(), pool) +} + +/// [`numeric_interior_singularity`] with the integrand's free *parameters* +/// pinned to concrete values by `params`. +/// +/// The scan itself is unchanged; only the environment the integrand is +/// evaluated in gains the extra bindings. See +/// [`parametric_interior_singularity`] for why a pole found at one parameter +/// value is enough to refuse an answer returned for all of them. +#[allow(clippy::neg_cmp_op_on_partial_ord)] +fn numeric_interior_singularity_at( + integrand: ExprId, + var: ExprId, + lower: ExprId, + upper: ExprId, + params: &HashMap, + pool: &ExprPool, ) -> Option { let (a, b) = (numeric_bound(lower, pool)?, numeric_bound(upper, pool)?); let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; @@ -2439,23 +2536,29 @@ fn numeric_interior_singularity( return None; } + // Sample through the tree-walking interpreter, not `eval::eval_f64`: the + // latter knows only `sin`, `cos`, `exp`, `log` and `sqrt`, so every sample + // of an integrand mentioning `tan` (or `abs`, `sinh`, `atan`, …) failed and + // the scan reported "no opinion" for the whole family. `∫_0^2 sec²x dx` was + // refused while `∫_0^2 tan²x dx` — the same function minus 1 — returned + // `tan 2 - 2 = -4.19`, a negative number for a non-negative integrand whose + // integral diverges. `eval_interp` covers the primitive vocabulary the + // integrator itself works over. let at = |t: f64| -> Option { - let mut bindings = HashMap::new(); + let mut bindings = params.clone(); bindings.insert(var, t); - crate::eval::eval_f64(integrand, pool, &bindings) - .ok() - .filter(|v| v.is_finite()) + crate::jit::eval_interp(integrand, &bindings, pool).filter(|v| v.is_finite()) }; // Coarse scan for the largest magnitude on the grid. let scan_width = scan_hi - scan_lo; - let mut evaluated = 0usize; + let mut magnitudes: Vec = Vec::with_capacity(POLE_SCAN_SAMPLES); let mut center = f64::NAN; let mut m0 = 0.0f64; for i in 0..POLE_SCAN_SAMPLES { let t = scan_lo + scan_width * (i as f64 + 0.5) / POLE_SCAN_SAMPLES as f64; if let Some(v) = at(t) { - evaluated += 1; + magnitudes.push(v.abs()); if v.abs() > m0 { m0 = v.abs(); center = t; @@ -2464,9 +2567,12 @@ fn numeric_interior_singularity( } // Nothing evaluated: the integrand is outside the numeric evaluator's // vocabulary, so this check has no opinion. - if evaluated == 0 || !center.is_finite() || m0 <= 0.0 { + if magnitudes.is_empty() || !center.is_finite() || m0 <= 0.0 { return None; } + // Typical magnitude on the interval — the baseline the blow-up has to beat. + magnitudes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let baseline = magnitudes[magnitudes.len() / 2].max(f64::MIN_POSITIVE); // Refine: keep shrinking a bracket around the running maximum. let mut half = scan_width / POLE_SCAN_SAMPLES as f64; @@ -2499,7 +2605,7 @@ fn numeric_interior_singularity( half = step; } - if peak > POLE_SCAN_MAGNITUDE && peak > POLE_SCAN_GROWTH * m0 { + if peak > POLE_SCAN_MAGNITUDE && peak > POLE_SCAN_GROWTH * baseline { return Some(format!( "improper integral: the integrand blows up at {} ≈ {}, strictly inside the \ interval of integration [{}, {}] (|integrand| exceeds {:e} there). The \ @@ -2515,6 +2621,304 @@ fn numeric_interior_singularity( None } +/// Maximum number of free parameters a parametric pole scan will handle. +/// +/// Beyond this the assignment grid is not worth its cost, and the scan simply +/// has no opinion — exactly as it does for an integrand the evaluator cannot +/// handle. +const POLE_SCAN_MAX_PARAMS: usize = 4; + +/// Number of assignments tried per parameter in a parametric pole scan. +const POLE_SCAN_PARAM_VALUES: usize = 11; + +/// Free symbols of `expr` other than `var`, in first-seen order. +/// +/// `∞` is excluded: it is the canonical bound marker, not a parameter, and it +/// is never a value the numeric evaluator could bind. +fn free_parameters(expr: ExprId, var: ExprId, pool: &ExprPool) -> Vec { + fn walk(e: ExprId, var: ExprId, inf: ExprId, pool: &ExprPool, out: &mut Vec) { + if e == var || e == inf { + return; + } + match pool.get(e) { + ExprData::Symbol { .. } => { + if !out.contains(&e) { + out.push(e); + } + } + ExprData::Add(xs) | ExprData::Mul(xs) => { + for x in xs { + walk(x, var, inf, pool, out); + } + } + ExprData::Pow { base, exp } => { + walk(base, var, inf, pool, out); + walk(exp, var, inf, pool, out); + } + ExprData::Func { args, .. } => { + for a in args { + walk(a, var, inf, pool, out); + } + } + _ => {} + } + } + let mut out = Vec::new(); + walk(expr, var, pool.pos_infinity(), pool, &mut out); + out +} + +/// Candidate values for a free parameter when scanning `[lo, hi]` for a pole. +/// +/// Poles whose *location* is set by a parameter (`1/(x-a)²`, `1/(a·x-1)²`) sit +/// inside the interval only for parameter values related to the interval +/// itself, so the grid mixes points spread across `[lo, hi]` with a handful of +/// generic small magnitudes. The offsets are deliberately not round fractions: +/// a parameter landing *exactly* on a grid sample makes the integrand +/// non-finite there, which the scan discards rather than reports. +fn pole_scan_parameter_values(lo: f64, hi: f64) -> Vec { + let width = hi - lo; + let mut values = vec![ + lo + 0.137 * width, + lo + 0.371 * width, + lo + 0.613 * width, + lo + 0.859 * width, + ]; + values.extend_from_slice(&[-2.13, -1.07, -0.43, 0.43, 1.07, 2.13, 3.71]); + debug_assert_eq!(values.len(), POLE_SCAN_PARAM_VALUES); + values +} + +/// Detect an interior pole that appears for *some* real value of the +/// integrand's free parameters. +/// +/// [`numeric_interior_singularity`] binds only the integration variable, so an +/// integrand carrying any other free symbol evaluates to nothing at every +/// sample and the scan silently switches itself off. `∫_0^2 sec²x dx` is +/// correctly refused, while `∫_0^2 a·sec²x dx` returned `a·tan 2` — a +/// *negative* number, at `a = 1`, for the very integrand the plain scan exists +/// to catch. `∫_{-1}^{1} (x-a)^{-2} dx` is the same failure through the exact +/// route: `interior_singularity` needs integer coefficients, so a parametric +/// denominator falls through and the FTC difference is returned as if it held +/// for every `a`, including the `|a| < 1` where the integral diverges. +/// +/// The result is reported for *all* parameter values, so exhibiting one real +/// value at which the integral is improper is enough to refuse it: the answer +/// carries no side condition that would exclude that value. Refusal is +/// therefore justified by the same blow-up evidence the plain scan uses — the +/// magnitude and growth thresholds mean no bounded integrand can trigger it — +/// and finding nothing simply falls through to the previous behaviour. +fn parametric_interior_singularity( + integrand: ExprId, + var: ExprId, + lower: ExprId, + upper: ExprId, + pool: &ExprPool, +) -> Option { + let params = free_parameters(integrand, var, pool); + if params.is_empty() || params.len() > POLE_SCAN_MAX_PARAMS { + return None; + } + let (a, b) = (numeric_bound(lower, pool)?, numeric_bound(upper, pool)?); + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + let grid = pole_scan_parameter_values(lo, hi); + + // The assignment set is deliberately linear, not the full product: the + // *diagonal* (every parameter at the same grid value, which is what finds + // the pole of `1/(x-a-b)²`), plus each parameter swept alone with the + // others held at a fixed non-degenerate value (which finds the pole of + // `1/(x-a)²` however many other parameters ride along). A full grid would + // be `11^n` scans for no extra coverage of the shapes that actually occur. + const HELD: f64 = 1.07; + let n = params.len(); + let mut assignments: Vec> = grid.iter().map(|v| vec![*v; n]).collect(); + if n > 1 { + for i in 0..n { + for v in &grid { + let mut row = vec![HELD; n]; + row[i] = *v; + assignments.push(row); + } + } + } + + for assignment in assignments { + let bindings: HashMap = params + .iter() + .copied() + .zip(assignment.iter().copied()) + .collect(); + if let Some(reason) = + numeric_interior_singularity_at(integrand, var, lower, upper, &bindings, pool) + { + let at = params + .iter() + .zip(assignment.iter()) + .map(|(p, v)| format!("{} = {}", pool.display(*p), v)) + .collect::>() + .join(", "); + return Some(format!( + "{reason}. This holds at {at}; the answer would be returned for every value \ + of {}, so it is refused rather than stated without the side condition that \ + keeps the pole outside [{}, {}]", + params + .iter() + .map(|p| pool.display(*p).to_string()) + .collect::>() + .join(", "), + lo, + hi, + )); + } + } + None +} + +/// Cells the antiderivative is sampled over when looking for a jump. +const JUMP_SCAN_CELLS: usize = 257; +/// Sub-samples per cell used to estimate `sup |f|` on that cell. +const JUMP_SCAN_SUBSAMPLES: usize = 9; +/// A cell is *suspicious* once `|ΔF|` exceeds this multiple of `h·sup|f|`. +/// +/// The mean value theorem gives `|ΔF| = h·|f(ξ)| ≤ h·sup|f|` wherever `F` is +/// differentiable, so the true value is `≤ 1`; the margin absorbs `sup|f|` +/// being *sampled* rather than computed. +const JUMP_SUSPICION_RATIO: f64 = 8.0; +/// Bisections applied to the most suspicious cell. +const JUMP_REFINEMENTS: usize = 50; +/// A jump is declared only once the ratio has grown past this. +/// +/// This is what separates a genuine discontinuity from a narrow spike. Around +/// a jump, `|ΔF|` tends to the jump height while `h·sup|f|` tends to zero, so +/// the ratio grows like `1/h`. Around a spike — however tall — `F` is still +/// continuous, `|ΔF|` shrinks with the cell, and the ratio stays bounded. +const JUMP_CONFIRM_RATIO: f64 = 1e6; + +/// Detect a jump discontinuity of the antiderivative `f` strictly inside +/// `(lower, upper)`, which makes `F(b) − F(a)` not the value of the integral. +/// +/// This is the failure the other two guards structurally cannot see: they look +/// at the *integrand*, and here the integrand is perfectly well behaved. +/// `∫_0^{3.2} dx/(cos x − 3)` — integrand between `1/16` and `1/4`, so the +/// integral is between `0.2` and `0.8` — returned `−0.413`, because the +/// half-angle antiderivative carries a `tan(x/2)` that jumps at `x = π`. Over a +/// full period the same mechanism returns `0`: `∫_0^{2π} dx/(2 + cos x)` came +/// back as `−8e-17` where the value is `2π/√3 ≈ 3.63`. +/// +/// Returns `None` — no opinion — whenever the question cannot be decided: +/// symbolic bounds, free parameters, or an `F` the interpreter cannot +/// evaluate. Refusal requires positive evidence, never absence of it. +// `!(width > 0.0)` and `!(bound > 0.0)` are NaN-safe bail-outs, exactly as in +// `numeric_interior_singularity`: they are *true* for NaN and correctly abandon +// the scan, whereas clippy's suggested `width <= 0.0` is false for NaN and would +// let a degenerate cell through into the ratio test. +#[allow(clippy::neg_cmp_op_on_partial_ord)] +fn antiderivative_jump( + f: ExprId, + integrand: ExprId, + var: ExprId, + lower: ExprId, + upper: ExprId, + pool: &ExprPool, +) -> Option { + if !free_parameters(f, var, pool).is_empty() + || !free_parameters(integrand, var, pool).is_empty() + { + return None; + } + let (a, b) = (numeric_bound(lower, pool)?, numeric_bound(upper, pool)?); + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + let width = hi - lo; + if !(width > 0.0) || !width.is_finite() { + return None; + } + + let at = |e: ExprId, t: f64| -> Option { + let mut bindings = HashMap::new(); + bindings.insert(var, t); + crate::jit::eval_interp(e, &bindings, pool).filter(|v| v.is_finite()) + }; + // `sup |integrand|` over `[c0, c1]`, sampled. `None` when nothing on the + // cell evaluates, which makes the cell undecidable rather than suspicious. + let sup_f = |c0: f64, c1: f64| -> Option { + let mut m: Option = None; + for j in 0..JUMP_SCAN_SUBSAMPLES { + let t = c0 + (c1 - c0) * (j as f64) / ((JUMP_SCAN_SUBSAMPLES - 1) as f64); + if let Some(v) = at(integrand, t) { + m = Some(m.map_or(v.abs(), |cur: f64| cur.max(v.abs()))); + } + } + m + }; + // `|ΔF| / (h · sup|f|)` on `[c0, c1]`, together with `|ΔF|`. + let ratio = |c0: f64, c1: f64| -> Option<(f64, f64)> { + let (f0, f1) = (at(f, c0)?, at(f, c1)?); + let jump = (f1 - f0).abs(); + let bound = (c1 - c0).abs() * sup_f(c0, c1)?; + if !(bound > 0.0) || !bound.is_finite() || !jump.is_finite() { + return None; + } + Some((jump / bound, jump)) + }; + + // Coarse pass: find the most suspicious cell. + let mut worst = 0.0_f64; + let mut cell = (f64::NAN, f64::NAN); + for i in 0..JUMP_SCAN_CELLS { + let c0 = lo + width * (i as f64) / (JUMP_SCAN_CELLS as f64); + let c1 = lo + width * ((i + 1) as f64) / (JUMP_SCAN_CELLS as f64); + if let Some((r, _)) = ratio(c0, c1) { + if r > worst { + worst = r; + cell = (c0, c1); + } + } + } + if worst < JUMP_SUSPICION_RATIO || !cell.0.is_finite() { + return None; + } + + // Refine: keep the half carrying the larger `|ΔF|`. A jump keeps its + // height while the cell shrinks; a spike does not. + let (mut c0, mut c1) = cell; + let mut best = worst; + for _ in 0..JUMP_REFINEMENTS { + let mid = 0.5 * (c0 + c1); + if !(c0 < mid && mid < c1) { + break; + } + let left = ratio(c0, mid); + let right = ratio(mid, c1); + let take_left = match (&left, &right) { + (Some((_, jl)), Some((_, jr))) => jl >= jr, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => break, + }; + let (nc0, nc1) = if take_left { (c0, mid) } else { (mid, c1) }; + let Some((r, _)) = ratio(nc0, nc1) else { break }; + c0 = nc0; + c1 = nc1; + best = best.max(r); + } + + (best > JUMP_CONFIRM_RATIO).then(|| { + format!( + "improper application of the fundamental theorem: the antiderivative {} is \ + discontinuous at {} ≈ {}, strictly inside [{}, {}] (its increment there exceeds \ + the integrand's own bound by a factor of {:.3e}, and grows as the bracket \ + shrinks). F(b) - F(a) therefore skips the jump and is not the value of this \ + integral", + pool.display(f), + pool.display(var), + 0.5 * (c0 + c1), + lo, + hi, + best, + ) + }) +} + /// Split `expr` into `(numerator, denominator)` by collecting factors carrying a /// negative integer power into the denominator. fn split_numer_denom(expr: ExprId, pool: &ExprPool) -> (ExprId, ExprId) { @@ -2763,10 +3167,28 @@ const U_SUBST_MAX_CANDIDATES: usize = 12; /// Every candidate result is **soundness-gated**: it is returned only when its /// derivative equals the original integrand (structurally, or to ~1e-7 over /// several real sample points). A failing candidate is skipped; if none passes, -/// the function declines with `None` and the caller reports its existing error. -fn try_u_substitution(expr: ExprId, var: ExprId, pool: &ExprPool, depth: u32) -> Option { +/// the function declines with `Ok(None)` and the caller reports its existing +/// error. +/// +/// # Why this returns a `Result` and not just an `Option` +/// +/// A failing candidate is skipped — but a *budget trip* is not a failing +/// candidate, it is the caller asking the whole call to stop. This loop used to +/// throw both away identically (`let Ok(inner) = … else { continue }`), which +/// silently defeated every cooperative checkpoint below the top level: with +/// `max_steps=2` — enough to clear the two depth-0 checks — a `request_cancel()` +/// or an exhausted wall clock was discarded and the search moved on to the next +/// of up to 12 candidates, each of which could take seconds. `integrate` was +/// therefore only interruptible in its first instants, whatever the binding did +/// about the GIL. A budget error now propagates; everything else still skips. +fn try_u_substitution( + expr: ExprId, + var: ExprId, + pool: &ExprPool, + depth: u32, +) -> Result, IntegrationError> { if depth >= U_SUBST_MAX_DEPTH { - return None; + return Ok(None); } // Try the integrand as written, and a trig-expanded form (tan → sin·cos⁻¹, @@ -2778,10 +3200,32 @@ fn try_u_substitution(expr: ExprId, var: ExprId, pool: &ExprPool, depth: u32) -> variants.push(expanded); } + // `(g, reduced integrand)` pairs already attempted. The two variants + // (`expr` and its trig-expanded form) very often reduce to the *same* inner + // integral under the same `g` — `∫ cos x·sin¹²x/(sin¹⁷x + sin x + 1) dx` + // reaches `∫ u¹²/(u¹⁷ + u + 1) du` from both — and since `u` is + // hash-consed, that is literally the same `ExprId`, integrated twice for the + // same verdict (measured: 5.6 s + 5.5 s of an 11.1 s call). The pair is the + // key rather than the integrand alone because a different `g` back- + // substitutes to a different candidate. + let mut attempted: std::collections::HashSet<(ExprId, ExprId)> = + std::collections::HashSet::new(); + for &form in &variants { let candidates = collect_usub_candidates(form, var, pool); for g in candidates.into_iter().take(U_SUBST_MAX_CANDIDATES) { + // Cooperative checkpoint at the granularity that actually costs + // something: each surviving candidate runs a full recursive + // `integrate`, which can take seconds. Checking only at the + // recursion boundary below is too late — once a budget has tripped, + // `simplify` stops rewriting, so the candidate's quotient no longer + // reduces, `is_free_of` rejects it, and it `continue`s without ever + // reaching that boundary. The search would then run out of + // candidates and report a *decline* for what is really a + // cancellation. + crate::budget::check()?; + // g must contain var, must not be var itself, and must not be constant. if g == var || is_free_of(g, var, pool) { continue; @@ -2812,10 +3256,24 @@ fn try_u_substitution(expr: ExprId, var: ExprId, pool: &ExprPool, depth: u32) -> if !is_free_of(replaced, var, pool) { continue; } + if !attempted.insert((g, replaced)) { + continue; // identical reduced integral, identical verdict + } // Integrate the reduced integrand in u (full pipeline, deeper level). - let Ok(inner) = integrate_inner(replaced, u, pool, depth + 1) else { - continue; + // As in the Weierstrass route, this candidate ends at the + // `verify_antiderivative` gate below, which provably cannot accept a + // `RootSum` — so suppress the Lazard–Rioboo–Trager number-field GCD + // that would build one rather than paying for an answer that is + // certain to be rejected. + let inner = { + let _no_root_sum = super::risch::rational_integrate::RootSumSuppressed::enter(); + match integrate_inner(replaced, u, pool, depth + 1) { + Ok(inner) => inner, + // Not this candidate declining — the caller wants out. + Err(e) if e.is_budget() => return Err(e), + Err(_) => continue, + } }; // Back-substitute u ↦ g. @@ -2825,12 +3283,12 @@ fn try_u_substitution(expr: ExprId, var: ExprId, pool: &ExprPool, depth: u32) -> // Soundness gate: d/dx(result) must equal the original integrand. if verify_antiderivative(result, expr, var, pool) { - return Some(result); + return Ok(Some(result)); } } } - None + Ok(None) } /// Rewrite trigonometric functions in terms of `sin`/`cos` (e.g. `tan → sin·cos⁻¹`) @@ -3042,6 +3500,111 @@ mod tests { ExprPool::new() } + /// `∫ cos(x)·sinⁿ(x)/(sin^d(x) + sin x + 1) dx` — declined by every rule, so + /// it goes to the two searches that cost real time: the Weierstrass + /// half-angle route and, failing that, derivative-divides u-substitution. + fn hard_trig_integrand(pool: &ExprPool, x: ExprId, n: i32, d: i32) -> ExprId { + let s = pool.func("sin", vec![x]); + let c = pool.func("cos", vec![x]); + let den = pool.add(vec![pool.pow(s, pool.integer(d)), s, pool.integer(1_i32)]); + pool.mul(vec![ + c, + pool.pow(s, pool.integer(n)), + pool.pow(den, pool.integer(-1_i32)), + ]) + } + + /// A budget trip inside the u-substitution search must reach the caller. + /// + /// `try_u_substitution` used to discard every error from its recursive + /// `integrate_inner` call — budget trips included — and move on to the next + /// of up to twelve candidates, so the checkpoint at the recursion boundary + /// did nothing. + /// + /// Called directly rather than through `integrate`, because which route + /// `integrate` picks for a given integrand is not this test's business: the + /// claim is about the search, so the search is what gets called. + #[test] + fn a_budget_trip_inside_u_substitution_propagates() { + let pool = p(); + let x = pool.symbol("x", Domain::Real); + let e = hard_trig_integrand(&pool, x, 6, 3); + + let _guard = crate::budget::enter(crate::budget::Budget::new().with_max_steps(0)); + let err = try_u_substitution(e, x, &pool, 0).expect_err("the budget must stop the search"); + assert!(err.is_budget(), "expected a budget trip, got {err:?}"); + assert_eq!(err.budget_code(), Some("E-BUDGET-002")); + } + + /// Same claim for the Weierstrass half-angle route, which is where a hard + /// rational-trig integrand actually spends its seconds: it runs a whole + /// nested `integrate` on a doubled-degree rational function, and used to + /// throw that call's budget verdict away with `.ok()?`. + #[test] + fn a_budget_trip_inside_the_weierstrass_route_propagates() { + let pool = p(); + let x = pool.symbol("x", Domain::Real); + let e = hard_trig_integrand(&pool, x, 6, 3); + let mut log = DerivationLog::new(); + + let _guard = crate::budget::enter(crate::budget::Budget::new().with_max_steps(0)); + let err = try_weierstrass_rational_trig(e, x, &pool, &mut log) + .expect_err("the budget must stop the route"); + assert!(err.is_budget(), "expected a budget trip, got {err:?}"); + } + + /// End to end: a wall budget on the integrand that used to overshoot it by + /// more than 10× must come back as a budget trip, not as a mathematical + /// decline. + /// + /// The failure this pins is subtle and was live until the checkpoints went + /// in: every route that gave up part-way reported `NotImplemented`, and + /// because `NotImplemented` is *also* the budget carrier, a trip could be + /// consumed by the next fallback and the caller would be told the integral + /// is unsupported when in fact it was never finished. No wall-clock + /// assertion here — only which verdict comes back. + /// + /// `(n, d)` was raised from `(12, 9)` to `(40, 31)` for 3.8: suppressing the + /// `RootSum` the two verify-gated routes cannot use took `(12, 9)` from + /// 3.7 s to 12 ms, which is inside the 50 ms budget, so the trip this test + /// asserts stopped happening for the good reason. `(40, 31)` still costs + /// about 5 s unbudgeted. + #[test] + fn a_wall_budget_stops_the_weierstrass_route_honestly() { + let pool = p(); + let x = pool.symbol("x", Domain::Real); + let e = hard_trig_integrand(&pool, x, 40, 31); + + let _guard = crate::budget::enter( + crate::budget::Budget::new().with_wall(std::time::Duration::from_millis(50)), + ); + let err = integrate(e, x, &pool).expect_err("the budget must stop this call"); + assert!( + err.is_budget(), + "a wall-clock trip must be reported as one, not as a decline; got {err:?}" + ); + assert_eq!(err.budget_code(), Some("E-BUDGET-001")); + } + + /// The control: the propagation must not turn a *declining* candidate into + /// an error. With no budget active the search still runs to its own verdict. + #[test] + fn u_substitution_still_declines_without_erroring() { + let pool = p(); + let x = pool.symbol("x", Domain::Real); + let two_x = pool.mul(vec![pool.integer(2_i32), x]); + let inner = pool.add(vec![pool.pow(x, pool.integer(2_i32)), pool.integer(1_i32)]); + // ∫ 2x·cos(x²) dx = sin(x²): u-substitution's bread and butter, and the + // path where earlier candidates decline before the right one is found. + let e = pool.mul(vec![two_x, pool.func("cos", vec![inner])]); + let got = integrate(e, x, &pool).expect("u-substitution must still solve this"); + let expected = pool.func("sin", vec![inner]); + assert_eq!( + simplify(got.value, &pool).value, + simplify(expected, &pool).value + ); + } + #[test] fn antiderivative_verification_distinguishes_numeric_evidence() { let pool = p(); diff --git a/alkahest-core/src/integrate/risch/number_field.rs b/alkahest-core/src/integrate/risch/number_field.rs index 115dbc07..c90ca34a 100644 --- a/alkahest-core/src/integrate/risch/number_field.rs +++ b/alkahest-core/src/integrate/risch/number_field.rs @@ -462,6 +462,12 @@ impl Quotient { } let mut quot = vec![Self::elem_zero(); (ad - bd + 1) as usize]; loop { + // Cooperative checkpoint, once per elimination step. Each step is a + // full `K`-multiplication per coefficient of `b`, over ℚ-coefficients + // that grow as the Euclidean remainder sequence goes on, so a single + // `kpoly_divrem` on a degree-18 minimal polynomial is seconds of + // uninterruptible work — see `kpoly_gcd`. + crate::budget::check().ok()?; let rd = self.kdeg(&r); if rd < bd { break; @@ -483,10 +489,28 @@ impl Quotient { } /// Monic (in `x`) GCD of two quotient-polynomials. + /// + /// # Cost, and why it checks the budget + /// + /// This is the Euclidean algorithm over `K = ℚ[t]/Q(t)` with no modular or + /// fraction-free strategy, so the ℚ-coefficients grow through the remainder + /// sequence. It is the log-argument step of Lazard–Rioboo–Trager + /// (`rational_integrate::alg_log_argument`), and on a degree-18 minimal + /// polynomial — what `∫ 1/(sin⁹x + sin x + 1) dx` produces after the + /// Weierstrass substitution — one call measured **3996 ms of a 4046 ms + /// integral**, i.e. 98.7% of the whole thing, in a single uninterruptible + /// stretch. That is why `Budget(wall_ms=300)` used to overshoot by more than + /// 10×, and why it kept getting worse with degree. + /// + /// Returns `None` when the budget trips, which the callers already treat as + /// "this route declines"; `integrate_inner` re-checks the budget immediately + /// afterwards and turns it into the honest `E-BUDGET-*`. Bailing rather than + /// widening the return type keeps this public signature intact. pub fn kpoly_gcd(&self, a: &[GPoly], b: &[GPoly]) -> Option>> { let mut a = self.kpoly_trim(a.to_vec()); let mut b = self.kpoly_trim(b.to_vec()); while self.kdeg(&b) >= 0 { + crate::budget::check().ok()?; let (_, rem) = self.kpoly_divrem(&a, &b)?; a = b; b = rem; diff --git a/alkahest-core/src/integrate/risch/rational_integrate.rs b/alkahest-core/src/integrate/risch/rational_integrate.rs index bf0879d8..abcba446 100644 --- a/alkahest-core/src/integrate/risch/rational_integrate.rs +++ b/alkahest-core/src/integrate/risch/rational_integrate.rs @@ -50,16 +50,73 @@ use super::poly_rde::{ qpoly_to_expr, rational_to_expr, trim, QPoly, }; use super::rational_rde::{ - expr_to_qrational, poly_div_exact, poly_divrem, poly_gcd, poly_monic, poly_sub, + expr_to_qrational, poly_div_exact, poly_divrem, poly_gcd, poly_gcd_budgeted, poly_monic, + poly_sub, }; +// --------------------------------------------------------------------------- +// "A RootSum answer would be thrown away" — see `RootSumSuppressed` +// --------------------------------------------------------------------------- + +std::thread_local! { + /// Whether a `RootSum` in the result is usable by the *caller*. + static ROOT_SUM_USABLE: std::cell::Cell = const { std::cell::Cell::new(true) }; +} + +/// Scope guard declaring that the caller will gate this integration through +/// [`crate::integrate::verify_antiderivative_status`], which **provably cannot +/// accept** a candidate containing a [`crate::kernel::ExprData::RootSum`]: +/// +/// * the exact arm simplifies `d/dx(candidate) − integrand` and asks whether it +/// is zero, but `simplify` replaces every `RootSum` with an *opaque atom* +/// (`simplify/egraph.rs`), so a residual containing one never reduces to 0; +/// * the numeric arm evaluates with `jit::eval_interp`, which has no `RootSum` +/// arm at all and returns `None`. +/// +/// So when this guard is active, building the `RootSum` is pure waste — and its +/// Lazard–Rioboo–Trager log argument is a number-field GCD that dominates the +/// whole integration (measured: 3.72 s of a 3.74 s `∫ cos·sin¹²/(sin⁹+sin+1)`, +/// all of it discarded). Declining early returns exactly the same `None` the +/// caller would have reached after paying for it. +pub(crate) struct RootSumSuppressed(bool); + +impl RootSumSuppressed { + /// Suppress `RootSum` results until the returned guard is dropped. + pub(crate) fn enter() -> Self { + Self(ROOT_SUM_USABLE.with(|c| c.replace(false))) + } +} + +impl Drop for RootSumSuppressed { + fn drop(&mut self) { + ROOT_SUM_USABLE.with(|c| c.set(self.0)); + } +} + +/// Whether emitting a `RootSum` is worth the work on this thread. +fn root_sum_usable() -> bool { + ROOT_SUM_USABLE.with(|c| c.get()) +} + /// Attempt to integrate `expr` as a rational function of `var`. /// /// Returns `Some(F)` with the antiderivative (no constant of integration) when /// the Rothstein–Trager path succeeds, or `None` when `expr` is not a rational /// function or falls outside the supported subset (see module docs), so the /// caller can fall back to its existing behaviour. +/// +/// # Budget +/// +/// Also returns `None` when [`crate::budget`] trips at one of the stage +/// boundaries below. This route is where a hard rational integrand spends its +/// time — measured at 4.0 s for the degree-18 denominator that +/// `∫ cos x·sin¹²x/(sin⁹x + sin x + 1) dx` reaches through the Weierstrass +/// substitution — so without these it was one uninterruptible block. +/// `integrate_inner` re-checks the budget straight after calling this and +/// reports `E-BUDGET-*`, so a bail-out is never mistaken for a mathematical +/// decline. pub fn try_integrate_rational(expr: ExprId, var: ExprId, pool: &ExprPool) -> Option { + crate::budget::check().ok()?; let (a, d) = expr_to_qrational(expr, var, pool)?; let a = trim(a); let d = trim(d); @@ -74,7 +131,9 @@ pub fn try_integrate_rational(expr: ExprId, var: ExprId, pool: &ExprPool) -> Opt let lc_inv = Rational::from(1) / d[degree(&d) as usize].clone(); let d = poly_scale(&d, &lc_inv); let a = poly_scale(&a, &lc_inv); - let g = poly_gcd(&a, &d); + // The dominant cost of this whole route on a high-degree integrand; see + // `poly_gcd_budgeted` for the measurement. + let g = poly_gcd_budgeted(&a, &d)?; let a = poly_div_exact(&a, &g); let d = poly_monic(&poly_div_exact(&d, &g)); if degree(&d) < 1 { @@ -94,13 +153,14 @@ pub fn try_integrate_rational(expr: ExprId, var: ExprId, pool: &ExprPool) -> Opt if !r.is_empty() { // Hermite reduction: split R/D into a rational part (added directly) plus a // proper fraction H/Drad with a *squarefree* denominator for the log part. + crate::budget::check().ok()?; let (rational_terms, h, drad) = hermite_reduce(&r, &d, var, pool)?; terms.extend(rational_terms); let h = trim(h); if !h.is_empty() { // Reduce H/Drad and apply Rothstein–Trager to the squarefree remainder. - let g = poly_gcd(&h, &drad); + let g = poly_gcd_budgeted(&h, &drad)?; let h = poly_div_exact(&h, &g); let drad = poly_monic(&poly_div_exact(&drad, &g)); if degree(&drad) >= 1 { @@ -108,9 +168,13 @@ pub fn try_integrate_rational(expr: ExprId, var: ExprId, pool: &ExprPool) -> Opt // Rothstein–Trager for rational residues; otherwise fall back to a // partial-fraction pass that emits log + arctan for irreducible // quadratic factors. + crate::budget::check().ok()?; let logs = match rothstein_trager(&h, &drad, &dprime, var, pool) { Some(logs) => logs, - None => partial_fraction_log_arctan(&h, &drad, var, pool)?, + None => { + crate::budget::check().ok()?; + partial_fraction_log_arctan(&h, &drad, var, pool)? + } }; terms.extend(logs); } @@ -531,6 +595,10 @@ fn partial_fraction_log_arctan( let mut terms: Vec = Vec::new(); for (p, n) in factors.iter().zip(nums.iter()) { + // Cooperative checkpoint per irreducible factor: the degree-≥3 arm below + // runs a number-field GCD whose cost grows sharply with that factor's + // degree, so this is the coarsest bound worth having on the loop. + crate::budget::check().ok()?; let n = trim(n.clone()); if n.is_empty() { continue; @@ -620,7 +688,13 @@ fn partial_fraction_log_arctan( } } _ => { - // RootSum(Q, t, t·log(S(t,x))). + // RootSum(Q, t, t·log(S(t,x))). The log argument is + // a number-field GCD and by far the most expensive + // step here, so skip it outright when the caller has + // told us a `RootSum` cannot be used. + if !root_sum_usable() { + return None; + } let s_expr = alg_log_argument(&n, &pp, p, qf, var, rvar, pool)?; let body = pool.mul(vec![rvar, pool.func("log", vec![s_expr])]); let q_expr = qpoly_to_expr(qf, rvar, pool); diff --git a/alkahest-core/src/integrate/risch/rational_rde.rs b/alkahest-core/src/integrate/risch/rational_rde.rs index 1d2014e1..7d1f8b45 100644 --- a/alkahest-core/src/integrate/risch/rational_rde.rs +++ b/alkahest-core/src/integrate/risch/rational_rde.rs @@ -110,8 +110,59 @@ pub fn poly_monic(p: &QPoly) -> QPoly { poly_scale(&p, &(Rational::from(1) / lc)) } -/// Monic GCD of `a` and `b` over ℚ (Euclidean algorithm). +/// Clear denominators: the primitive integer associate of a `ℚ`-polynomial, +/// as a FLINT `fmpz_poly`. Scaling by a nonzero rational does not change a +/// *monic* GCD, so this is lossless for [`poly_gcd`]'s purposes. +fn qpoly_to_fmpz(p: &[Rational]) -> crate::flint::FlintPoly { + let mut l = rug::Integer::from(1); + for c in p.iter().filter(|c| **c != 0) { + l.lcm_mut(c.denom()); + } + let ints: Vec = p + .iter() + .map(|c| c.numer() * rug::Integer::from(&l / c.denom())) + .collect(); + crate::flint::FlintPoly::from_rug_coefficients(&ints) +} + +/// Monic GCD of `a` and `b` over ℚ. +/// +/// # Why this goes through FLINT +/// +/// The obvious implementation — the Euclidean algorithm over `ℚ` — is +/// quadratic in the *number* of coefficient operations but its coefficients +/// blow up through the remainder sequence, and every one of those operations is +/// a canonicalising `rug::Rational` multiply that pays a bignum GCD. Measured +/// on the degree-80 image that `∫ cos x·sin¹²x/(sin¹⁷x + sin x + 1) dx` +/// produces after the Weierstrass substitution, the ℚ-Euclid took **11.5 s**; +/// clearing denominators and handing the integer problem to FLINT's modular +/// `fmpz_poly_gcd` takes **0.3 s** for a bit-identical answer. +/// +/// The result is unchanged, not merely equivalent: the monic GCD of two +/// polynomials over a field is unique, and clearing denominators multiplies +/// each input by a nonzero rational, which cannot change it. +/// `poly_gcd_euclid` (crate-internal) remains as the reference implementation and is what the +/// two agree-on-random-input property tests compare against. pub fn poly_gcd(a: &QPoly, b: &QPoly) -> QPoly { + let a = trim(a.clone()); + let b = trim(b.clone()); + if a.is_empty() { + return poly_monic(&b); + } + if b.is_empty() { + return poly_monic(&a); + } + let g = qpoly_to_fmpz(&a).gcd(&qpoly_to_fmpz(&b)); + let coeffs: Vec = (0..g.length()) + .map(|i| Rational::from(g.get_coeff_flint(i).to_rug())) + .collect(); + poly_monic(&coeffs) +} + +/// The textbook Euclidean algorithm over `ℚ` — the reference [`poly_gcd`] is +/// checked against, kept because it needs no FFI and no integer conversion. +#[cfg(test)] +pub(crate) fn poly_gcd_euclid(a: &QPoly, b: &QPoly) -> QPoly { let mut a = trim(a.clone()); let mut b = trim(b.clone()); while !b.is_empty() { @@ -122,6 +173,36 @@ pub fn poly_gcd(a: &QPoly, b: &QPoly) -> QPoly { poly_monic(&a) } +/// [`poly_gcd`], but gives up when [`crate::budget`] trips. +/// +/// # Why a second function instead of a check inside `poly_gcd` +/// +/// A GCD has no error channel — it returns the polynomial — so a checkpoint +/// inside it could only *stop early*, and stopping the Euclidean algorithm early +/// returns a **wrong** GCD. Downstream that is a wrong antiderivative, which is +/// the one outcome worse than being slow. `poly_gcd` is also public API, so it +/// cannot grow a `Result` without a major semver break. This variant is +/// crate-internal, returns `None` rather than a truncated answer, and leaves +/// every existing caller of `poly_gcd` untouched. +/// +/// # Why it is worth having +/// +/// The Euclidean algorithm over ℚ has no modular or fraction-free strategy here, +/// so the coefficients grow through the remainder sequence. Normalising `A/D` to +/// lowest terms in [`super::rational_integrate::try_integrate_rational`] +/// measured **480 ms of a 482 ms call** on the degree-80 image that +/// `∫ cos x·sin⁴⁰x/(sin¹⁷x + sin x + 1) dx` produces after the Weierstrass +/// substitution — the last uninterruptible block on that route once the other +/// checkpoints were in, and the reason a 50 ms budget still took 400 ms. +/// Checking once per Euclidean step makes the granularity one `poly_divrem`. +pub(crate) fn poly_gcd_budgeted(a: &QPoly, b: &QPoly) -> Option { + // `poly_gcd` now delegates to FLINT, which is a single uninterruptible call + // — but one that is orders of magnitude shorter than the ℚ-Euclid it + // replaced, so the checkpoint before it is the granularity that matters. + crate::budget::check().ok()?; + Some(poly_gcd(a, b)) +} + /// Exact division `a / b` (panics in debug if the remainder is nonzero). pub fn poly_div_exact(a: &QPoly, b: &QPoly) -> QPoly { let (q, r) = poly_divrem(a, b); @@ -890,6 +971,76 @@ mod tests { Rational::from(n) } + // -- poly_gcd: the FLINT route must agree with the reference ℚ-Euclid ---- + + /// A cheap deterministic PRNG so this stays a unit test, not a proptest. + fn lcg(state: &mut u64) -> i64 { + *state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1); + ((*state >> 33) % 21) as i64 - 10 + } + + #[test] + fn poly_gcd_flint_agrees_with_euclid_on_random_rationals() { + let mut s = 0x2545_F491_4F6C_DD1D_u64; + for trial in 0..400 { + let da = (trial % 7) + 1; + let db = (trial % 5) + 1; + let mk = |n: usize, s: &mut u64| -> QPoly { + let mut p: QPoly = (0..=n) + .map(|_| { + let num = lcg(s); + let den = lcg(s).unsigned_abs().max(1) as i64; + Rational::from((num, den)) + }) + .collect(); + if trim(p.clone()).is_empty() { + p = vec![rat(1)]; + } + p + }; + let a = mk(da, &mut s); + let b = mk(db, &mut s); + // Force a nontrivial common factor half the time. + let (a, b) = if trial % 2 == 0 { + let c = mk(2, &mut s); + (poly_mul(&a, &c), poly_mul(&b, &c)) + } else { + (a, b) + }; + assert_eq!( + poly_gcd(&a, &b), + poly_gcd_euclid(&a, &b), + "trial {trial}: FLINT gcd disagrees with ℚ-Euclid on {a:?}, {b:?}" + ); + } + } + + #[test] + fn poly_gcd_flint_agrees_with_euclid_on_degenerate_inputs() { + let zero: QPoly = Vec::new(); + let cases: Vec<(QPoly, QPoly)> = vec![ + (zero.clone(), zero.clone()), + (vec![rat(0), rat(0)], zero.clone()), + (zero.clone(), vec![rat(3), rat(6)]), + (vec![rat(3), rat(6)], zero.clone()), + (vec![rat(5)], vec![rat(7)]), + (vec![rat(0), rat(1)], vec![rat(0), rat(0), rat(1)]), + ( + vec![Rational::from((1, 3)), Rational::from((2, 5))], + vec![Rational::from((7, 11))], + ), + ]; + for (a, b) in cases { + assert_eq!( + poly_gcd(&a, &b), + poly_gcd_euclid(&a, &b), + "FLINT gcd disagrees with ℚ-Euclid on {a:?}, {b:?}" + ); + } + } + // ∫ (x-1)/x² · exp(x) dx = exp(x)/x. // RDE: v' + v = (x-1)/x² → v = 1/x. #[test] diff --git a/alkahest-core/src/kernel/depth.rs b/alkahest-core/src/kernel/depth.rs new file mode 100644 index 00000000..78dbd638 --- /dev/null +++ b/alkahest-core/src/kernel/depth.rs @@ -0,0 +1,220 @@ +//! The expression-depth ceiling that keeps deep trees from killing the process. +//! +//! # Why this exists +//! +//! Almost every operation on an expression is a structural recursion over the +//! DAG: printing, simplification, differentiation, substitution, translation to +//! Lean or SMT-LIB, evaluation. Each level of the expression costs one or more +//! native stack frames, and a native stack overflow is **not** an exception — +//! the kernel delivers `SIGSEGV` and the process dies with no traceback, no +//! error code, and nothing for a caller's `except Exception` to catch. For an +//! unattended run that is strictly worse than a wrong answer: a wrong answer +//! can be logged. +//! +//! Measured by bisection on the shipped release build with the usual 8 MiB +//! main-thread stack (`ulimit -s 8192`), on a chain of `sin` applications: +//! +//! | operation | deepest that returned | first that segfaulted | +//! |---|---|---| +//! | `symbolic_grad` (reverse-mode DFS) | 4 625 | 4 687 | +//! | `simplify`, `to_lean` | 9 216 | 9 472 | +//! | `latex` | 13 312 | 13 824 | +//! | `unicode_str` | 15 360 | 15 872 | +//! | `str` / `repr` | 23 552 | 24 576 | +//! +//! [`MAX_EXPR_DEPTH`] is set below the worst of those with room to spare, so +//! that every consumer refuses before any of them overflows, and one number +//! covers all of them instead of each walker carrying its own. +//! +//! The ceiling is calibrated for the **shipped release build on an 8 MiB +//! stack**, which is what a Python caller gets on the main thread. A debug +//! build has frames several times larger, and a `cargo test` worker or a Rayon +//! worker has a 2 MiB stack, so those configurations can still overflow below +//! this limit; a test that means to reach the cap should run on a thread it +//! sized itself. (`simplify_par` already handles the Rayon case by hopping to +//! a thread with a stack it sized itself — see `simplify::parallel`, which is +//! only compiled with the `parallel` feature.) +//! +//! # How it is enforced +//! +//! [`ExprPool`] caches each node's depth at intern time, so +//! [`check_expr_depth`] is a single array read and an integer compare. That +//! matters: the guard sits on hot paths such as `__str__`, and anything that +//! had to walk the tree to find its depth would cost more than it saves. +//! +//! # What a caller should do about a refusal +//! +//! [`DepthLimitError`] is a normal, catchable, coded error (`E-DEPTH-001`). +//! Rebuild the expression with less nesting — a balanced `Add` of 100 000 terms +//! has depth 2, while the same terms accumulated one at a time with `+` have +//! depth 100 000 — or split the work into subexpressions. + +use crate::errors::AlkahestError; +use crate::kernel::{ExprId, ExprPool}; +use std::fmt; + +/// Deepest expression any recursive consumer will accept. +/// +/// See the module documentation for the measurements behind this number. The +/// shallowest walker to fall over did so at depth 4 687 on an 8 MiB stack, so +/// this leaves a factor of ~2.3 for stacks that already have frames on them, +/// for debug builds (whose frames are several times larger than release ones), +/// and for future walkers that use more stack per level than today's. +/// +/// It is deliberately *one* number rather than a per-operation table: a caller +/// that gets `str(expr)` to work should not then be surprised by a segfault +/// from `symbolic_grad(expr)`, and a walker added later inherits the guard +/// instead of having to remember to measure itself. +pub const MAX_EXPR_DEPTH: u32 = 2048; + +/// An expression was too deeply nested to be processed by recursion. +/// +/// Returned rather than risking a stack overflow; see the [module +/// documentation](self). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepthLimitError { + /// Depth of the offending expression, saturating at [`u32::MAX`]. + pub depth: u32, + /// The ceiling that was exceeded — always [`MAX_EXPR_DEPTH`] today. + pub limit: u32, +} + +impl fmt::Display for DepthLimitError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "expression nesting depth {} exceeds the limit of {}; \ + recursing over it would overflow the stack", + self.depth, self.limit + ) + } +} + +impl std::error::Error for DepthLimitError {} + +impl AlkahestError for DepthLimitError { + fn code(&self) -> &'static str { + "E-DEPTH-001" + } + + fn remediation(&self) -> Option<&'static str> { + Some("rebuild the expression with less nesting (a balanced n-ary Add is shallow where a chain of binary ones is not), or process it in smaller pieces") + } +} + +/// Refuse `id` if recursing over it would risk a stack overflow. +/// +/// O(1) — the depth was cached when `id` was interned. Call this at the entry +/// point of anything that walks an expression recursively; see the [module +/// documentation](self) for why. +/// +/// ``` +/// use alkahest_cas::kernel::depth::{check_expr_depth, MAX_EXPR_DEPTH}; +/// use alkahest_cas::kernel::{Domain, ExprPool}; +/// +/// let pool = ExprPool::new(); +/// let x = pool.symbol("x", Domain::Real); +/// assert!(check_expr_depth(&pool, x).is_ok()); +/// +/// let mut deep = x; +/// for _ in 0..MAX_EXPR_DEPTH { +/// deep = pool.func("sin", vec![deep]); +/// } +/// let err = check_expr_depth(&pool, deep).unwrap_err(); +/// assert_eq!(err.limit, MAX_EXPR_DEPTH); +/// ``` +pub fn check_expr_depth(pool: &ExprPool, id: ExprId) -> Result<(), DepthLimitError> { + let depth = pool.depth(id); + if depth > MAX_EXPR_DEPTH { + Err(DepthLimitError { + depth, + limit: MAX_EXPR_DEPTH, + }) + } else { + Ok(()) + } +} + +/// Like [`check_expr_depth`] but for a batch of expressions. +/// +/// Reports the first offender, so a caller handed a hundred expressions does +/// not have to find the bad one itself. +pub fn check_expr_depths(pool: &ExprPool, ids: &[ExprId]) -> Result<(), DepthLimitError> { + for &id in ids { + check_expr_depth(pool, id)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::Domain; + + /// Depth is the *longest* root-to-leaf path, and hash-consing must not + /// confuse it: `sin(x) + x` is 3 (Add → Func → Symbol), not 2. + #[test] + fn depth_is_the_longest_path_not_the_shortest() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + assert_eq!(pool.depth(x), 1); + let s = pool.func("sin", vec![x]); + assert_eq!(pool.depth(s), 2); + let sum = pool.add(vec![s, x]); + assert_eq!(pool.depth(sum), 3); + } + + /// A wide expression is shallow; the guard must not confuse size with + /// depth, or `check_expr_depth` would reject perfectly printable inputs. + #[test] + fn width_does_not_count_towards_depth() { + let pool = ExprPool::new(); + let terms: Vec<_> = (0..10_000).map(|i| pool.integer(i)).collect(); + let wide = pool.add(terms); + assert_eq!(pool.depth(wide), 2); + assert!(check_expr_depth(&pool, wide).is_ok()); + } + + /// The same terms accumulated pairwise are deep, and that is exactly the + /// shape that used to segfault every printer. + #[test] + fn a_chain_of_binary_adds_is_refused_past_the_limit() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let mut acc = x; + for i in 0..MAX_EXPR_DEPTH { + let k = pool.integer(i); + acc = pool.add(vec![acc, k]); + } + assert_eq!(pool.depth(acc), MAX_EXPR_DEPTH + 1); + let err = check_expr_depth(&pool, acc).expect_err("one past the limit must be refused"); + assert_eq!(err.depth, MAX_EXPR_DEPTH + 1); + assert_eq!(err.code(), "E-DEPTH-001"); + } + + /// Exactly at the limit is accepted — the boundary is inclusive, so the + /// documented number is the deepest expression that still works. + #[test] + fn the_limit_itself_is_accepted() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let mut acc = x; + for _ in 1..MAX_EXPR_DEPTH { + acc = pool.func("sin", vec![acc]); + } + assert_eq!(pool.depth(acc), MAX_EXPR_DEPTH); + assert!(check_expr_depth(&pool, acc).is_ok()); + } + + #[test] + fn batch_check_reports_the_first_offender() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let mut deep = x; + for _ in 0..=MAX_EXPR_DEPTH { + deep = pool.func("sin", vec![deep]); + } + assert!(check_expr_depths(&pool, &[x, x]).is_ok()); + assert!(check_expr_depths(&pool, &[x, deep, x]).is_err()); + } +} diff --git a/alkahest-core/src/kernel/mod.rs b/alkahest-core/src/kernel/mod.rs index ac263dcb..f3d54925 100644 --- a/alkahest-core/src/kernel/mod.rs +++ b/alkahest-core/src/kernel/mod.rs @@ -1,3 +1,4 @@ +pub mod depth; pub mod display; pub mod domain; pub mod eval_const; @@ -9,6 +10,7 @@ pub mod pool_persist; mod proptests; pub mod subs; +pub use depth::{check_expr_depth, check_expr_depths, DepthLimitError, MAX_EXPR_DEPTH}; pub use display::{render_latex, render_unicode}; pub use domain::Domain; pub use eval_const::{try_expr_f64, try_predicate_bool, try_predicate_bool_from_expr}; diff --git a/alkahest-core/src/kernel/pool.rs b/alkahest-core/src/kernel/pool.rs index 40e6e48c..a45dd2fb 100644 --- a/alkahest-core/src/kernel/pool.rs +++ b/alkahest-core/src/kernel/pool.rs @@ -92,6 +92,18 @@ struct Node { /// here from the children's cached flags — O(arity) — instead of by /// walking the whole subtree on every query. mult_commutative: bool, + /// Length of the longest root-to-leaf path in this subtree; a leaf is 1. + /// + /// Computed exactly like `mult_commutative` — once, at intern time, from + /// the children's cached values — so [`ExprPool::depth`] is a single array + /// read. Recomputing it on demand is not an option: the pool is a DAG, so + /// an unmemoised depth walk is exponential in the sharing, and a memoised + /// one allocates a map per query. Saturating, so a pathological expression + /// pins at `u32::MAX` instead of wrapping to a small value. + /// + /// This is what lets every recursive consumer refuse a too-deep expression + /// in O(1) rather than discovering the problem by overflowing the stack. + depth: u32, } pub struct ExprPool { @@ -153,12 +165,44 @@ impl ExprPool { /// so their flags are just array reads. fn make_node(&self, data: ExprData) -> Node { let mult_commutative = self.compute_mult_commutative(&data); + let depth = self.compute_depth(&data); Node { data, mult_commutative, + depth, } } + /// One level of the depth recurrence: `1 + max(child depths)`, reading each + /// child's cached depth rather than descending into it. + fn compute_depth(&self, data: &ExprData) -> u32 { + let child = |c: ExprId| self.depth(c); + let deepest = match data { + ExprData::Symbol { .. } + | ExprData::Integer(_) + | ExprData::Rational(_) + | ExprData::Float(_) => 0, + ExprData::Add(args) | ExprData::Mul(args) => { + args.iter().copied().map(child).max().unwrap_or(0) + } + ExprData::Pow { base, exp } => child(*base).max(child(*exp)), + ExprData::Func { args, .. } => args.iter().copied().map(child).max().unwrap_or(0), + ExprData::Piecewise { branches, default } => branches + .iter() + .map(|&(c, v)| child(c).max(child(v))) + .max() + .unwrap_or(0) + .max(child(*default)), + ExprData::Predicate { args, .. } => args.iter().copied().map(child).max().unwrap_or(0), + ExprData::Forall { var, body } | ExprData::Exists { var, body } => { + child(*var).max(child(*body)) + } + ExprData::BigO(inner) => child(*inner), + ExprData::RootSum { poly, body, .. } => child(*poly).max(child(*body)), + }; + deepest.saturating_add(1) + } + /// One level of the `mult_tree_is_commutative` recurrence, reading each /// child's cached flag rather than descending into it. fn compute_mult_commutative(&self, data: &ExprData) -> bool { @@ -187,6 +231,18 @@ impl ExprPool { self.node(id).mult_commutative } + /// Length of the longest root-to-leaf path in the subtree rooted at `id`. + /// + /// A leaf (symbol or number) has depth 1. O(1): the value was computed + /// when `id` was interned. Saturates at [`u32::MAX`]. + /// + /// Every recursive consumer of an expression uses this to decline a tree + /// too deep for the stack — see + /// [`crate::kernel::depth::check_expr_depth`]. + pub fn depth(&self, id: ExprId) -> u32 { + self.node(id).depth + } + fn node(&self, id: ExprId) -> &Node { self.nodes .get(id.0 as usize) diff --git a/alkahest-core/src/lib.rs b/alkahest-core/src/lib.rs index 234256d6..1be96c56 100644 --- a/alkahest-core/src/lib.rs +++ b/alkahest-core/src/lib.rs @@ -78,9 +78,10 @@ pub use integrate::{ }; #[allow(deprecated)] pub use kernel::{ - expr_contains_noncommutative_symbol, load_from, mult_tree_is_commutative, open_persistent, - render_latex, render_unicode, save_to, subs, Domain, ExprData, ExprDisplay, ExprId, ExprPool, - IoError, PoolPersistError, + check_expr_depth, check_expr_depths, expr_contains_noncommutative_symbol, load_from, + mult_tree_is_commutative, open_persistent, render_latex, render_unicode, save_to, subs, + DepthLimitError, Domain, ExprData, ExprDisplay, ExprId, ExprPool, IoError, PoolPersistError, + MAX_EXPR_DEPTH, }; pub use logic::{ dpll_sat, formula_from_expr, satisfiable, BoolClause, BoolLit, Formula, LogicError, @@ -269,8 +270,9 @@ pub mod stable { pub use crate::kernel::pool_persist::PoolPersistError; pub use crate::kernel::pool_persist::{load_from, open_persistent, save_to, IoError}; pub use crate::kernel::{ - expr_contains_noncommutative_symbol, mult_tree_is_commutative, render_latex, - render_unicode, subs, Domain, ExprData, ExprDisplay, ExprId, ExprPool, + check_expr_depth, check_expr_depths, expr_contains_noncommutative_symbol, + mult_tree_is_commutative, render_latex, render_unicode, subs, DepthLimitError, Domain, + ExprData, ExprDisplay, ExprId, ExprPool, MAX_EXPR_DEPTH, }; pub use crate::lattice::{ lattice_reduce_rows, lattice_reduce_rows_with_delta, validate_lll_rows, LatticeError, diff --git a/alkahest-core/src/matrix/eigen.rs b/alkahest-core/src/matrix/eigen.rs index 4d852c24..ed978693 100644 --- a/alkahest-core/src/matrix/eigen.rs +++ b/alkahest-core/src/matrix/eigen.rs @@ -19,6 +19,63 @@ use rug::Rational; use std::fmt; use std::sync::atomic::{AtomicUsize, Ordering}; +/// Why [`kernel_column_basis`] could not produce a basis. +/// +/// # Why this is not `()` +/// +/// It used to be. Every caller wrote `map_err(|_| …KernelFailed)`, so by the +/// time the refusal reached a caller of `nullspace` it said only "could not +/// compute nullspace basis" (`E-LINALG-002`) — honest, and useless. The two +/// situations a caller must tell apart are "this matrix is genuinely hard for +/// the kernel routine" and "one entry's vanishing is undecidable, and +/// substituting concrete parameters would fix it" (`E-LINALG-010`); the second +/// is actionable and the first is not. A payload-free error type cannot carry +/// that distinction across the boundary, so it was lost there. +/// +/// Widening it costs nothing on the public API: both this type and +/// [`kernel_column_basis`] are `pub(crate)`. +/// +/// Exhaustive on purpose. Today elimination has exactly one way to fail, and a +/// second one must be a deliberate decision at every call site rather than +/// something a `_ =>` arm quietly absorbs into the vague code again. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KernelFailure { + /// Elimination reached an entry it could prove neither zero nor non-zero, + /// and refused rather than guess a pivot. Carries that entry so the caller + /// can record the refusal against its own error variant — see + /// [`crate::matrix::zero_test`]. + Undecidable(ExprId), +} + +/// What the caller already knows about `det(m)` when asking for its kernel. +/// +/// The 2×2 fast path in [`kernel_column_basis`] returns the perpendicular of a +/// non-vanishing row, which is the kernel only when the determinant vanishes. +/// Deciding that from the entries is undecidable in general (see +/// [`crate::matrix::zero_test`]) — but the callers that hit the hard cases are +/// not actually asking the question, they already know the answer: +/// +/// * `eigenvectors` builds `A − λI` for a λ it just obtained as a **root of the +/// characteristic polynomial**, so `det(A − λI) = 0` is a theorem about the +/// construction. Re-deriving it means asking the simplifier to drive a pile of +/// nested radicals to literal `0`, which it often cannot, and a routine that +/// refused whenever it could not would stop computing perfectly good +/// eigenvectors. +/// * `jordan_form` asks for kernels of `(A − λI)^k`, singular for the same +/// reason. +/// * `nullspace` is handed an arbitrary matrix and knows nothing. +/// +/// Passing the knowledge in keeps the gate strict for the one caller that has to +/// establish singularity, without making the others pay for a question they can +/// already answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KnownSingular { + /// `det(m) = 0` by construction. + Yes, + /// Nothing is known; the routine must prove it or refuse. + No, +} + /// Errors from eigen-decomposition helpers. #[derive(Debug, Clone, PartialEq, Eq)] pub enum EigenError { @@ -533,13 +590,33 @@ pub fn eigenvectors( let mut out = Vec::with_capacity(vals.len()); for (lambda, mult) in vals { let b = m_minus_lambda_scaled(m, lambda, pool); - let vecs = - kernel_column_basis(&b, pool).map_err(|_| EigenError::KernelComputationFailed)?; + // `lambda` came out of `eigenvalues`, i.e. it is a root of the + // characteristic polynomial, so `det(A − λI) = 0` holds by construction. + let vecs = kernel_column_basis(&b, pool, KnownSingular::Yes) + .map_err(|f| kernel_failure_to_eigen(f, pool))?; out.push((lambda, mult, vecs)); } Ok(out) } +/// Report a [`KernelFailure`] in this module's error vocabulary. +/// +/// [`EigenError`] is a public exhaustive enum and cannot grow a variant without +/// a major semver break, so — exactly as +/// [`LinearAlgebraError::UnsupportedField`](crate::matrix::LinearAlgebraError::UnsupportedField) +/// does for elimination, and `MatrixError::SingularMatrix` for a determinant — +/// [`EigenError::KernelComputationFailed`] carries the refusal and the specific +/// cause travels out of band for +/// [`take_zero_test_refusal`](crate::matrix::take_zero_test_refusal). +fn kernel_failure_to_eigen(f: KernelFailure, pool: &ExprPool) -> EigenError { + match f { + KernelFailure::Undecidable(e) => { + zero_test::record_refusal(pool, e, zero_test::RefusalSite::Pivot); + EigenError::KernelComputationFailed + } + } +} + /// Returns `(P, D)` with `M·P == P·D` (same convention as SymPy: columns of `P` are eigenvectors). pub fn diagonalize(m: &Matrix, pool: &ExprPool) -> Result<(Matrix, Matrix), EigenError> { let evecs = eigenvectors(m, pool)?; @@ -560,8 +637,14 @@ pub fn diagonalize(m: &Matrix, pool: &ExprPool) -> Result<(Matrix, Matrix), Eige if cols.len() != n { return Err(EigenError::NonDiagonalizable); } - let p_mat = - concatenate_columns(&cols, pool).map_err(|_| EigenError::KernelComputationFailed)?; + // `KernelComputationFailed` for its *original* meaning — the eigenvector + // columns do not assemble into a matrix. Clear any zero-test refusal left + // on this thread so this error cannot inherit its `E-LINALG-010`; see + // [`zero_test::forget_refusal`]. + let p_mat = concatenate_columns(&cols, pool).map_err(|_| { + zero_test::forget_refusal(); + EigenError::KernelComputationFailed + })?; // Verify full rank geometrically via det / invertibility later let d_mat = diagonal_from_entries(&diag_entries, pool); if !columns_match_eigen_relation(m, &p_mat, pool, &diag_entries) { @@ -904,16 +987,15 @@ pub(crate) fn m_minus_lambda_scaled(m: &Matrix, lambda: ExprId, pool: &ExprPool) // Nullspace // --------------------------------------------------------------------------- -fn kernel_2x2_column_basis(m: &Matrix, pool: &ExprPool) -> Option> { +fn kernel_2x2_column_basis( + m: &Matrix, + pool: &ExprPool, + singular: KnownSingular, +) -> Option> { let a00 = simplify(m.get(0, 0), pool).value; let b01 = simplify(m.get(0, 1), pool).value; let c10 = simplify(m.get(1, 0), pool).value; let d11 = simplify(m.get(1, 1), pool).value; - // Full-rank gate for numeric/rational matrices: if det is a nonzero - // constant then the kernel is trivial. Do *not* use an `M·v = 0` check on - // the candidate perpendicular — for symbolic `(A − λI)` that residual only - // vanishes after substituting an eigenvalue, so the check would wrongly - // drop legitimate eigenspace bases. let det = simplify( pool.add(vec![ pool.mul(vec![a00, d11]), @@ -922,6 +1004,11 @@ fn kernel_2x2_column_basis(m: &Matrix, pool: &ExprPool) -> Option> { pool, ) .value; + + // A determinant that is *literally* a non-zero constant means a trivial + // kernel, whatever the caller believes about this matrix. Kept ahead of + // everything else so a caller that wrongly claims singularity gets the + // honest empty basis rather than a fabricated vector. let det_nonzero_const = match pool.get(det) { ExprData::Integer(n) => n.0 != 0, ExprData::Rational(r) => r.0 != 0, @@ -930,6 +1017,40 @@ fn kernel_2x2_column_basis(m: &Matrix, pool: &ExprPool) -> Option> { if det_nonzero_const { return Some(Vec::new()); } + + // Everything below returns the perpendicular of a non-vanishing row, which + // is the kernel **only when `det = 0`**. That is a fact about the matrix, so + // something has to establish it. + // + // This gate used to be `det_nonzero_const` alone: a non-literal determinant + // fell straight through, i.e. "could not prove `det ≠ 0`" was read as + // "`det = 0`". `Matrix([[x, 0], [0, 1]]).nullspace()` therefore returned the + // 1-dimensional basis `(0, x)` — for which `M·v = (0, x)` — while `rank()` + // on the same matrix correctly said 2, so one call violated rank–nullity + // against the other with no exception and no flag. It is the exact mirror of + // the `rref` defect that motivated `zero_test`: that one read *unknown* as + // non-zero, this one read *unknown* as zero. + // + // The old comment warned against checking `M·v = 0` on the candidate, + // because for a symbolic `A − λI` that residual only vanishes once λ is + // substituted — a real hazard, and the reason the check cannot simply be + // tightened. The way out is to ask a more precise question rather than a + // weaker one: on the eigen path `det(A − λI) = 0` holds *by construction*, + // λ being a root of the characteristic polynomial, so the caller states it + // ([`KnownSingular::Yes`]) instead of the simplifier trying to rediscover it + // from a pile of radicals. Only a caller that genuinely does not know — + // `nullspace` on an arbitrary matrix — pays for the zero test. + if singular == KnownSingular::No { + match zero_test::zero_status(pool, det) { + // Generically invertible: the same verdict `rank` pivots on. + zero_test::ZeroStatus::NonZero => return Some(Vec::new()), + // Provably singular: the perpendicular below really is the kernel. + zero_test::ZeroStatus::Zero => {} + // Undecided. Hand it to the general Gaussian path, which refuses + // with `E-LINALG-010` rather than picking one of the two answers. + zero_test::ZeroStatus::Unknown => return None, + } + } let neg_one = pool.integer(-1_i32); // The perpendicular `(−b, a)` is taken from a row that is *not* the zero // row; taking it from a vanishing row would return `(0, 0)`, which is not a @@ -974,9 +1095,13 @@ fn row_is_nonvanishing(pool: &ExprPool, x: ExprId, y: ExprId) -> Option { None } -pub(crate) fn kernel_column_basis(m: &Matrix, pool: &ExprPool) -> Result, ()> { +pub(crate) fn kernel_column_basis( + m: &Matrix, + pool: &ExprPool, + singular: KnownSingular, +) -> Result, KernelFailure> { if m.rows == 2 && m.cols == 2 { - if let Some(bas) = kernel_2x2_column_basis(m, pool) { + if let Some(bas) = kernel_2x2_column_basis(m, pool, singular) { return Ok(bas); } } @@ -1409,7 +1534,7 @@ fn qi_nullspace_basis( // --- Expr Gaussian fallback --- -fn gauss_nullspace_expr(m: &Matrix, pool: &ExprPool) -> Result>, ()> { +fn gauss_nullspace_expr(m: &Matrix, pool: &ExprPool) -> Result>, KernelFailure> { let rows = m.rows; let cols = m.cols; let mut a: Vec> = (0..rows) @@ -1431,7 +1556,7 @@ fn gauss_nullspace_expr(m: &Matrix, pool: &ExprPool) -> Result>, // zero. An undecided entry aborts: reporting the wrong pivot column // here silently changes the dimension of the nullspace. let mut prow = None; - let mut undecided = false; + let mut undecided: Option = None; for rr in r_at..rows { let e = simplify(a[rr][c], pool).value; match zero_test::zero_status(pool, e) { @@ -1440,11 +1565,13 @@ fn gauss_nullspace_expr(m: &Matrix, pool: &ExprPool) -> Result>, break; } zero_test::ZeroStatus::Zero => a[rr][c] = pool.integer(0_i32), - zero_test::ZeroStatus::Unknown => undecided = true, + // Keep the *first* undecided entry: it is the one a caller has + // to make decidable, and it is deterministic. + zero_test::ZeroStatus::Unknown => undecided = undecided.or(Some(e)), } } - if prow.is_none() && undecided { - return Err(()); + if let (None, Some(e)) = (prow, undecided) { + return Err(KernelFailure::Undecidable(e)); } let Some((pr, piv)) = prow else { continue }; if pr != r_at { @@ -1501,6 +1628,15 @@ fn gauss_nullspace_expr(m: &Matrix, pool: &ExprPool) -> Result>, /// Retained only for the coefficient accumulator, where the slot being tested /// is a literal `0` this function itself put there. Every site that decides a /// *pivot* uses `zero_test::zero_status` instead. +/// Whether `e` is the *literal* zero constant. +/// +/// The `_ => false` arm is the same shape as the determinant gate above — "not a +/// literal, so assume the other case" — but here nothing mathematical rides on +/// it. Its only caller decides between writing `rest` and `slot + rest` into a +/// coefficient slot, and `0 + rest` equals `rest`; a wrong answer costs an extra +/// `Add` node and nothing else. Recorded so the next reader auditing this +/// pattern does not have to work that out twice, or "fix" it into a zero test +/// that would cost real time on every coefficient. fn expr_is_exactly_zero(pool: &ExprPool, e: ExprId) -> bool { match pool.get(e) { ExprData::Integer(n) => n.0 == 0, diff --git a/alkahest-core/src/matrix/linear_algebra.rs b/alkahest-core/src/matrix/linear_algebra.rs index 966cda69..d40b5876 100644 --- a/alkahest-core/src/matrix/linear_algebra.rs +++ b/alkahest-core/src/matrix/linear_algebra.rs @@ -6,7 +6,7 @@ use crate::kernel::{Domain, ExprData, ExprId, ExprPool}; use crate::matrix::eigen::{ self, characteristic_polynomial_lambda_minus_m, concatenate_columns, kernel_column_basis, - m_minus_lambda_scaled, + m_minus_lambda_scaled, KernelFailure, KnownSingular, }; use crate::matrix::normal_form::{smith_form_poly, PolyMatrixQ, RatUniPoly}; use crate::matrix::{zero_test, Matrix, MatrixError}; @@ -137,8 +137,36 @@ impl crate::errors::AlkahestError for LinearAlgebraError { // --------------------------------------------------------------------------- /// Basis of the nullspace (kernel) of `m`, as column vectors. +/// +/// # Errors +/// +/// [`LinearAlgebraError::UnsupportedField`] when elimination reached an entry +/// whose vanishing it could not decide — the same refusal [`rank`] and [`rref`] +/// make, carrying the specific `E-LINALG-010` through +/// [`take_zero_test_refusal`](crate::matrix::take_zero_test_refusal). It used +/// to be reported as the generic [`LinearAlgebraError::KernelFailed`], which +/// told a caller nothing about the one remediation that works (substitute +/// concrete values for the parameters). pub fn nullspace_basis(m: &Matrix, pool: &ExprPool) -> Result, LinearAlgebraError> { - kernel_column_basis(m, pool).map_err(|()| LinearAlgebraError::KernelFailed) + // An arbitrary matrix: nothing is known about its determinant, so the 2×2 + // fast path has to establish singularity or refuse. See [`KnownSingular`]. + kernel_column_basis(m, pool, KnownSingular::No).map_err(|f| kernel_failure_to_error(f, pool)) +} + +/// Report a [`KernelFailure`] in this module's error vocabulary. +/// +/// The whole point of [`KernelFailure`] carrying a payload: the undecided entry +/// survives the boundary, so the refusal keeps its own `E-LINALG-010` instead of +/// collapsing into [`LinearAlgebraError::KernelFailed`]'s +/// "could not compute nullspace basis". +/// +/// [`LinearAlgebraError::KernelFailed`] is *not* a carrier — it has ~30 call +/// sites and no way to tell which one a stale thread-local refusal belongs to, +/// so a genuine kernel failure can never pick up this code by accident. +fn kernel_failure_to_error(f: KernelFailure, pool: &ExprPool) -> LinearAlgebraError { + match f { + KernelFailure::Undecidable(e) => inconclusive(pool, e), + } } /// Rank of `m`. @@ -625,9 +653,10 @@ pub fn jordan_form(m: &Matrix, pool: &ExprPool) -> Result<(Matrix, Matrix), Line pow = pow .mul(&shifted, pool) .map_err(|_| LinearAlgebraError::KernelFailed)?; + // `pow` is a power of `A − λI` for an eigenvalue λ, hence singular. ker_dims.push( - kernel_column_basis(&pow, pool) - .map_err(|_| LinearAlgebraError::KernelFailed)? + kernel_column_basis(&pow, pool, KnownSingular::Yes) + .map_err(|f| kernel_failure_to_error(f, pool))? .len(), ); } @@ -650,8 +679,8 @@ pub fn jordan_form(m: &Matrix, pool: &ExprPool) -> Result<(Matrix, Matrix), Line .mul(&shifted, pool) .map_err(|_| LinearAlgebraError::KernelFailed)?; } - let bas = - kernel_column_basis(&nk, pool).map_err(|_| LinearAlgebraError::KernelFailed)?; + let bas = kernel_column_basis(&nk, pool, KnownSingular::Yes) + .map_err(|f| kernel_failure_to_error(f, pool))?; let v_top = bas.last().ok_or(LinearAlgebraError::KernelFailed)?.clone(); let mut chain = vec![v_top.clone()]; let mut cur = v_top; @@ -1582,6 +1611,225 @@ mod tests { assert_eq!(refusal.code(), "E-MAT-004"); } + /// The 2×2 matrix whose only non-zero entry nothing can decide. + /// + /// Its nullspace is a real question — it is 1- or 2-dimensional depending + /// on whether `mystery(x)` vanishes identically — so answering it at all + /// would be a guess. + fn undecidable_matrix(p: &ExprPool) -> Matrix { + let x = p.symbol("x", Domain::Real); + let opaque = p.func("mystery", vec![x]); + let zero = p.integer(0_i32); + Matrix::new(vec![vec![opaque, zero], vec![zero, zero]]).unwrap() + } + + /// `nullspace` used to flatten this into `KernelFailed` / `E-LINALG-002` + /// ("could not compute nullspace basis"), which cannot be told apart from a + /// matrix that is merely hard. + #[test] + fn nullspace_reports_the_specific_undecidable_entry() { + use crate::errors::AlkahestError; + let p = pool(); + let err = nullspace_basis(&undecidable_matrix(&p), &p) + .expect_err("an undecidable pivot must refuse"); + assert!( + matches!(err, LinearAlgebraError::UnsupportedField), + "expected the zero-test carrier variant, got {err:?}" + ); + let refusal = crate::matrix::take_zero_test_refusal() + .expect("the refusal must be recoverable, or the specific code is lost"); + assert_eq!(refusal.code(), "E-LINALG-010"); + assert!( + refusal.entry().contains("mystery"), + "refusal should name the undecided entry, got {}", + refusal.entry() + ); + } + + /// `jordan_form` reaches the same elimination and must report the same + /// thing: it is the undecided entry that stops it, not the Jordan search. + #[test] + fn jordan_form_reports_the_specific_undecidable_entry() { + use crate::errors::AlkahestError; + let p = pool(); + let err = + jordan_form(&undecidable_matrix(&p), &p).expect_err("an undecidable pivot must refuse"); + assert!( + matches!(err, LinearAlgebraError::UnsupportedField), + "expected the zero-test carrier variant, got {err:?}" + ); + let refusal = crate::matrix::take_zero_test_refusal() + .expect("the refusal must be recoverable, or the specific code is lost"); + assert_eq!(refusal.code(), "E-LINALG-010"); + } + + /// `eigenvects` shares the same kernel routine; the refusal must survive + /// that boundary too rather than become the vague `E-EIGEN-006`. + #[test] + fn eigenvectors_report_the_specific_undecidable_entry() { + use crate::errors::AlkahestError; + let p = pool(); + let err = eigen::eigenvectors(&undecidable_matrix(&p), &p) + .expect_err("an undecidable pivot must refuse"); + assert_eq!(err, eigen::EigenError::KernelComputationFailed); + let refusal = crate::matrix::take_zero_test_refusal() + .expect("the refusal must be recoverable, or the specific code is lost"); + assert_eq!(refusal.code(), "E-LINALG-010"); + } + + /// A refusal recorded by `nullspace` must not be picked up by the next + /// unrelated error — the reason `KernelFailed` was left alone as a carrier + /// (~30 call sites, no way to tell which one a stale refusal belongs to). + #[test] + fn a_nullspace_refusal_is_not_re_attributed_to_a_later_error() { + let p = pool(); + // Refuse once and leave the refusal on the thread: a Rust caller that + // never consults it is exactly how a stale one gets there. + assert!(nullspace_basis(&undecidable_matrix(&p), &p).is_err()); + // Now an error whose cause is *proven*, not undecided: det = 0 exactly. + let singular = Matrix::new(vec![ + vec![p.integer(1_i32), p.integer(2_i32)], + vec![p.integer(2_i32), p.integer(4_i32)], + ]) + .unwrap(); + assert_eq!( + matrix_inverse(&singular, &p), + Err(MatrixError::SingularMatrix) + ); + assert_eq!( + crate::matrix::take_zero_test_refusal(), + None, + "a proven singularity must not inherit the nullspace refusal's code" + ); + } + + /// `M·v = 0` for every returned basis vector, checked symbolically. + fn kernel_vectors_are_annihilated(m: &Matrix, basis: &[Matrix], p: &ExprPool) -> bool { + basis.iter().all(|v| { + let prod = m.mul(v, p).expect("M·v"); + (0..prod.rows).all(|r| { + zero_test::zero_status(p, simplify(prod.get(r, 0), p).value) + == zero_test::ZeroStatus::Zero + }) + }) + } + + /// A symbolic determinant that cannot be decided must not be *assumed* zero. + /// + /// The 2×2 fast path returns the perpendicular of a non-vanishing row, which + /// is the kernel only when `det = 0`. Its full-rank gate only fired for a + /// literal non-zero constant, so any non-literal determinant fell through + /// into the rank-1 answer — "could not prove `det ≠ 0`" read as + /// "`det = 0`", the mirror of the `rref` defect that motivated `zero_test`. + #[test] + fn nullspace_refuses_an_undecidable_determinant() { + use crate::errors::AlkahestError; + let p = pool(); + let x = p.symbol("x", Domain::Real); + let opaque = p.func("mystery", vec![x]); + // det = mystery(x): neither provably zero nor provably non-zero. + let m = Matrix::new(vec![ + vec![opaque, p.integer(1_i32)], + vec![p.integer(0_i32), p.integer(1_i32)], + ]) + .unwrap(); + let err = nullspace_basis(&m, &p) + .expect_err("an undecidable determinant must refuse, not return the det=0 answer"); + assert!(matches!(err, LinearAlgebraError::UnsupportedField)); + let refusal = crate::matrix::take_zero_test_refusal().expect("recoverable refusal"); + assert_eq!(refusal.code(), "E-LINALG-010"); + // And it agrees with `rank`, which already refused this matrix. + assert!(rank(&m, &p).is_err()); + let _ = crate::matrix::take_zero_test_refusal(); + } + + /// A *decidable* non-zero determinant means a trivial kernel — and `rank` + /// and `nullspace` must not contradict each other. + /// + /// `[[x, 0], [0, 1]]` needs no exotic function: `rank` said 2 while + /// `nullspace` returned the 1-dimensional `(0, x)`, for which + /// `M·v = (0, x) ≠ 0`. Two public calls, 2 + 1 = 3 for a 2-column matrix. + #[test] + fn a_generically_invertible_symbolic_matrix_has_a_trivial_kernel() { + let p = pool(); + let x = p.symbol("x", Domain::Real); + for m in [ + Matrix::new(vec![ + vec![x, p.integer(0_i32)], + vec![p.integer(0_i32), p.integer(1_i32)], + ]) + .unwrap(), + Matrix::new(vec![ + vec![x, p.integer(1_i32)], + vec![p.integer(0_i32), p.integer(1_i32)], + ]) + .unwrap(), + Matrix::new(vec![vec![x, p.integer(0_i32)], vec![p.integer(0_i32), x]]).unwrap(), + ] { + let basis = nullspace_basis(&m, &p).expect("a generic determinant is decidable"); + assert!( + basis.is_empty(), + "det is generically non-zero, so the kernel is trivial; got {} vector(s)", + basis.len() + ); + // rank + nullity = number of columns, across the two public calls. + assert_eq!(rank(&m, &p).unwrap() + basis.len(), m.cols); + } + } + + /// The control that keeps the fix from being "refuse everything": a matrix + /// that really is singular must still hand back a kernel, and the vectors + /// must actually be annihilated. + #[test] + fn a_genuinely_singular_symbolic_matrix_still_returns_its_kernel() { + let p = pool(); + let x = p.symbol("x", Domain::Real); + for m in [ + Matrix::new(vec![vec![x, x], vec![x, x]]).unwrap(), + Matrix::new(vec![ + vec![p.integer(1_i32), p.integer(1_i32)], + vec![p.integer(1_i32), p.integer(1_i32)], + ]) + .unwrap(), + // Rank 1 with a transcendental relation the zero test can prove: + // row 2 = exp(a)·row 1. + { + let a = p.symbol("a", Domain::Real); + let ea = p.func("exp", vec![a]); + Matrix::new(vec![ + vec![p.integer(1_i32), ea], + vec![ea, p.mul(vec![ea, ea])], + ]) + .unwrap() + }, + ] { + let basis = nullspace_basis(&m, &p).expect("a provably singular matrix has a kernel"); + assert_eq!(basis.len(), 1, "rank-1 2×2 has a 1-dimensional kernel"); + assert!( + kernel_vectors_are_annihilated(&m, &basis, &p), + "returned basis vector is not in the kernel" + ); + assert_eq!(rank(&m, &p).unwrap() + basis.len(), m.cols); + } + } + + /// The control: a nullspace the routine *can* compute must leave nothing + /// behind for a later error to inherit. + #[test] + fn a_computable_nullspace_records_no_refusal() { + let p = pool(); + let a = p.symbol("a", Domain::Real); + let exp_a = p.func("exp", vec![a]); + // Rank 1: row 2 is exp(a) times row 1, and the zero test can prove it. + let m = Matrix::new(vec![ + vec![p.integer(1_i32), exp_a], + vec![exp_a, p.mul(vec![exp_a, exp_a])], + ]) + .unwrap(); + assert_eq!(nullspace_basis(&m, &p).unwrap().len(), 1); + assert_eq!(crate::matrix::take_zero_test_refusal(), None); + } + #[test] fn rref_2x3_rational() { let p = pool(); diff --git a/alkahest-core/src/matrix/mod.rs b/alkahest-core/src/matrix/mod.rs index a8bc5792..9e437bc0 100644 --- a/alkahest-core/src/matrix/mod.rs +++ b/alkahest-core/src/matrix/mod.rs @@ -3,7 +3,8 @@ //! Provides a dense `Matrix` of `ExprId` values together with: //! - arithmetic (`+`, `-`, `*`) //! - `transpose()` -//! - `det()` (Bareiss integer-preserving elimination) +//! - `det()` (Bareiss fraction-free elimination when every entry is numeric, +//! cofactor expansion when any entry is symbolic) //! - `jacobian(f_vec, x_vec, pool)` — the `m×n` matrix `∂f_i/∂x_j` use crate::diff::diff; @@ -16,7 +17,7 @@ pub mod linear_algebra; pub mod normal_form; mod smith; mod smith_poly; -mod zero_test; +pub(crate) mod zero_test; pub use eigen::{ characteristic_polynomial_lambda_minus_m, diagonalize, eigenvalues, eigenvectors, EigenError, @@ -294,7 +295,75 @@ impl Matrix { } } - /// Determinant using Bareiss algorithm (exact over integers, symbolic otherwise). + /// Every entry as an exact rational, or `None` if any entry is not a + /// numeric literal. + fn numeric_entries(&self, pool: &ExprPool) -> Option> { + self.data + .iter() + .map(|&e| { + pool.with(e, |d| match d { + crate::kernel::ExprData::Integer(i) => Some(rug::Rational::from(i.0.clone())), + crate::kernel::ExprData::Rational(r) => Some(r.0.clone()), + _ => None, + }) + }) + .collect() + } + + /// Determinant of a matrix whose entries are all numeric literals, by + /// Bareiss fraction-free elimination. + /// + /// `O(n³)` ring operations, against the `O(n!)` of the cofactor expansion + /// in [`det`](Matrix::det) — measured on integer matrices as 2.7 ms at + /// `n = 6`, 148 ms at `n = 8` and 1.42 s at `n = 9` before, against 3.5 ms + /// for SymPy at `n = 9`. The value is exact and identical either way, so + /// this is purely a route change. + fn det_numeric(&self, pool: &ExprPool) -> Option { + let n = self.rows; + let mut m = self.numeric_entries(pool)?; + let at = |i: usize, j: usize| i * n + j; + let mut prev = rug::Rational::from(1); + let mut sign = 1i32; + for k in 0..n.saturating_sub(1) { + if m[at(k, k)] == 0 { + // Pivot: swap in a row below with a nonzero entry in column k. + let Some(r) = (k + 1..n).find(|&r| m[at(r, k)] != 0) else { + return Some(pool.integer(0_i32)); // singular + }; + for j in 0..n { + m.swap(at(k, j), at(r, j)); + } + sign = -sign; + } + for i in k + 1..n { + for j in k + 1..n { + // Bareiss: the division is exact over any integral domain. + let v = (m[at(i, j)].clone() * m[at(k, k)].clone() + - m[at(i, k)].clone() * m[at(k, j)].clone()) + / prev.clone(); + m[at(i, j)] = v; + } + } + prev = m[at(k, k)].clone(); + } + let mut d = m[at(n - 1, n - 1)].clone(); + if sign < 0 { + d = -d; + } + Some(if *d.denom() == 1 { + pool.integer(d.numer().clone()) + } else { + pool.rational(d.numer().clone(), d.denom().clone()) + }) + } + + /// Determinant. + /// + /// All-numeric matrices take the `O(n³)` Bareiss route in + /// `det_numeric` (private); symbolic entries fall back to + /// cofactor expansion along the first row, which is `O(n!)` and is the + /// reason symbolic determinants beyond about `n = 7` are impractical (see + /// `temp-alkahest/testing/3.8-performance-audit.md`). pub fn det(&self, pool: &ExprPool) -> Result { if self.rows != self.cols { return Err(MatrixError::NotSquare); @@ -306,6 +375,11 @@ impl Matrix { if n == 1 { return Ok(self.get(0, 0)); } + if n >= 3 { + if let Some(d) = self.det_numeric(pool) { + return Ok(d); + } + } if n == 2 { // ad - bc let ad = pool.mul(vec![self.get(0, 0), self.get(1, 1)]); @@ -521,6 +595,104 @@ mod tests { ExprPool::new() } + /// The `O(n!)` cofactor expansion the numeric route replaced, kept here as + /// the reference the fast path is checked against. + fn det_cofactor(m: &Matrix, pool: &ExprPool) -> ExprId { + let n = m.rows; + if n == 1 { + return m.get(0, 0); + } + let mut terms: Vec = Vec::new(); + for j in 0..n { + let minor = m.minor(0, j); + let minor_det = det_cofactor(&minor, pool); + let sign = if j % 2 == 0 { + pool.integer(1_i32) + } else { + pool.integer(-1_i32) + }; + terms.push(pool.mul(vec![sign, m.get(0, j), minor_det])); + } + simplify(pool.add(terms), pool).value + } + + #[test] + fn numeric_det_agrees_with_cofactor_expansion() { + let pool = p(); + let mut state = 0x2545_F491_4F6C_DD1D_u64; + let mut rnd = |m: i64| { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1); + ((state >> 33) % (2 * m as u64 + 1)) as i64 - m + }; + for n in 3..=6usize { + for trial in 0..12 { + // Integer entries, plus a rational-entry and a singular case. + let data: Vec = (0..n * n) + .map(|k| match trial % 3 { + 0 => pool.integer(rnd(9)), + 1 => pool.rational(rug::Integer::from(rnd(9)), rug::Integer::from(7)), + _ => pool.integer(if k < n { 0_i32 } else { rnd(9) as i32 }), + }) + .collect(); + let rows: Vec> = data.chunks(n).map(|c| c.to_vec()).collect(); + let m = Matrix::new(rows).expect("square"); + assert_eq!( + m.det(&pool).expect("square"), + det_cofactor(&m, &pool), + "n={n} trial={trial}: numeric Bareiss disagrees with cofactor expansion" + ); + } + } + } + + #[test] + fn numeric_det_handles_a_zero_pivot_and_a_singular_matrix() { + let pool = p(); + // Leading zero pivot but nonsingular: det = -1·(0·1 − 1·1)·… → -1. + let m = Matrix::new(vec![ + vec![ + pool.integer(0_i32), + pool.integer(1_i32), + pool.integer(0_i32), + ], + vec![ + pool.integer(1_i32), + pool.integer(0_i32), + pool.integer(0_i32), + ], + vec![ + pool.integer(0_i32), + pool.integer(0_i32), + pool.integer(1_i32), + ], + ]) + .expect("square"); + assert_eq!(m.det(&pool).unwrap(), pool.integer(-1_i32)); + + // A zero row is singular. + let z = Matrix::new(vec![ + vec![ + pool.integer(0_i32), + pool.integer(0_i32), + pool.integer(0_i32), + ], + vec![ + pool.integer(1_i32), + pool.integer(2_i32), + pool.integer(3_i32), + ], + vec![ + pool.integer(4_i32), + pool.integer(5_i32), + pool.integer(7_i32), + ], + ]) + .expect("square"); + assert_eq!(z.det(&pool).unwrap(), pool.integer(0_i32)); + } + #[test] fn identity_2x2() { let pool = p(); diff --git a/alkahest-core/src/parse.rs b/alkahest-core/src/parse.rs index 1072d783..6bc7905e 100644 --- a/alkahest-core/src/parse.rs +++ b/alkahest-core/src/parse.rs @@ -51,7 +51,7 @@ use crate::kernel::{Domain, ExprId, ExprPool}; pub struct ParseError { pub message: String, pub span: Option<(usize, usize)>, - code_idx: u8, // 1 = E-PARSE-001, 2 = E-PARSE-002, 3 = E-PARSE-003 + code_idx: u8, // 1 = E-PARSE-001, 2 = E-PARSE-002, 3 = E-PARSE-003, 4 = E-PARSE-004 } impl ParseError { @@ -78,6 +78,16 @@ impl ParseError { code_idx: 3, } } + + /// Input nested more deeply than the recursive-descent parser's stack + /// budget allows — see [`MAX_PARSE_DEPTH`]. + fn too_deep(msg: impl Into, span: (usize, usize)) -> Self { + ParseError { + message: msg.into(), + span: Some(span), + code_idx: 4, + } + } } impl std::fmt::Display for ParseError { @@ -97,6 +107,7 @@ impl AlkahestError for ParseError { match self.code_idx { 1 => "E-PARSE-001", 2 => "E-PARSE-002", + 4 => "E-PARSE-004", _ => "E-PARSE-003", } } @@ -105,6 +116,7 @@ impl AlkahestError for ParseError { match self.code_idx { 1 => Some("only ASCII arithmetic expressions are supported"), 2 => Some("check parentheses and operator placement"), + 4 => Some("flatten the expression — deeply nested parentheses, prefix signs or function calls exceed the parser's recursion budget"), _ => Some("use a known function: sin, cos, tan, sec, csc, cot, sinh, cosh, tanh, sech, csch, coth, asin, acos, atan, asinh, acosh, atanh, atan2, exp, log, sqrt, abs, sign, floor, ceil, round, erf, erfc, gamma, lambert_w"), } } @@ -329,11 +341,26 @@ fn reciprocal_base(name: &str) -> Option<&'static str> { // Parser // --------------------------------------------------------------------------- +/// Deepest grammatical nesting [`parse`] will accept. +/// +/// The parser is recursive descent, so `"((((…x…))))"` or `"sin(sin(sin(…)))"` +/// costs native stack frames per level and overflows — a `SIGSEGV`, not an +/// error — long before it runs out of input. This cap is the parser's +/// counterpart to [`crate::kernel::depth::MAX_EXPR_DEPTH`]; it has to be +/// counted separately because the overflow happens *before* any node is +/// interned, so there is no cached node depth to consult yet. +/// +/// Deliberately equal to `MAX_EXPR_DEPTH`: text that parses should be text +/// whose result can then be simplified and printed. +const MAX_PARSE_DEPTH: u32 = crate::kernel::depth::MAX_EXPR_DEPTH; + struct Parser<'a> { tokens: Vec, pos: usize, pool: &'a ExprPool, symbols: &'a mut HashMap, + /// Grammatical nesting depth of the production currently being parsed. + depth: u32, } impl<'a> Parser<'a> { @@ -347,6 +374,7 @@ impl<'a> Parser<'a> { pos: 0, pool, symbols, + depth: 0, } } @@ -383,6 +411,24 @@ impl<'a> Parser<'a> { } fn parse_expr(&mut self, rbp: u8) -> Result { + // Every nested production — a parenthesis, a prefix minus, a function + // argument — re-enters here, so this is the one place that has to count + // to keep the recursion off the end of the stack. + self.depth += 1; + if self.depth > MAX_PARSE_DEPTH { + let offset = self.peek().offset; + self.depth -= 1; + return Err(ParseError::too_deep( + format!("expression nesting exceeds the limit of {MAX_PARSE_DEPTH}"), + (offset, offset + 1), + )); + } + let result = self.parse_expr_inner(rbp); + self.depth -= 1; + result + } + + fn parse_expr_inner(&mut self, rbp: u8) -> Result { let tok = self.advance(); let mut left = self.nud(tok)?; loop { @@ -675,6 +721,77 @@ mod tests { assert_eq!(e, expected); } + /// Refuse `src` and return the code, from a thread with room to reach the + /// cap. + /// + /// [`MAX_PARSE_DEPTH`] is sized for the shipped **release** build on the + /// usual 8 MiB stack. A `cargo test` worker gets 2 MiB and debug frames + /// are several times larger, so a debug run overflows before the cap is + /// reached — the test would then abort the whole runner, which is exactly + /// the outcome this feature exists to prevent. 64 MiB covers both. + fn parse_code_on_big_stack(src: String) -> &'static str { + std::thread::Builder::new() + .stack_size(64 * 1024 * 1024) + .spawn(move || { + let pool = ExprPool::new(); + let mut syms = HashMap::new(); + parse(&src, &pool, &mut syms) + .err() + .map(|e| e.code()) + .unwrap_or("OK") + }) + .expect("spawn") + .join() + .expect("deep parse must return, not overflow the stack") + } + + /// Recursive descent costs native stack frames per nesting level, so + /// `"((((…x…))))"` used to overflow the stack — a `SIGSEGV` that kills the + /// process, with no error for the caller to catch. Just past the limit is + /// used deliberately: a regression must fail this test, not crash the test + /// runner. + #[test] + fn deeply_nested_parentheses_are_refused_not_fatal() { + let n = (MAX_PARSE_DEPTH + 8) as usize; + let src = format!("{}x{}", "(".repeat(n), ")".repeat(n)); + assert_eq!(parse_code_on_big_stack(src), "E-PARSE-004"); + } + + /// Prefix operators and function calls re-enter the same production, so + /// they must be counted too. + #[test] + fn deeply_nested_prefix_and_calls_are_refused() { + let n = (MAX_PARSE_DEPTH + 8) as usize; + assert_eq!( + parse_code_on_big_stack(format!("{}x", "-".repeat(n))), + "E-PARSE-004" + ); + assert_eq!( + parse_code_on_big_stack(format!("{}x{}", "sin(".repeat(n), ")".repeat(n))), + "E-PARSE-004" + ); + } + + /// One level under the cap must still parse, so the limit is a real + /// boundary and not merely "everything deep fails". + #[test] + fn just_under_the_parse_cap_still_parses() { + let n = (MAX_PARSE_DEPTH - 2) as usize; + assert_eq!( + parse_code_on_big_stack(format!("{}x{}", "(".repeat(n), ")".repeat(n))), + "OK" + ); + } + + /// A long *flat* sum is not nesting and must still parse: the cap counts + /// depth, not length. + #[test] + fn a_long_flat_sum_is_not_nesting() { + let (pool, _x, mut syms) = pool_and_x(); + let src = vec!["x"; 20_000].join("+"); + parse(&src, &pool, &mut syms).expect("a flat sum has depth 1 per term"); + } + #[test] fn atan2_two_args() { let pool = ExprPool::new(); diff --git a/alkahest-core/src/poly/real_roots.rs b/alkahest-core/src/poly/real_roots.rs index 0bfc9cdb..cf2b9fce 100644 --- a/alkahest-core/src/poly/real_roots.rs +++ b/alkahest-core/src/poly/real_roots.rs @@ -153,17 +153,17 @@ impl fmt::Display for RootInterval { /// Count sign variations in the non-zero coefficients (Descartes' rule of signs). fn sign_variations(coeffs: &[Integer]) -> usize { - let nonzero: Vec<&Integer> = coeffs.iter().filter(|c| **c != 0).collect(); - if nonzero.len() < 2 { - return 0; - } let mut count = 0; - for w in nonzero.windows(2) { - let pos0 = *w[0] > 0; - let pos1 = *w[1] > 0; - if pos0 != pos1 { + let mut prev: Option = None; + for c in coeffs { + if *c == 0 { + continue; + } + let pos = *c > 0; + if prev.is_some_and(|p| p != pos) { count += 1; } + prev = Some(pos); } count } @@ -174,14 +174,24 @@ fn sign_variations(coeffs: &[Integer]) -> usize { /// `c[j] += c[j+1]`. fn taylor_shift_by_1(coeffs: &[Integer]) -> Vec { let mut c: Vec = coeffs.to_vec(); + taylor_shift_by_1_in_place(&mut c); + c +} + +/// In-place `p(x + 1)`. +/// +/// The accumulation is done through a `split_at_mut` pair rather than +/// `c[j] += c[j + 1].clone()`: the clone allocated and freed a fresh `mpz` on +/// every one of the O(n²) inner steps, which dominated the cost of this +/// function for the small coefficients typical of a VAS frame. +fn taylor_shift_by_1_in_place(c: &mut [Integer]) { let n = c.len(); for i in 0..n.saturating_sub(1) { for j in (i..n - 1).rev() { - let cjp1 = c[j + 1].clone(); - c[j] += cjp1; + let (left, right) = c.split_at_mut(j + 1); + left[j] += &right[0]; } } - c } /// Compute `p(x + k)` for a non-negative integer `k`. @@ -191,10 +201,13 @@ fn taylor_shift_by(coeffs: &[Integer], k: u64) -> Vec { } let mut c = coeffs.to_vec(); let n = c.len(); + let ki = Integer::from(k); for i in 0..n.saturating_sub(1) { for j in (i..n - 1).rev() { - let delta = c[j + 1].clone() * k; - c[j] += delta; + let (left, right) = c.split_at_mut(j + 1); + // Fused multiply-add (`mpz_addmul`); the previous form built and + // dropped a temporary `Integer` on every inner step. + left[j] += &right[0] * &ki; } } c @@ -392,14 +405,72 @@ fn squarefree_part(coeffs: &[Integer]) -> Vec { // VAS CF lower bound // --------------------------------------------------------------------------- +/// Descartes' rule of signs applied to the open interval `(0, k)`. +/// +/// Substituting `x = k·u` maps `(0, k)` onto `(0, 1)`, and the usual test for +/// `(0, 1)` is `(1+t)ⁿ·p(1/(1+t))`, i.e. reverse the coefficients and Taylor +/// shift by 1. The number of roots in `(0, k)` is at most the number of sign +/// variations of the result, so a count of **zero is a proof** that there is +/// no root there. A non-zero count proves nothing either way, which is why +/// the caller only ever uses `true`. +fn no_root_in_open_interval(coeffs: &[Integer], k: u64) -> bool { + if k == 0 { + return true; + } + let n = coeffs.len(); + if n < 2 { + return true; + } + // Build `reverse(p(k·u))` directly: index `i` of the result is + // `coeffs[n−1−i] · k^(n−1−i)`. Scaling and reversing were two separate + // passes, each allocating a full vector of `Integer` clones. + let mut c: Vec = vec![Integer::new(); n]; + let ki = Integer::from(k); + let mut power = Integer::from(1); + for (j, coef) in coeffs.iter().enumerate() { + c[n - 1 - j] = Integer::from(coef * &power); + if j + 1 < n { + power *= &ki; + } + } + // If the reversed, scaled coefficients already have no sign variation then + // `p(k·u)` has no positive root at all, so `(0, k)` is empty and the O(n²) + // shift can be skipped outright. + if sign_variations(&c) == 0 { + return true; + } + taylor_shift_by_1_in_place(&mut c); + sign_variations(&c) == 0 +} + /// Integer lower bound on the smallest positive root of `p`. /// -/// Uses a doubling-then-binary-search over integer evaluation points. +/// Uses a doubling-then-binary-search over integer evaluation points, then +/// **certifies** the candidate with Descartes' rule before returning it. /// Precondition: `p(0) ≠ 0` (no root at the origin). -/// Returns the largest integer `k ≥ 1` such that `p(k)` has the same sign -/// as `p(0)` (implying all positive roots are `> k`), or `0` if the -/// smallest positive root is in `(0, 1]`. -fn cf_lower_bound_floor(coeffs: &[Integer]) -> u64 { +/// Returns an integer `k ≥ 1` for which `p` is proved to have no root in +/// `(0, k)`, or `0` when no such bound could be certified. +/// +/// The sign search alone is not sound, and returning its answer directly is +/// how [`real_roots`] used to lose roots. Its stated rule — "`p(k)` has the +/// same sign as `p(0)`, implying all positive roots are `> k`" — is false: +/// equal signs at `0` and `k` imply an *even* number of roots in `(0, k)`, +/// which may be two rather than none. For `25x³ − 325x² + 804x − 540 = +/// 25(x − 6/5)(x − 9/5)(x − 10)` the polynomial is negative at every integer +/// from 0 to 9, so the search returned `k = 9`, [`isolate_positive_roots`] +/// shifted the frame past both `6/5` and `9/5`, and `real_roots` reported a +/// single root where there are three — with no error and no flag. Chebyshev +/// `T₆` lost four of its six roots the same way. +/// +/// The sign search is kept as a *proposal* (it is cheap and usually right) +/// and halved until it is certified, so the returned bound is sound by +/// construction. +/// +/// `sign_var` must be `sign_variations(coeffs)`, which the caller has already +/// computed. It lets most proposals be certified by a counting argument that +/// costs nothing, leaving the explicit Descartes test — the expensive part — +/// only for `sign_var ≥ 3`. See the comment at the certification step. +fn cf_lower_bound_floor(coeffs: &[Integer], sign_var: usize) -> u64 { if coeffs.is_empty() { return 0; } @@ -463,6 +534,31 @@ fn cf_lower_bound_floor(coeffs: &[Integer]) -> u64 { } } + // Certify the proposal, halving until it is proved sound. `k = 0` is + // trivially certified, so this always terminates with a sound answer. + // + // At this point the search has established, for the proposed `lo ≥ 1`: + // * `p(0)` and `p(lo)` are both non-zero and share a sign, so the number + // of roots in `(0, lo)` counted with multiplicity is **even** — the + // very fact the old code mistook for "zero"; + // * `p(lo+1)` is zero or has the opposite sign, so `(lo, lo+1]` contains + // at least **one** root counted with multiplicity. + // + // Descartes bounds the total number of positive roots, with multiplicity, + // by `sign_var`. So if `(0, lo)` were non-empty it would hold at least two + // roots, and with the one in `(lo, lo+1]` the total would be at least + // three. For `sign_var ≤ 2` that is a contradiction, and the proposal is + // certified with no further work. + // + // Only `sign_var ≥ 3` needs the explicit test, which is exactly the regime + // of the polynomials that used to lose roots: `25x³ − 325x² + 804x − 540` + // and Chebyshev `T₆` both have three sign variations. + if sign_var > 2 { + while lo >= 1 && !no_root_in_open_interval(coeffs, lo) { + lo /= 2; + } + } + lo } @@ -614,7 +710,7 @@ fn isolate_positive_roots(coeffs: Vec) -> Vec { // ---- VAS CF step: shift by integer lower bound k ---------------------- frame.just_deflated = false; // reset flag before bisection - let k = cf_lower_bound_floor(&frame.poly); + let k = cf_lower_bound_floor(&frame.poly, v); if k >= 1 { let new_p = taylor_shift_by(&frame.poly, k); let ki = Integer::from(k); @@ -679,6 +775,148 @@ fn isolate_positive_roots(coeffs: Vec) -> Vec { result } +// --------------------------------------------------------------------------- +// Exact rational-root recovery +// --------------------------------------------------------------------------- + +/// Evaluate `p` at a rational point in **integer** arithmetic, preserving sign +/// and vanishing. +/// +/// For `x = n/d` in canonical form this returns the homogeneous form +/// `H(n, d) = Σ cᵢ·nⁱ·d^(deg−i)`, which is exactly `p(x)·d^deg`. A canonical +/// [`rug::Rational`] has `d > 0`, so `d^deg > 0` and therefore `H` vanishes +/// precisely when `p(x)` does and otherwise carries the same sign. +/// +/// Those two facts — vanishing and sign — are all that +/// [`exact_rational_root`] and [`refine_root`] ever ask of an evaluation, and +/// getting them this way avoids rational arithmetic entirely. The previous +/// `rug::Rational` Horner spent three `mpz` GCD canonicalisations and roughly +/// four allocations *per coefficient*, measured at ~17 800 instructions for a +/// single degree-8 evaluation — enough to make rational-root recovery cost +/// half as much again as the whole of `real_roots`. This form is a plain +/// Horner loop with an `mpz_addmul` and no GCD at all. +fn eval_coeffs_homogeneous(coeffs: &[Integer], x: &rug::Rational) -> Integer { + let n = x.numer(); + let d = x.denom(); + let mut acc = Integer::new(); + // `dp` is `d^k` where `k` counts completed steps, so that the coefficient + // `c_{deg−k}` is scaled by `d^k` exactly as the homogeneous form requires. + let mut dp = Integer::from(1); + let unit_denom = *d == 1; + for c in coeffs.iter().rev() { + acc *= n; + if unit_denom { + acc += c; + } else { + acc += c * &dp; + dp *= d; + } + } + acc +} + +/// Bisection budget for exact rational-root recovery. +/// +/// Each halving is one polynomial evaluation, and the loop stops as soon as +/// the bracket is narrower than `1/lc`, so this ceiling is only reached for a +/// bracket that started astronomically wide relative to the leading +/// coefficient — in which case the interval is left alone and behaviour is +/// exactly what it was before. +const RATIONAL_RECOVERY_BISECTIONS: u32 = 512; + +/// Leading-coefficient size above which recovery is not attempted. +/// +/// The search is over multiples of `1/lc`, so its cost is driven by the size of +/// `lc` rather than by the degree. Beyond this the bracket is returned +/// unchanged: a loose bracket is a weaker answer, never a wrong one. +const RATIONAL_RECOVERY_MAX_LC_BITS: u32 = 128; + +/// Recover the **exact** rational root inside `iv`, when the root is rational. +/// +/// [`RootInterval`] documents that an exact rational root `r` is reported as +/// `lo == hi == r`, and every caller that has to decide something *at* a root +/// — CAD cell sampling, and therefore [`crate::real::cad::decide`] — depends on +/// it: a sample set built from bracket endpoints and midpoints contains only +/// dyadic rationals, so a root like `2/3` is never tested, and a sentence whose +/// truth turns on that point (`∀x. (3x+2)² > 0`) is decided wrong with no +/// indication that anything was skipped. +/// +/// The VAS isolator only delivers `lo == hi` when a root is found exactly at a +/// Möbius endpoint (`t = 0` or `t = 1`), which happens for dyadic roots and not +/// in general. This pass closes the gap. +/// +/// The search is exact, not a heuristic: by the rational-root theorem every +/// rational root of an integer polynomial has denominator dividing the leading +/// coefficient `lc`, so once the bracket is narrower than `1/lc` it contains at +/// most one such rational, and that single candidate is checked by exact +/// evaluation. `None` means "no rational root here", never "probably not". +/// +/// `coeffs` must be **squarefree** — bisection needs the sign change that a +/// simple root guarantees. +fn exact_rational_root(coeffs: &[Integer], iv: &RootInterval) -> Option { + if iv.lo == iv.hi { + return Some(iv.lo.clone()); + } + let lc = coeffs.last()?.clone().abs(); + if lc.is_zero() || lc.significant_bits() > RATIONAL_RECOVERY_MAX_LC_BITS { + return None; + } + + let mut lo = iv.lo.clone(); + let mut hi = iv.hi.clone(); + let v_lo = eval_coeffs_homogeneous(coeffs, &lo); + let v_hi = eval_coeffs_homogeneous(coeffs, &hi); + if v_lo == 0 || v_hi == 0 || (v_lo > 0) == (v_hi > 0) { + // Bisection needs a strict sign change across the bracket. A vanishing + // endpoint is *not* good enough: neighbouring brackets share endpoints, + // so an endpoint root generally belongs to the neighbour, and + // collapsing onto it would silently delete the root this bracket was + // isolating. Leave the bracket alone — a loose bracket is a weaker + // answer, a lost root is a wrong one. + return None; + } + let lo_positive = v_lo > 0; + + // Narrow until at most one multiple of 1/lc can remain inside. + let target = rug::Rational::from((Integer::from(1), lc.clone())); + for _ in 0..RATIONAL_RECOVERY_BISECTIONS { + if hi.clone() - lo.clone() < target { + break; + } + let mid = (lo.clone() + hi.clone()) / rug::Rational::from(2); + let v = eval_coeffs_homogeneous(coeffs, &mid); + if v == 0 { + return Some(mid); + } + if (v > 0) == lo_positive { + lo = mid; + } else { + hi = mid; + } + } + + // Any rational root has denominator dividing `lc`, i.e. is `n/lc` for an + // integer `n`. At most two such points survive a bracket this narrow. + let scaled_lo = lo * rug::Rational::from((lc.clone(), Integer::from(1))); + let scaled_hi = hi * rug::Rational::from((lc.clone(), Integer::from(1))); + let (mut n, _) = scaled_lo + .numer() + .clone() + .div_rem_ceil(scaled_lo.denom().clone()); + let (n_max, _) = scaled_hi + .numer() + .clone() + .div_rem_floor(scaled_hi.denom().clone()); + while n <= n_max { + let candidate = rug::Rational::from((n.clone(), lc.clone())); + if eval_coeffs_homogeneous(coeffs, &candidate) == 0 { + return Some(candidate); + } + n += 1; + } + None +} + // --------------------------------------------------------------------------- // Public entry points // --------------------------------------------------------------------------- @@ -758,6 +996,25 @@ pub fn real_roots(poly: &UniPoly) -> Result, RealRootError> { result.push(RootInterval::new(neg_lo, neg_hi)); } + // Honour the documented contract: an exact rational root is reported as + // `lo == hi == r`. VAS only produces that for roots it happens to land on + // (dyadic ones); `2/3` came back as the bracket `[0, 1]`, and CAD's sample + // set — bracket endpoints and midpoints, all dyadic — then never tests the + // root itself. + // + // Against `working`, not `sq`: `working` is `sq` with the root at the origin + // divided out, and it is the polynomial whose roots these brackets isolate. + // Using `sq` made every bracket with `0` as an endpoint collapse onto the + // origin, which loses a root outright. + for iv in result.iter_mut() { + if iv.lo == iv.hi { + continue; + } + if let Some(r) = exact_rational_root(&working, iv) { + *iv = RootInterval::new(r.clone(), r); + } + } + result.sort_by(|a, b| a.lo.partial_cmp(&b.lo).unwrap_or(std::cmp::Ordering::Equal)); Ok(result) } @@ -784,30 +1041,111 @@ pub fn real_roots_symbolic( real_roots(&poly) } +/// The smallest `f64` strictly greater than `v`, for finite `v ≥ 0`. +fn next_up_nonneg(v: f64) -> f64 { + if !v.is_finite() { + return v; + } + if v == 0.0 { + return f64::from_bits(1); + } + f64::from_bits(v.to_bits() + 1) +} + +/// Smallest `f64` that is `≥ r`, for a non-negative rational `r`. +/// +/// `Rational::to_f64` rounds to nearest, which can land *below* `r` — and a +/// radius rounded down is a ball that does not contain what it claims to. +fn round_up_f64(r: &rug::Rational) -> f64 { + let v = r.to_f64(); + if !v.is_finite() { + return v; + } + match rug::Rational::from_f64(v) { + Some(back) if back >= *r => v, + _ => next_up_nonneg(v), + } +} + +/// Build a ball that provably contains every point of the exact rational +/// interval `[lo, hi]`. +/// +/// Both the midpoint and the radius are `f64`, so both are rounded; the +/// midpoint may round either way, and the radius is therefore measured +/// *against the rounded midpoint* and then rounded **up**. +fn ball_covering(lo: &rug::Rational, hi: &rug::Rational, prec: u32) -> ArbBall { + let mid_rat = rug::Rational::from(lo + hi) / 2u32; + let center = mid_rat.to_f64(); + let Some(center_rat) = rug::Rational::from_f64(center) else { + return ArbBall::infinity(prec.max(53)); + }; + let left = rug::Rational::from(¢er_rat - lo); + let right = rug::Rational::from(hi - ¢er_rat); + let rad_rat = if left > right { left } else { right }; + let radius = if rad_rat <= 0 { + 0.0 + } else { + round_up_f64(&rad_rat) + }; + ArbBall::from_midpoint_radius(center, radius, prec.max(53)) +} + /// Narrow a [`RootInterval`] to at least `prec` bits of precision. /// -/// Uses bisection with floating-point Horner evaluation. For exact roots -/// (`lo == hi`), returns a zero-radius [`ArbBall`]. +/// Bisection is performed in **exact rational arithmetic**, and the returned +/// ball is rounded outwards, so it genuinely contains the root — which is what +/// every caller of a "rigorous enclosure" is entitled to assume. +/// +/// The previous implementation did neither, and both shortcuts were +/// observable. It bisected with an `f64` Horner evaluation, so for +/// `(10⁹x − 1414213562)(x² − 2)` the sign test `f_lo * f_mid <= 0` was wrong at +/// every step, `hi` collapsed onto `lo`, and the result was an *exact* +/// (zero-radius) ball at `1.41421356205…`, which is not a root of anything — +/// the root in that bracket is `√2`. And even when the bracket was right, the +/// ball was built as `mid = (lo+hi)/2`, `rad = (hi-lo)/2` with round-to-nearest +/// on both, so for `x² − 2` it returned `mid = 1.414213562373095`, +/// `rad = 1.11e-16` whose upper end `mid + rad` is still strictly below `√2`: +/// `(mid + rad)² − 2 = −4.06e-17 < 0` in exact arithmetic. `contains(√2)` was +/// `false` for the ball that was supposed to enclose `√2`. +/// +/// For an exact rational root (`lo == hi`) the radius covers the rounding of +/// that rational to `f64`, which is zero only when the rational is itself +/// representable — `6/5` is not. pub fn refine_root(poly: &UniPoly, interval: &RootInterval, prec: u32) -> ArbBall { + let prec = prec.max(53); if interval.lo == interval.hi { - return ArbBall::from_midpoint_radius(interval.lo.to_f64(), 0.0, prec.max(53)); + return ball_covering(&interval.lo, &interval.hi, prec); } - let coeffs_f64: Vec = poly.coefficients().iter().map(|c| c.to_f64()).collect(); - let eval = |x: f64| -> f64 { coeffs_f64.iter().rev().fold(0.0_f64, |acc, &c| acc * x + c) }; - - let target_width = 2.0_f64.powi(-(prec as i32)); - let mut lo = interval.lo.to_f64(); - let mut hi = interval.hi.to_f64(); - let mut f_lo = eval(lo); + let coeffs: Vec = poly.coefficients(); + let mut lo = interval.lo.clone(); + let mut hi = interval.hi.clone(); + let mut f_lo = eval_coeffs_homogeneous(&coeffs, &lo); + if f_lo == 0 { + return ball_covering(&lo, &lo, prec); + } + let f_hi = eval_coeffs_homogeneous(&coeffs, &hi); + if f_hi == 0 { + return ball_covering(&hi, &hi, prec); + } + // Without a strict sign change there is nothing to bisect against; return + // the bracket as given rather than narrowing onto an arbitrary endpoint. + if (f_lo > 0) == (f_hi > 0) { + return ball_covering(&lo, &hi, prec); + } - for _ in 0..300 { - if hi - lo <= target_width { + let target_width = rug::Rational::from((Integer::from(1), Integer::from(1) << prec)); + let steps = (prec as usize + 2).saturating_mul(2).min(4096); + for _ in 0..steps { + if rug::Rational::from(&hi - &lo) <= target_width { break; } - let mid = (lo + hi) / 2.0; - let f_mid = eval(mid); - if f_lo * f_mid <= 0.0 { + let mid = rug::Rational::from(&lo + &hi) / 2u32; + let f_mid = eval_coeffs_homogeneous(&coeffs, &mid); + if f_mid == 0 { + return ball_covering(&mid, &mid, prec); + } + if (f_lo > 0) != (f_mid > 0) { hi = mid; } else { lo = mid; @@ -815,9 +1153,7 @@ pub fn refine_root(poly: &UniPoly, interval: &RootInterval, prec: u32) -> ArbBal } } - let center = (lo + hi) / 2.0; - let radius = (hi - lo) / 2.0; - ArbBall::from_midpoint_radius(center, radius, prec.max(53)) + ball_covering(&lo, &hi, prec) } // --------------------------------------------------------------------------- @@ -1041,6 +1377,88 @@ mod tests { } } + #[test] + fn homogeneous_eval_agrees_with_rational_eval_on_sign_and_zero() { + // The whole point of `eval_coeffs_homogeneous` is that it is a drop-in + // replacement wherever only the sign and the vanishing of `p(x)` are + // consulted. Check that against exact rational evaluation. + let rational_eval = |coeffs: &[Integer], x: &rug::Rational| -> rug::Rational { + let mut acc = rug::Rational::from(0); + for c in coeffs.iter().rev() { + acc *= x; + acc += rug::Rational::from((c.clone(), Integer::from(1))); + } + acc + }; + let polys: [&[i64]; 4] = [ + &[-540, 804, -325, 25], // roots 6/5, 9/5, 10 + &[-1, -1, 0, 0, 0, 0, 0, 0, 1], // x⁸ − x − 1 + &[640, -248, 24], // roots 5, 16/3 + &[1, 0, 1], // no real root + ]; + for p in polys { + let coeffs: Vec = p.iter().map(|v| Integer::from(*v)).collect(); + for num in -25i64..=25 { + for den in 1i64..=12 { + let x = rug::Rational::from((num, den)); + let h = eval_coeffs_homogeneous(&coeffs, &x); + let r = rational_eval(&coeffs, &x); + assert_eq!(h == 0, r == 0, "vanishing disagrees at {x} for {p:?}"); + if h != 0 { + assert_eq!(h > 0, r > 0, "sign disagrees at {x} for {p:?}"); + } + } + } + } + } + + #[test] + fn real_roots_three_rational_roots_kept() { + // 25x³ − 325x² + 804x − 540 = 25(x − 6/5)(x − 9/5)(x − 10). + // + // The polynomial is negative at every integer from 0 to 9, so the + // `cf_lower_bound_floor` sign search proposes k = 9 and shifting by it + // would step past both 6/5 and 9/5. Three sign variations, so the + // proposal is not covered by the counting argument and the explicit + // Descartes certification must reject it down to k = 1. + let poly = make_poly(&[-540, 804, -325, 25]); + let roots = real_roots(&poly).unwrap(); + assert_eq!(roots.len(), 3, "25(x−6/5)(x−9/5)(x−10) has 3 real roots"); + } + + #[test] + fn real_roots_chebyshev_t6_all_six() { + // T₆(x) = 32x⁶ − 48x⁴ + 18x² − 1; 6 roots in (−1, 1). Also three sign + // variations, and used to report only 2. + let poly = make_poly(&[-1, 0, 18, 0, -48, 0, 32]); + let roots = real_roots(&poly).unwrap(); + assert_eq!(roots.len(), 6, "T₆ has 6 real roots"); + for r in &roots { + assert!(r.lo >= -1); + assert!(r.hi <= 1); + } + } + + #[test] + fn cf_lower_bound_is_certified_for_high_sign_variation() { + // Guard the counting argument itself: for the 25x³ polynomial the + // sign search alone proposes 9, and the certified bound must be 1. + let coeffs: Vec = [-540, 804, -325, 25] + .iter() + .map(|v| Integer::from(*v)) + .collect(); + let v = sign_variations(&coeffs); + assert_eq!(v, 3, "this polynomial has three sign variations"); + assert_eq!( + cf_lower_bound_floor(&coeffs, v), + 1, + "certification must reject the uncertified proposal k = 9" + ); + // And the test it relies on agrees: there *is* a root below 9. + assert!(!no_root_in_open_interval(&coeffs, 9)); + assert!(no_root_in_open_interval(&coeffs, 1)); + } + #[test] fn real_roots_chebyshev_t4() { // T₄(x) = 8x⁴ - 8x² + 1; 4 roots in (-1, 1). diff --git a/alkahest-core/src/real/cad.rs b/alkahest-core/src/real/cad.rs index 349d7614..dcdcc151 100644 --- a/alkahest-core/src/real/cad.rs +++ b/alkahest-core/src/real/cad.rs @@ -750,30 +750,61 @@ fn decide_exists_univariate( // Algebraic equality literals are rarely satisfied exactly at purely rational samples; // use isolating intervals of squarefree Eq-polynomial factors with gcd-based Eq checks. + let mut untested_algebraic_boundary = false; for p_focus in eq_polynomials_for_sampling(pool, &phi, var)? { let sf = p_focus.squarefree_part(); if sf.is_zero() { continue; } for iv in real_roots(&sf)? { + if iv.lo != iv.hi { + untested_algebraic_boundary = true; + } if eval_qf_formula_on_iv(pool, var, &phi, &iv, &sf)? { + // The witness is the *root* in `iv`, which is irrational + // whenever the bracket has not collapsed — and the bracket + // midpoint is then not a witness at all. `∃x. 3x − 2 = 0` used + // to come back with `x = 1/2`, which fails the very equation it + // is offered as a solution to. Report a witness only when it + // survives the same check any caller would apply. let mid = iv_midpoint(&iv.lo, &iv.hi); - let mut wm = HashMap::new(); - wm.insert(var, mid); + let witness = if eval_qf_formula(pool, var, &phi, &mid)? { + let mut wm = HashMap::new(); + wm.insert(var, mid); + Some(wm) + } else { + None + }; return Ok(QeResult { truth: true, - witness: Some(wm), + witness, }); } } } + // Nothing satisfied the formula at any sampled point. That is only a *proof* + // of unsatisfiability if the sample set met every cell — including the + // zero-dimensional cells at the roots themselves, which are reachable only + // when the root is an exact rational. With an irrational root and a + // non-strict atom, the one point that could have satisfied the formula was + // never tested, and answering `false` here is how `∀x. (x² − 2)² > 0` came + // back `true`: a machine-checked-looking proof of a false theorem. Refuse. + if untested_algebraic_boundary && body_has_boundary_atom(&phi) { + return Err(CadError::Unsupported(ALGEBRAIC_BOUNDARY_MSG)); + } + Ok(QeResult { truth: false, witness: None, }) } +const ALGEBRAIC_BOUNDARY_MSG: &str = "the formula has a non-strict atom (=, <=, >=) whose only \ + possible solutions are roots of an irrational algebraic number; deciding it needs \ + algebraic-number CAD lifting (full CAD). Refusing rather than reporting an unsatisfiability \ + that was never checked at that point"; + fn decide_closed_qf(pool: &ExprPool, phi: Formula) -> Result { if !free_vars_formula(&phi, pool).is_empty() { return Err(CadError::Unsupported( @@ -919,11 +950,58 @@ fn decide_two_var( } } -fn body_has_eq_or_ne(f: &Formula) -> bool { +/// Does `f` (in NNF) contain an atom whose truth can turn on a single boundary +/// point — `=`, `≤` or `≥`? +/// +/// Strict atoms (`<`, `>`, `≠`) have *open* solution sets: if one is satisfiable +/// at all it is satisfiable on a whole interval, so the open-cell sampling in +/// [`decide_exists_univariate`] is complete for them and a bracket that never +/// lands on a root costs nothing. A non-strict atom can be satisfied at nothing +/// but a root (`x² ≤ 0` holds only at `x = 0`), and then the root itself has to +/// be in the sample set or the search is incomplete. +fn body_has_boundary_atom(f: &Formula) -> bool { match f { - Formula::Atom { kind, .. } => matches!(kind, PredicateKind::Eq | PredicateKind::Ne), - Formula::And(a, b) | Formula::Or(a, b) => body_has_eq_or_ne(a) || body_has_eq_or_ne(b), - Formula::Not(x) => body_has_eq_or_ne(x), + Formula::Atom { kind, .. } => matches!( + kind, + PredicateKind::Eq | PredicateKind::Le | PredicateKind::Ge + ), + Formula::And(a, b) | Formula::Or(a, b) => { + body_has_boundary_atom(a) || body_has_boundary_atom(b) + } + Formula::Not(x) => body_has_boundary_atom(x), + _ => false, + } +} + +/// Does `f` (in NNF) contain an atom that is not strict — `=`, `≠`, `≤` or `≥`? +/// +/// The two-variable analogue of [`body_has_boundary_atom`], and the guard for +/// [`project_and_sample_x`]'s `ambiguous_irrational_root`. `≤`/`≥` are the +/// reason it exists: the original guard tested `=`/`≠` only, so +/// `∃x∃y. (x² − 2)² + y² ≤ 0` — true at `(±√2, 0)` and nowhere else — was +/// answered `false`, and its dual `∀x∀y. (x² − 2)² + y² > 0` came back `true`. +/// That is the *same* completeness gap that +/// [`decide_exists_univariate`] closes one dimension down: an atom whose +/// solution set can be a single boundary point needs that point in the sample +/// set, and no rational sample ever lands on an irrational projection root. +/// +/// `≠` is kept in the set even though its solution set is open (so open-cell +/// sampling is already complete for it): the pre-existing guard refused on it, +/// and loosening a refusal is the one direction in which a change here could +/// introduce an unsound answer. +/// +/// Strict atoms (`<`, `>`) have open solution sets, so the open-cell midpoints +/// are complete for them and no refusal is warranted. +fn body_has_nonstrict_atom(f: &Formula) -> bool { + match f { + Formula::Atom { kind, .. } => matches!( + kind, + PredicateKind::Eq | PredicateKind::Ne | PredicateKind::Le | PredicateKind::Ge + ), + Formula::And(a, b) | Formula::Or(a, b) => { + body_has_nonstrict_atom(a) || body_has_nonstrict_atom(b) + } + Formula::Not(x) => body_has_nonstrict_atom(x), _ => false, } } @@ -1046,7 +1124,7 @@ fn project_and_sample_x( }) } -const IRRATIONAL_ROOT_MSG: &str = "an equality/inequation atom combined with an irrational \ +const IRRATIONAL_ROOT_MSG: &str = "a non-strict atom (=, /=, <=, >=) combined with an irrational \ projection root of the eliminated variable would require algebraic-number CAD lifting \ (full CAD); refusing to guess rather than risk an unsound answer"; @@ -1059,9 +1137,9 @@ const IRRATIONAL_ROOT_MSG: &str = "an equality/inequation atom combined with an /// by [`decide_exists_univariate`] (including its own algebraic-root handling for /// `y`). If no witness is found and every projection root sampled was rational, /// the cell decomposition is complete and `false` is sound. If some projection -/// root is irrational *and* `body` contains an equality/inequation atom, the -/// cell at that exact root cannot be tested rationally, so we report -/// `Unsupported` rather than risk a false negative. +/// root is irrational *and* `body` contains a non-strict atom (see +/// [`body_has_nonstrict_atom`]), the cell at that exact root cannot be tested +/// rationally, so we report `Unsupported` rather than risk a false negative. fn decide_exists_exists( pool: &ExprPool, x: ExprId, @@ -1087,7 +1165,7 @@ fn decide_exists_exists( }); } } - if cells.ambiguous_irrational_root && body_has_eq_or_ne(&body) { + if cells.ambiguous_irrational_root && body_has_nonstrict_atom(&body) { return Err(CadError::Unsupported(IRRATIONAL_ROOT_MSG)); } Ok(QeResult { @@ -1120,7 +1198,7 @@ fn decide_exists_forall( }); } } - if cells.ambiguous_irrational_root && body_has_eq_or_ne(&body) { + if cells.ambiguous_irrational_root && body_has_nonstrict_atom(&body) { return Err(CadError::Unsupported(IRRATIONAL_ROOT_MSG)); } Ok(QeResult { @@ -1280,7 +1358,73 @@ mod tests { }; let r = decide(&f, &p).unwrap(); assert!(r.truth); - assert!(r.witness.is_some()); + // `√2` is not rational, so there is no rational witness to report. This + // used to assert `witness.is_some()` and passed on the isolating + // interval's midpoint — a "solution" of `x² = 2` that is not one. A + // witness is a certificate; a wrong one is worse than none. + assert!(r.witness.is_none()); + } + + /// Every witness `decide` reports must satisfy the sentence it witnesses. + /// + /// `∃x. 3x − 2 = 0` has the rational solution `2/3`. Before exact + /// rational-root recovery in `real_roots`, the isolating bracket stayed at + /// `[0, 1]` and the reported witness was its midpoint `1/2`, which fails the + /// equation outright. + #[test] + fn exists_witness_satisfies_the_equation() { + let p = ExprPool::new(); + let x = p.symbol("x", Domain::Real); + let lhs = p.add(vec![p.mul(vec![p.integer(3_i32), x]), p.integer(-2_i32)]); + let body = p.pred_eq(lhs, p.integer(0_i32)); + let f = Formula::Exists { + var: x, + body: Box::new(formula_from_expr(body, &p).unwrap()), + }; + let r = decide(&f, &p).unwrap(); + assert!(r.truth); + let w = r + .witness + .expect("2/3 is rational, so a witness is reportable"); + assert_eq!(w[&x], rug::Rational::from((2, 3))); + } + + /// `∀x. (3x + 2)² > 0` is **false**: the square vanishes at `x = −2/3`. + /// + /// The CAD sample set is built from bracket endpoints and midpoints, all + /// dyadic, so `−2/3` was never tested and the sentence came back `true` — + /// a proof of a false theorem. Exact rational-root recovery puts the root + /// itself in the sample set. + #[test] + fn forall_square_positive_is_false_at_a_non_dyadic_root() { + let p = ExprPool::new(); + let x = p.symbol("x", Domain::Real); + let inner = p.add(vec![p.mul(vec![p.integer(3_i32), x]), p.integer(2_i32)]); + let body = p.pred_gt(p.pow(inner, p.integer(2_i32)), p.integer(0_i32)); + let f = Formula::Forall { + var: x, + body: Box::new(formula_from_expr(body, &p).unwrap()), + }; + assert!(!decide(&f, &p).unwrap().truth); + } + + /// The same sentence with an *irrational* touching root is refused, not + /// answered. `∀x. (x² − 2)² > 0` is false (at `±√2`), and no rational + /// sample can show it; the honest answer is `Unsupported`. + #[test] + fn forall_square_positive_refuses_at_an_irrational_root() { + let p = ExprPool::new(); + let x = p.symbol("x", Domain::Real); + let inner = p.add(vec![p.pow(x, p.integer(2_i32)), p.integer(-2_i32)]); + let body = p.pred_gt(p.pow(inner, p.integer(2_i32)), p.integer(0_i32)); + let f = Formula::Forall { + var: x, + body: Box::new(formula_from_expr(body, &p).unwrap()), + }; + assert!(matches!( + decide(&f, &p), + Err(CadError::Unsupported(ALGEBRAIC_BOUNDARY_MSG)) + )); } #[test] @@ -1595,4 +1739,120 @@ mod sample_point_completeness_tests { ); assert!(lo > -2, "bracket did not tighten at all"); } + + // ----------------------------------------------------------------------- + // The same completeness gap, two variables up. + // + // `project_and_sample_x` flags an irrational projection root as untested, + // but the flag only escalated to a refusal for `=` / `≠` atoms, so `≤`/`≥` + // still reported an unsatisfiability that was never checked at the one + // point that could have satisfied it. + // ----------------------------------------------------------------------- + + /// `(x² − 2)² + y²`, non-negative and zero exactly at `(±√2, 0)`. + fn touching_at_sqrt_two(pool: &ExprPool, x: ExprId, y: ExprId) -> ExprId { + let inner = pool.add(vec![pool.pow(x, pool.integer(2_i32)), pool.integer(-2_i32)]); + pool.add(vec![ + pool.pow(inner, pool.integer(2_i32)), + pool.pow(y, pool.integer(2_i32)), + ]) + } + + /// `∃x∃y. (x²−2)² + y² ≤ 0` is **true** at `(√2, 0)`; no rational sample + /// can exhibit it, so the only sound answers are `true` and a refusal. + #[test] + fn two_var_nonstrict_boundary_at_an_irrational_root_is_not_denied() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let lhs = touching_at_sqrt_two(&pool, x, y); + let body = atom(PredicateKind::Le, lhs, pool.integer(0_i32)); + let f = Formula::Exists { + var: x, + body: Box::new(Formula::Exists { + var: y, + body: Box::new(body), + }), + }; + match decide(&f, &pool) { + Ok(r) => assert!( + r.truth, + "`exists x exists y. (x^2-2)^2 + y^2 <= 0` is true at (sqrt 2, 0)" + ), + Err(e) => assert_eq!(e.code(), "E-CAD-001"), + } + } + + /// The dual: `∀x∀y. (x²−2)² + y² > 0` is **false**, and a `true` here is a + /// machine-checked-looking proof of a false theorem. + #[test] + fn two_var_universal_over_an_irrational_root_is_not_proved() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let lhs = touching_at_sqrt_two(&pool, x, y); + let body = atom(PredicateKind::Gt, lhs, pool.integer(0_i32)); + let f = Formula::Forall { + var: x, + body: Box::new(Formula::Forall { + var: y, + body: Box::new(body), + }), + }; + match decide(&f, &pool) { + Ok(r) => assert!( + !r.truth, + "`forall x forall y. (x^2-2)^2 + y^2 > 0` is false at (sqrt 2, 0)" + ), + Err(e) => assert_eq!(e.code(), "E-CAD-001"), + } + } + + /// The control that the guard is not a blanket refusal of `≤`: the same + /// polynomial shifted up by 1 is never `≤ 0`, and that `false` is sound + /// because no boundary cell exists at all. + #[test] + fn two_var_nonstrict_unsatisfiable_still_decides_false() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let lhs = pool.add(vec![touching_at_sqrt_two(&pool, x, y), pool.integer(1_i32)]); + let body = atom(PredicateKind::Le, lhs, pool.integer(0_i32)); + let f = Formula::Exists { + var: x, + body: Box::new(Formula::Exists { + var: y, + body: Box::new(body), + }), + }; + let r = decide(&f, &pool).expect("two squares plus one is decidable"); + assert!(!r.truth, "two squares plus 1 is never <= 0"); + } + + /// The control that a *rational* boundary point is still found: the same + /// shape with the double root at `x = 2/3` must come back `true`. + #[test] + fn two_var_nonstrict_boundary_at_a_rational_root_is_found() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let inner = pool.add(vec![ + pool.mul(vec![pool.integer(3_i32), x]), + pool.integer(-2_i32), + ]); + let lhs = pool.add(vec![ + pool.pow(inner, pool.integer(2_i32)), + pool.pow(y, pool.integer(2_i32)), + ]); + let body = atom(PredicateKind::Le, lhs, pool.integer(0_i32)); + let f = Formula::Exists { + var: x, + body: Box::new(Formula::Exists { + var: y, + body: Box::new(body), + }), + }; + let r = decide(&f, &pool).expect("a rational boundary point is reachable"); + assert!(r.truth, "(3x-2)^2 + y^2 <= 0 holds at (2/3, 0)"); + } } diff --git a/alkahest-core/src/simplify/egraph.rs b/alkahest-core/src/simplify/egraph.rs index b33d17dc..fe5e3603 100644 --- a/alkahest-core/src/simplify/egraph.rs +++ b/alkahest-core/src/simplify/egraph.rs @@ -247,6 +247,84 @@ mod backend { } } + /// Whether `expr` has a subterm `b^k` with a negative literal exponent `k` + /// and a base `b` that is *provably* zero — i.e. a division by zero. + /// + /// The e-graph cannot be trusted with such a term. Its `shrink` ruleset + /// contains both `(Mul ?x (Num 0)) → (Num 0)` and + /// `(Mul ?x (Pow ?x (Num -1))) → (Num 1)`, and on `0 · 0⁻¹` *both* fire — + /// so `(Num 0)` and `(Num 1)` are unioned into one e-class and the + /// extractor picks whichever is cheaper. That is not a wrong rewrite that + /// could be patched away: an e-graph has no way to carry the side + /// condition `?x ≠ 0` that makes cancellation valid, and the union it + /// performs is `0 = 1`, which poisons every other e-class in the run. + /// + /// So the whole call is handed to the rule engine instead, which now + /// leaves the undefined product alone. This mirrors the existing + /// non-commutative bail-out immediately above it. + /// + /// The zero test is the three-valued + /// [`crate::matrix::zero_test::zero_status`], so `(x - x)⁻¹` is caught as + /// well as the literal `0⁻¹`; only a *proven* zero bails out, leaving the + /// documented `b · b⁻¹ → 1` convention intact for every base that is not. + /// Its cost is irrelevant here — this runs once per `simplify_egraph` + /// call, against building and saturating an entire egglog program — and + /// literal and symbol bases are settled without calling it at all. + fn has_provably_zero_denominator(expr: ExprId, pool: &ExprPool) -> bool { + let mut visited = std::collections::HashSet::new(); + count_dag_nodes_rec(expr, pool, &mut visited); + visited + .into_iter() + .any(|node| is_zero_denominator(node, pool)) + } + + fn is_zero_denominator(node: ExprId, pool: &ExprPool) -> bool { + let Some((base, exp)) = pool.with(node, |d| match d { + ExprData::Pow { base, exp } => Some((*base, *exp)), + _ => None, + }) else { + return false; + }; + let negative_exponent = pool.with(exp, |d| match d { + ExprData::Integer(n) => n.0 < 0, + ExprData::Rational(r) => r.0 < 0, + ExprData::Float(f) => f.inner < 0.0, + _ => false, + }); + if !negative_exponent { + return false; + } + // Settle the easy bases without the general zero test. + enum Quick { + Zero, + NonZero, + Ask, + } + let quick = pool.with(base, |d| match d { + ExprData::Integer(n) => { + if n.0 == 0 { + Quick::Zero + } else { + Quick::NonZero + } + } + ExprData::Rational(_) | ExprData::Symbol { .. } => Quick::NonZero, + ExprData::Float(f) => { + if f.inner == 0.0 { + Quick::Zero + } else { + Quick::NonZero + } + } + _ => Quick::Ask, + }); + match quick { + Quick::Zero => true, + Quick::NonZero => false, + Quick::Ask => crate::matrix::zero_test::zero_status(pool, base).is_proven_zero(), + } + } + fn egglog_program(expr_str: &str, config: &super::EgraphConfig) -> String { // node_limit is enforced as a pre-saturation DAG-size check in // simplify_egraph_impl; egglog 0.4 does not expose a per-run node cap. @@ -856,6 +934,13 @@ mod backend { return super::super::engine::simplify(expr, pool); } + // `0⁻¹` in the e-graph makes `(Num 0)` and `(Num 1)` the same e-class + // (see `has_provably_zero_denominator`); hand it to the rule engine, + // which leaves the undefined product unevaluated. + if has_provably_zero_denominator(expr, pool) { + return super::super::engine::simplify(expr, pool); + } + // Enforce the node limit before handing the expression to egglog. // Saturation can materialise exponentially many equivalent forms, so a // hard pre-check on input size prevents OOM on pathological inputs. diff --git a/alkahest-core/src/simplify/parallel.rs b/alkahest-core/src/simplify/parallel.rs index 63b63114..b61b59d0 100644 --- a/alkahest-core/src/simplify/parallel.rs +++ b/alkahest-core/src/simplify/parallel.rs @@ -203,19 +203,32 @@ fn with_stack_segment(f: impl FnOnce() -> R + Send) -> R { /// Stack bytes consumed on this thread since the current segment began. /// -/// Uses the address of a local as a stack-depth probe; the first probe on a -/// thread establishes the baseline. Stacks grow downwards on every platform -/// this crate targets, and the subtraction is saturating, so a platform where -/// they do not simply reports 0 and never refills. +/// Uses the address of a local as a stack-depth probe. Stacks grow downwards +/// on every platform this crate targets, so a *smaller* address means deeper. +/// +/// The baseline is re-established whenever the probe lands at or above it. +/// That matters because Rayon reuses its workers: the baseline used to be +/// latched on a thread's first probe and never revisited, so a worker that +/// happened to take its first `simplify_par` task from deep inside a call +/// chain kept that deep address as its baseline forever. Every later task on +/// that worker started *above* the stale baseline, `saturating_sub` floored +/// the difference at 0, and the traversal read its own stack usage as zero no +/// matter how deep it went — so it never refilled, and ran off the end of the +/// worker's 2 MiB stack. A stack overflow aborts the process, which is +/// precisely what this machinery exists to prevent. +/// +/// Re-baselining upwards is always safe: an address above the current +/// baseline means the frames that baseline was measured against have already +/// returned, so it describes a stack that no longer exists. fn stack_used() -> usize { let probe = 0u8; let here = &probe as *const u8 as usize; SEGMENT_BASE.with(|base| { - if base.get() == 0 { + if base.get() == 0 || here >= base.get() { base.set(here); 0 } else { - base.get().saturating_sub(here) + base.get() - here } }) } @@ -590,6 +603,50 @@ mod tests { assert_eq!(par.value, x); } + /// Burn `frames` stack frames, then report `stack_used()` from the bottom. + #[inline(never)] + fn probe_at_depth(frames: u32) -> usize { + // A real local keeps the frame from being optimised to nothing. + let mut pad = [0u8; 256]; + pad[0] = frames as u8; + std::hint::black_box(&pad); + if frames == 0 { + stack_used() + } else { + probe_at_depth(frames - 1) + } + } + + /// `SEGMENT_BASE` used to be latched on a thread's first probe and never + /// revisited. Rayon reuses its workers, so a worker whose first task + /// probed from deep in a call chain kept that deep address forever; every + /// later task started above it, `saturating_sub` floored the result at 0, + /// and the traversal believed it was using no stack however deep it went. + /// It therefore never refilled and eventually overflowed the worker's + /// 2 MiB stack — an abort, not an error. + /// + /// Asserted here rather than by actually overflowing a stack: a + /// regression must fail this test, not kill the test process. + #[test] + fn stack_probe_rebaselines_after_unwinding() { + // Task 1: latch a baseline from deep in a call chain, then unwind. + let deep = probe_at_depth(400); + assert_eq!(deep, 0, "the first probe on a thread establishes the base"); + + // Task 2 on the same (reused) thread, starting near the top of the + // stack. This must re-baseline... + assert_eq!(stack_used(), 0, "a probe above the old base must re-base"); + + // ...so that going deeper than *this* point is now measurable. With + // the stale baseline still in place this read 0, which is exactly the + // under-read that let the traversal run off the end of the stack. + let used = probe_at_depth(64); + assert!( + used > 0, + "stack usage under-read as {used} after re-baselining" + ); + } + /// At a depth both paths can handle, the results must still agree. #[test] fn par_matches_sequential_on_moderate_chain() { diff --git a/alkahest-core/src/simplify/rules.rs b/alkahest-core/src/simplify/rules.rs index 38135e3c..0331229a 100644 --- a/alkahest-core/src/simplify/rules.rs +++ b/alkahest-core/src/simplify/rules.rs @@ -153,6 +153,65 @@ fn is_one(expr: ExprId, pool: &ExprPool) -> bool { integer_is(expr, pool, 1) } +/// Whether `expr` is a literal `0` raised to a literal **negative** power. +/// +/// `0^(-1)` — and every `0^(-n)`, `0^(-p/q)` — is division by zero, so it has +/// no value under any convention. A product containing such a factor is +/// therefore undefined too, and must not be folded to `0` (by `mul_zero` / +/// `const_fold`) or to `1` (by `collect_mul_factors` cancelling the negative +/// exponent against a positive one). `simplify(0^-1)` already leaves the power +/// alone; these guards make the surrounding product agree with it. +/// +/// Only *literal* zero bases are recognised. Deciding whether an arbitrary +/// symbolic base vanishes is what [`crate::matrix::zero_test::zero_status`] +/// is for, and it costs several `ArbBall` evaluations at 128 bits — far too +/// much for a predicate on the hot `Mul` rewrite path. Because the engine +/// simplifies strictly bottom-up (`simplify_children` before the node itself), +/// any base the simplifier *can* reduce to zero — `x - x`, `sin(0)`, +/// `0 * y` — is already the literal `0` node by the time these rules see the +/// product, so the literal test covers those too. For a base that is not +/// provably zero, cancelling `b · b⁻¹ → 1` asserts `b ≠ 0`, which is the +/// library's documented convention (`simplify_control_cancel_x_over_x`). +fn is_zero_to_negative_power(expr: ExprId, pool: &ExprPool) -> bool { + let parts = pool.with(expr, |data| match data { + ExprData::Pow { base, exp } => Some((*base, *exp)), + _ => None, + }); + match parts { + Some((base, exp)) => is_zero(base, pool) && is_negative_literal(exp, pool), + None => false, + } +} + +/// Whether `expr` is, or is a product containing, a literal `0` to a negative +/// literal power — i.e. whether `expr` is undefined for that reason. +/// +/// Only the top level and its immediate `Mul` factors are inspected: the +/// simplifier normalises bottom-up and flattens `Mul` nodes, so an undefined +/// factor of a product is a direct child by the time a `Mul`/`Add` rule sees +/// it. Callers use this to decline a rewrite, so a missed deeper occurrence +/// costs nothing beyond the existing behaviour. +fn has_zero_to_negative_power_factor(expr: ExprId, pool: &ExprPool) -> bool { + if is_zero_to_negative_power(expr, pool) { + return true; + } + pool.with(expr, |data| match data { + ExprData::Mul(args) => args.clone(), + _ => Vec::new(), + }) + .into_iter() + .any(|a| is_zero_to_negative_power(a, pool)) +} + +/// Whether `expr` is a negative `Integer` or `Rational` literal. +fn is_negative_literal(expr: ExprId, pool: &ExprPool) -> bool { + pool.with(expr, |data| match data { + ExprData::Integer(n) => n.0 < 0, + ExprData::Rational(r) => r.0 < 0, + _ => false, + }) +} + pub(crate) fn one_step(name: &'static str, before: ExprId, after: ExprId) -> DerivationLog { let mut log = DerivationLog::new(); log.push(RewriteStep::simple(name, before, after)); @@ -376,18 +435,7 @@ impl RewriteRule for MulZero { // literal `0^(negative)` factor is itself undefined (division by // zero), so the product is indeterminate, not `0`. This is the // n=0 boundary of `0 * x^(-1)` being indeterminate at x=0. - let has_zero_to_neg_pow = args.iter().any(|&a| { - match pool.with(a, |d| match d { - ExprData::Pow { base, exp } => Some((*base, *exp)), - _ => None, - }) { - Some((base, exp)) => { - is_zero(base, pool) && as_integer(exp, pool).is_some_and(|e| e < 0) - } - None => false, - } - }); - if has_zero_to_neg_pow { + if args.iter().any(|&a| is_zero_to_negative_power(a, pool)) { return None; } let after = pool.integer(0_i32); @@ -632,6 +680,15 @@ impl RewriteRule for ConstFold { } } let after = if prod == 0 { + // Same guard as `mul_zero`: `0 * 0^(-1) * 5` is + // undefined, not `0`. The undefined factor is never + // numeric, so it lands in `non_numeric`. + if non_numeric + .iter() + .any(|&a| is_zero_to_negative_power(a, pool)) + { + return None; + } pool.integer(0_i32) } else if non_numeric.is_empty() { intern_rational(prod, pool) @@ -922,6 +979,22 @@ impl RewriteRule for SubSelf { return None; } + // Dropping a term whose integer coefficient sums to `0` asserts that + // the term's remaining factor is a *number* — `0 · u = 0` is false + // when `u` is undefined. `diff(2/(x - x), x)` lands here as + // `(0 · 0⁻¹) + (2 · −1 · 0 · 0⁻²)`, where both coefficients are the + // literal `0` that came out of the numerator, and dropping both + // reported a derivative of `0` for an expression that has none. + // Only checked when something actually cancels, so ordinary + // `x - x → 0` collection is untouched. + if any_zero + && coeff_map + .iter() + .any(|(base, c)| *c == 0 && has_zero_to_negative_power_factor(*base, pool)) + { + return None; + } + // Build new args let mut new_args: Vec = vec![]; let mut seen: HashSet = HashSet::new(); @@ -987,6 +1060,19 @@ impl RewriteRule for DivSelf { return None; } + // Summing exponents of a common base is `b^k · b^m = b^(k+m)`, an + // identity that fails for `b = 0` as soon as one exponent is + // negative: `0^1 · 0^(-1)` is `0 · (1/0)`, undefined, while the + // merged `0^0` would be `1`. `simplify(0^-1)` already declines to + // give the undefined power a value; decline here too rather than + // invent one for the product. The literal check is one sign test per + // factor plus an `O(1)` node probe on the (rare) negative ones — see + // `is_zero_to_negative_power` for why a full three-valued zero test + // is not affordable on this path. + if exp_pairs.iter().any(|(e, b)| *e < 0 && is_zero(*b, pool)) { + return None; + } + let new_args: Vec = if globally_comm { // Commutative: sum exponents for each base anywhere in the product. let mut exp_map: HashMap = HashMap::new(); @@ -1522,6 +1608,87 @@ mod tests { assert_eq!(result, pool.integer(0_i32)); } + // --- division by a literal zero: `0 · 0^(-1)` has no value --- + // + // `0^(-1)` is division by zero, so every product containing it is + // undefined. `simplify(0^-1)` already leaves the power alone and + // `eval_expr(0^-1)` raises `E-EVAL-009`; these check that the surrounding + // product agrees instead of collapsing to `1` (exponent collection) or to + // `0` (absorption / constant folding). + + /// `0 · 0^(-1)` — the exponents sum to `0`, but `0^0 = 1` is not the value + /// of `0 · (1/0)`. + #[test] + fn div_self_does_not_cancel_a_literal_zero_base() { + let pool = p(); + let zero = pool.integer(0_i32); + let inv_zero = pool.pow(zero, pool.integer(-1_i32)); + let expr = pool.mul(vec![zero, inv_zero]); + assert!(DivSelf.apply(expr, &pool).is_none()); + assert_eq!(super::super::engine::simplify(expr, &pool).value, expr); + } + + /// The same product with a spectator factor takes the constant-folding + /// route (`prod == 0`) instead of the exponent-collecting one. + #[test] + fn const_fold_does_not_absorb_a_literal_zero_reciprocal() { + let pool = p(); + let zero = pool.integer(0_i32); + let inv_zero = pool.pow(zero, pool.integer(-1_i32)); + let expr = pool.mul(vec![pool.integer(5_i32), inv_zero, zero]); + assert!(ConstFold.apply(expr, &pool).is_none()); + assert!(MulZero.apply(expr, &pool).is_none()); + } + + /// `(0 · 0^-1) + (0 · 0^-2)` — both integer coefficients are `0`, but a + /// term is only droppable when its remaining factor is a number. This is + /// the shape `diff(2/(x - x), x)` produces. + #[test] + fn sub_self_does_not_drop_an_undefined_term_with_zero_coefficient() { + let pool = p(); + let zero = pool.integer(0_i32); + let inv_zero = pool.pow(zero, pool.integer(-1_i32)); + let inv_zero_sq = pool.pow(zero, pool.integer(-2_i32)); + let expr = pool.add(vec![ + pool.mul(vec![zero, inv_zero]), + pool.mul(vec![zero, inv_zero_sq]), + ]); + assert!(SubSelf.apply(expr, &pool).is_none()); + } + + /// A rational negative exponent is division by zero just the same. + #[test] + fn mul_zero_does_not_absorb_a_rational_negative_zero_power() { + let pool = p(); + let zero = pool.integer(0_i32); + let root = pool.pow(zero, pool.rational(-1_i32, 2_u32)); + let expr = pool.mul(vec![zero, root]); + assert!(MulZero.apply(expr, &pool).is_none()); + } + + /// The guards are keyed on a *literal* zero base only: a symbolic base + /// still cancels, which is the library's documented convention. + #[test] + fn div_self_still_cancels_a_symbolic_base() { + let pool = p(); + let x = pool.symbol("x", Domain::Real); + let inv_x = pool.pow(x, pool.integer(-1_i32)); + let expr = pool.mul(vec![x, inv_x]); + let (result, _) = DivSelf.apply(expr, &pool).unwrap(); + assert_eq!(result, pool.integer(1_i32)); + } + + /// …and `0 · x` still absorbs: the guard must not switch absorption off. + #[test] + fn mul_zero_still_absorbs_a_symbolic_factor() { + let pool = p(); + let x = pool.symbol("x", Domain::Real); + let zero = pool.integer(0_i32); + let expr = pool.mul(vec![zero, x]); + let (result, _) = MulZero.apply(expr, &pool).unwrap(); + assert_eq!(result, zero); + } + // --- PowOne --- #[test] diff --git a/alkahest-core/src/validated/taylor.rs b/alkahest-core/src/validated/taylor.rs index 9e0ca14a..dd901793 100644 --- a/alkahest-core/src/validated/taylor.rs +++ b/alkahest-core/src/validated/taylor.rs @@ -575,7 +575,19 @@ impl TaylorModel { let (s, co) = (c.sin(), c.cos()); let mut a = Vec::with_capacity(self.order + 1); for k in 0..=self.order { - // dᵏ/dxᵏ sin = sin, cos, -sin, -cos ; cos shifts by one. + // dᵏ/dxᵏ sin = sin, cos, -sin, -cos ; cos shifts by one, because + // cos⁽ᵏ⁾ = sin⁽ᵏ⁺¹⁾. The `(k+1) % 4` phase *is* that shift and + // already yields the cosine derivative — `k = 0` gives `cos(m₀)`, + // `k = 1` gives `-sin(m₀)`. A further `-base` for the cosine + // branch (present until 3.8) negated the whole polynomial while + // leaving the symmetric remainder bound untouched, so every + // "validated" cosine came back tight, confident and sign-flipped: + // `bound_on_box(cos x, x ∈ [1,1])` returned `[-0.54030…, -0.54030…]` + // for `cos 1 = +0.54030…`, an enclosure that does not contain the + // value it encloses. Downstream that is a false theorem, not just a + // wrong number — `verified_no_roots(cos x - 0.9, [0,1])` answered + // `true` although `arccos(0.9) = 0.451 ∈ [0,1]`. `sin²+cos² = 1` is + // invariant under the flip, which is why the existing test passed. let phase = if is_sin { k % 4 } else { (k + 1) % 4 }; let base = match phase { 0 => s.clone(), @@ -583,7 +595,6 @@ impl TaylorModel { 2 => -s.clone(), _ => -co.clone(), }; - let base = if is_sin { base } else { -base }; a.push(Self::div_ball(&base, &Self::factorial(k, self.prec))?); } // |sin^{(p+1)}| ≤ 1 and |cos^{(p+1)}| ≤ 1 everywhere. @@ -1047,6 +1058,29 @@ mod tests { taylor_range(expr, pool, &boxes, order, P).unwrap() } + /// A validated enclosure that does not contain the value it encloses is the + /// worst failure this module can have, and `sin²+cos²=1` cannot see a sign + /// flip. Pin the *value*, at a degenerate box, against the hand constant. + #[test] + fn cos_enclosure_contains_cos_of_the_point() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + for point in [1.0_f64, 0.0, 2.5, -1.25, 3.5] { + let expected = point.cos(); + let r = range_of(pool.func("cos", vec![x]), &pool, &[(x, point, point)], 6); + // A degenerate box gives a tight ball; the tolerance only absorbs + // the last f64 ulp, and a sign flip misses it by ~2·|cos(point)|. + assert!( + (r.mid_f64() - expected).abs() < 1e-12 && r.rad_f64() < 1e-12, + "cos({point}) = {expected} but the enclosure is {r:?}" + ); + } + // …and over a genuine box: cos is ≥ cos(1) > 0 on [0, 1]. + let r = range_of(pool.func("cos", vec![x]), &pool, &[(x, 0.0, 1.0)], 8); + assert!(r.lo() > 0.0, "cos > 0 on [0,1] but enclosure is {r:?}"); + assert!(r.hi() >= 1.0, "cos(0) = 1 must be enclosed, got {r:?}"); + } + #[test] fn dependency_cancellation_x_minus_x() { let pool = ExprPool::new(); diff --git a/alkahest-py/src/lib.rs b/alkahest-py/src/lib.rs index a280abac..9133e5a2 100644 --- a/alkahest-py/src/lib.rs +++ b/alkahest-py/src/lib.rs @@ -206,6 +206,81 @@ pyo3::create_exception!(alkahest, PyDomainError, PyAlkahestError); pyo3::create_exception!(alkahest, PyDiffError, PyAlkahestError); pyo3::create_exception!(alkahest, PyPoolError, PyAlkahestError); pyo3::create_exception!(alkahest, PyAssumptionError, PyAlkahestError); +pyo3::create_exception!(alkahest, PyDepthLimitError, PyAlkahestError); + +fn depth_error_to_py(e: alkahest_core::DepthLimitError) -> PyErr { + Python::with_gil(|py| { + let exc_type = py.get_type_bound::(); + make_structured_err(py, &exc_type, &e) + }) +} + +/// Refuse an expression too deeply nested to recurse over. +/// +/// O(1) — the depth is cached on the pool node. See +/// [`alkahest_core::kernel::depth`] for why every recursive consumer needs +/// this: without it a deep enough argument overflows the native stack, and a +/// stack overflow is a `SIGSEGV`, not an exception, so the caller's +/// `except Exception` never runs. +fn guard_depth(pool: &ExprPool, id: ExprId) -> PyResult<()> { + alkahest_core::check_expr_depth(pool, id).map_err(depth_error_to_py) +} + +/// [`guard_depth`] for an expression still wrapped in its `PyExpr`. +fn guard_expr_depth(py: Python<'_>, expr: &PyExpr) -> PyResult<()> { + guard_depth(&expr.pool.borrow(py).inner, expr.id) +} + +/// Largest `n_pts` a plotting call will accept. +/// +/// The renderers hand `n_pts` straight to `Vec::with_capacity`, so without a +/// ceiling a Python `int` becomes either a capacity-overflow panic or an +/// allocation the OOM killer resolves — neither of which a caller can catch. +/// 10 million points is already far past any useful SVG. +const MAX_PLOT_POINTS: usize = 10_000_000; + +/// Largest series / Taylor-model order any entry point will accept. +/// +/// Order sizes a coefficient vector, and Rust's allocator **aborts** on +/// failure — `SIGABRT`, no unwinding, nothing to catch. `series(sin(x), x, 0, +/// 2**31 - 1)` did exactly that. A truncation degree past this is not a +/// computation anyone is waiting for; 2^20 coefficients is already minutes of +/// exact-rational work. +const MAX_SERIES_ORDER: usize = 1 << 20; + +/// Reject an order that would size an allocation out of the process. +fn checked_order(what: &str, order: usize) -> PyResult { + if order > MAX_SERIES_ORDER { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "{what} must be at most {MAX_SERIES_ORDER} (got {order})" + ))); + } + Ok(order) +} + +/// Largest floating-point precision, in bits, any entry point will accept. +/// +/// `rug::Float::with_val` **panics** outside `[1, i32::MAX]`, and several +/// call sites double the precision internally, so the ceiling is set two +/// octaves below `i32::MAX` to leave room for that. 16 Mibit is ~5 million +/// decimal digits. +const MAX_PRECISION_BITS: u32 = 1 << 24; + +/// Validate a user-supplied precision before it reaches `rug`. +/// +/// `Float::with_val(0, x)` and `Float::with_val(huge, x)` both panic, and a +/// panic crossing PyO3 becomes `pyo3_runtime.PanicException`, which derives +/// from `BaseException` — so `except Exception` in a caller's loop does not +/// catch it and the loop dies. Every entry point taking a `prec` / +/// `precision_bits` argument goes through here instead. +fn checked_prec(prec: u32) -> PyResult { + if prec == 0 || prec > MAX_PRECISION_BITS { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "precision must be between 1 and {MAX_PRECISION_BITS} bits (got {prec})" + ))); + } + Ok(prec) +} /// Parse a Python ``int`` (any size) into an interned integer expression. fn integer_into_pool(pool: &ExprPool, n: &Bound<'_, PyAny>) -> PyResult { @@ -511,12 +586,17 @@ thread_local! { fn py_push_budget(wall_ms: Option, max_steps: Option, seed: Option) -> PyResult<()> { let mut budget = alkahest_core::budget::Budget::new(); if let Some(ms) = wall_ms { + // `Duration::from_secs_f64` panics above `u64::MAX` seconds, and + // `wall_ms=1e30` is a plausible way to spell "effectively unlimited". + // Saturate at ~100 years instead of dying. if !ms.is_finite() || ms < 0.0 { return Err(PyValueError::new_err( "wall_ms must be a finite, non-negative number of milliseconds", )); } - budget.wall = Some(std::time::Duration::from_secs_f64(ms / 1000.0)); + const MAX_WALL_SECS: f64 = 100.0 * 365.0 * 24.0 * 3600.0; + let secs = (ms / 1000.0).min(MAX_WALL_SECS); + budget.wall = Some(std::time::Duration::from_secs_f64(secs)); } budget.max_steps = max_steps; budget.seed = seed; @@ -759,6 +839,21 @@ fn matrix_error_to_py(e: MatrixError) -> PyErr { } fn eigen_error_to_py(e: EigenError) -> PyErr { + // `KernelComputationFailed` reaches Python from two places: the eigenvector + // nullspace refused because one entry's vanishing is undecidable + // (`E-LINALG-010` — fixable by substituting concrete parameters), or the + // computed columns did not assemble (`E-EIGEN-006`). `EigenError` is an + // exhaustive public enum, so the first travels out of band exactly as it + // does for `nullspace`; see `linear_algebra_error_to_py`. The exception + // class stays `EigenError` — only the code and the message get specific. + if matches!(e, EigenError::KernelComputationFailed) { + if let Some(r) = alkahest_core::matrix::take_zero_test_refusal() { + return Python::with_gil(|py| { + let exc_type = py.get_type_bound::(); + make_structured_err(py, &exc_type, &r) + }); + } + } Python::with_gil(|py| { let exc_type = py.get_type_bound::(); make_structured_err(py, &exc_type, &e) @@ -880,10 +975,11 @@ impl PyExprPool { PyExpr { id, pool } } - fn float(slf: PyRef<'_, Self>, value: f64, prec: Option) -> PyExpr { - let id = slf.inner.float(value, prec.unwrap_or(53)); + fn float(slf: PyRef<'_, Self>, value: f64, prec: Option) -> PyResult { + let prec = checked_prec(prec.unwrap_or(53))?; + let id = slf.inner.float(value, prec); let pool: Py = slf.into(); - PyExpr { id, pool } + Ok(PyExpr { id, pool }) } /// `O(arg)` — Landau remainder bound (V2-15 series API). @@ -1018,20 +1114,31 @@ impl PyExpr { h.finish() } - fn __repr__(&self, py: Python<'_>) -> String { - self.pool.borrow(py).inner.display(self.id).to_string() + // Every renderer below walks the expression recursively, so each one is a + // stack overflow — i.e. a `SIGSEGV`, not an exception — on a deep enough + // tree. `guard_depth` turns that into a catchable `DepthLimitError`. + fn __repr__(&self, py: Python<'_>) -> PyResult { + let pool = self.pool.borrow(py); + guard_depth(&pool.inner, self.id)?; + Ok(pool.inner.display(self.id).to_string()) } - fn __str__(&self, py: Python<'_>) -> String { - self.pool.borrow(py).inner.display(self.id).to_string() + fn __str__(&self, py: Python<'_>) -> PyResult { + let pool = self.pool.borrow(py); + guard_depth(&pool.inner, self.id)?; + Ok(pool.inner.display(self.id).to_string()) } - fn display_latex(&self, py: Python<'_>) -> String { - alkahest_core::render_latex(self.id, &self.pool.borrow(py).inner) + fn display_latex(&self, py: Python<'_>) -> PyResult { + let pool = self.pool.borrow(py); + guard_depth(&pool.inner, self.id)?; + Ok(alkahest_core::render_latex(self.id, &pool.inner)) } - fn display_unicode(&self, py: Python<'_>) -> String { - alkahest_core::render_unicode(self.id, &self.pool.borrow(py).inner) + fn display_unicode(&self, py: Python<'_>) -> PyResult { + let pool = self.pool.borrow(py); + guard_depth(&pool.inner, self.id)?; + Ok(alkahest_core::render_unicode(self.id, &pool.inner)) } // ------------------------------------------------------------------ @@ -1522,7 +1629,9 @@ impl PyFps { var: PyRef, order: usize, ) -> PyResult { + let order = checked_order("Fps order", order)?; let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; let fps = CoreFps::from_expr(expr.id, var.id, &pool.inner).map_err(fps_error_to_py)?; let coeffs = fps.coeffs(order); Ok(PyFps { @@ -1604,11 +1713,15 @@ impl PyFps { /// The `n`-th coefficient `aₙ` as a Python `int` / `Fraction`. fn coeff(&self, py: Python<'_>, n: usize) -> PyResult { + // The memoising probe behind `coeff` sizes a coefficient vector from + // `n`; an unchecked Python int aborts the process in the allocator. + let n = checked_order("Fps coefficient index", n)?; rational_to_py(py, &self.inner.coeff(n)) } /// The first `n` coefficients `[a₀, …, a_{n-1}]`. fn coeffs(&self, py: Python<'_>, n: usize) -> PyResult { + let n = checked_order("Fps coefficient count", n)?; let out = PyList::empty_bound(py); for c in self.inner.coeffs(n) { out.append(rational_to_py(py, &c)?)?; @@ -1618,13 +1731,14 @@ impl PyFps { /// Truncate to a symbolic `Expr` of degree `< order` in `var` (with an /// `O(varᵒʳᵈᵉʳ)` tail). - fn to_expr(&self, py: Python<'_>, var: PyRef, order: u32) -> PyExpr { + fn to_expr(&self, py: Python<'_>, var: PyRef, order: u32) -> PyResult { + checked_order("Fps order", order as usize)?; let pool_py = var.pool.clone_ref(py); let id = { let pool = pool_py.borrow(py); self.inner.to_expr(var.id, order, &pool.inner) }; - PyExpr { id, pool: pool_py } + Ok(PyExpr { id, pool: pool_py }) } /// Sum `self + other`. @@ -2107,8 +2221,8 @@ impl PyDerivedResult { metadata } - fn __repr__(&self, py: Python<'_>) -> String { - format!("DerivedResult(value={})", self.value.__repr__(py)) + fn __repr__(&self, py: Python<'_>) -> PyResult { + Ok(format!("DerivedResult(value={})", self.value.__repr__(py)?)) } fn __bool__(&self, py: Python<'_>) -> bool { @@ -2167,7 +2281,7 @@ impl PyDerivedResult { out.set_item("kind", "alkahest.derived_result")?; out.set_item("schema_version", RESULT_SCHEMA_VERSION)?; out.set_item("steps_schema_version", STEPS_SCHEMA_VERSION)?; - out.set_item("value", self.value.__str__(py))?; + out.set_item("value", self.value.__str__(py)?)?; if compact { let verification = PyDict::new_bound(py); @@ -2621,13 +2735,14 @@ fn elliptic_pi(py: Python<'_>, n: PyRef, phi: PyRef, m: PyRef, expr: PyRef) -> PyDerivedResult { +fn py_simplify(py: Python<'_>, expr: PyRef) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_simplify(expr.id, &pool.inner) }; let pool_py = expr.pool.clone_ref(py); - make_derived_result(py, derived, pool_py, None) + Ok(make_derived_result(py, derived, pool_py, None)) } /// Python-visible configuration for the e-graph simplifier. @@ -2720,13 +2835,14 @@ impl PyEgraphConfig { #[pyfunction] #[pyo3(name = "simplify_egraph")] -fn py_simplify_egraph(py: Python<'_>, expr: PyRef) -> PyDerivedResult { +fn py_simplify_egraph(py: Python<'_>, expr: PyRef) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_simplify_egraph(expr.id, &pool.inner) }; let pool_py = expr.pool.clone_ref(py); - make_derived_result(py, derived, pool_py, None) + Ok(make_derived_result(py, derived, pool_py, None)) } /// Simplify using the e-graph backend with a custom [`EgraphConfig`]. @@ -2739,13 +2855,14 @@ fn py_simplify_egraph_with( py: Python<'_>, expr: PyRef, config: PyRef, -) -> PyDerivedResult { +) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_simplify_egraph_with(expr.id, &pool.inner, &config.inner, &SizeCost) }; let pool_py = expr.pool.clone_ref(py); - make_derived_result(py, derived, pool_py, None) + Ok(make_derived_result(py, derived, pool_py, None)) } #[pyfunction] @@ -2753,6 +2870,7 @@ fn py_simplify_egraph_with( fn py_diff(py: Python<'_>, expr: PyRef, var: PyRef) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_diff(expr.id, var.id, &pool.inner).map_err(diff_error_to_py)? }; let pool_py = expr.pool.clone_ref(py); @@ -2768,6 +2886,7 @@ fn py_diff_forward( ) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_diff_forward(expr.id, var.id, &pool.inner).map_err(diff_error_to_py)? }; let pool_py = expr.pool.clone_ref(py); @@ -3324,6 +3443,22 @@ impl PyRationalFunction { // Module // --------------------------------------------------------------------------- +/// Integrate, with the GIL **released** for the duration of the core call. +/// +/// `integrate` is one of the two engines that honour `alkahest.Budget` / +/// `request_cancel()` (see `docs/mdbook/src/budgets.md`). Holding the GIL for +/// the whole run made that promise half-true: a watchdog thread calling +/// `request_cancel()` could not execute a single bytecode until the call it +/// wanted to cancel had already finished, so only a flag set *before* the call +/// was ever observed — the opposite of what a fan-out search loop needs. +/// +/// The idiom is `py_simplify_par`'s, and the safety argument is the same one: +/// `ExprPool` is `Send + Sync` and interns through a lock-free index, and this +/// is strictly weaker than what `simplify_par` already does (Rayon workers on +/// the same pool, concurrently). Nothing under `core_integrate` touches a +/// `Python` token. The budget itself is thread-local, and `allow_threads` does +/// not move the work to another thread — it only drops the GIL on this one — so +/// the active `Budget` frame is still the caller's. #[pyfunction] #[pyo3(name = "integrate")] fn py_integrate( @@ -3332,8 +3467,13 @@ fn py_integrate( var: PyRef, ) -> PyResult { let derived = { - let pool = expr.pool.borrow(py); - core_integrate(expr.id, var.id, &pool.inner).map_err(integrate_error_to_py)? + let pool_ref = expr.pool.borrow(py); + guard_depth(&pool_ref.inner, expr.id)?; + // Bind out of the `PyRef` first: it carries a `Python` marker and so is + // not `Sync`, but the pool and ids themselves are safe to send. + let (id, var_id, pool) = (expr.id, var.id, &pool_ref.inner); + py.allow_threads(|| core_integrate(id, var_id, pool)) + .map_err(integrate_error_to_py)? }; let pool_py = expr.pool.clone_ref(py); let mut result = make_derived_result(py, derived, pool_py, None); @@ -3367,6 +3507,7 @@ fn py_apart(py: Python<'_>, expr: PyRef, var: PyRef) -> PyResult let pool_py = expr.pool.clone_ref(py); let id = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_apart(expr.id, var.id, &pool.inner).map_err(apart_error_to_py)? }; Ok(PyExpr { id, pool: pool_py }) @@ -3446,6 +3587,7 @@ fn py_residue( let gauss = parse_gauss_point(point)?; let id = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_residue(expr.id, var.id, gauss, &pool.inner).map_err(residue_error_to_py)? }; Ok(PyExpr { id, pool: pool_py }) @@ -3468,6 +3610,8 @@ fn py_series( let point_id = coerce_substituent(&pool_py, point, py)?; let id = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; + checked_order("series order", order as usize)?; core_series(expr.id, var.id, point_id, order, &pool.inner) .map_err(series_error_to_py)? .expr() @@ -3477,6 +3621,13 @@ fn py_series( }) } +/// Take a limit, with the GIL **released** for the duration of the core call. +/// +/// `limit` is the other budget-honouring engine; see `py_integrate` for why +/// holding the GIL made `request_cancel()` unable to reach a running call, and +/// for the safety argument (identical here — `core_limit` takes `&ExprPool` and +/// no `Python` token, and the budget/work-ceiling state it uses is thread-local +/// to *this* thread, which `allow_threads` does not change). #[pyfunction] #[pyo3(name = "limit", signature = (expr, var, point, dir=None))] fn py_limit( @@ -3489,8 +3640,13 @@ fn py_limit( let pool_py = expr.pool.clone_ref(py); let d = parse_limit_direction(dir); let id = { - let pool = pool_py.borrow(py); - core_limit(expr.id, var.id, point.id, d, &pool.inner).map_err(limit_error_to_py)? + let pool_ref = pool_py.borrow(py); + guard_depth(&pool_ref.inner, expr.id)?; + // Bind out of the `PyRef` first: it carries a `Python` marker and so is + // not `Sync`, but the pool and ids themselves are safe to send. + let (id, var_id, point_id, pool) = (expr.id, var.id, point.id, &pool_ref.inner); + py.allow_threads(|| core_limit(id, var_id, point_id, d, pool)) + .map_err(limit_error_to_py)? }; Ok(PyExpr { id, pool: pool_py }) } @@ -4085,6 +4241,7 @@ fn py_sum_indefinite( ) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_sum_indefinite(expr.id, k.id, &pool.inner).map_err(sum_error_to_py)? }; Ok(make_derived_result( @@ -4106,6 +4263,7 @@ fn py_sum_definite( ) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_sum_definite(expr.id, k.id, lo.id, hi.id, &pool.inner).map_err(sum_error_to_py)? }; Ok(make_derived_result( @@ -4125,6 +4283,7 @@ fn py_product_indefinite( ) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_product_indefinite(expr.id, k.id, &pool.inner).map_err(product_error_to_py)? }; Ok(make_derived_result( @@ -4146,6 +4305,7 @@ fn py_product_definite( ) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_product_definite(expr.id, k.id, lo.id, hi.id, &pool.inner) .map_err(product_error_to_py)? }; @@ -4579,10 +4739,13 @@ fn match_pattern( pattern_expr: PyRef, expr: PyRef, wildcards: bool, -) -> PyObject { +) -> PyResult { let pool_py = pattern_expr.pool.clone_ref(py); let matches = { let pool = pool_py.borrow(py); + // The matcher recurses over both sides, so both need the guard. + alkahest_core::check_expr_depths(&pool.inner, &[pattern_expr.id, expr.id]) + .map_err(depth_error_to_py)?; let pat = Pattern::from_expr(pattern_expr.id); core_match_pattern_with_config(&pat, expr.id, &pool.inner, MatchConfig { wildcards }) }; @@ -4594,11 +4757,11 @@ fn match_pattern( id, pool: pool_py.clone_ref(py), }; - d.set_item(name, expr_py.into_py(py)).unwrap(); + d.set_item(name, expr_py.into_py(py))?; } - out.append(d).unwrap(); + out.append(d)?; } - out.into_py(py) + Ok(out.into_py(py)) } // --------------------------------------------------------------------------- @@ -4672,26 +4835,28 @@ fn py_simplify_with( /// Applies `(a + b) * c → a*c + b*c` in addition to all default rules. #[pyfunction] #[pyo3(name = "simplify_expanded")] -fn py_simplify_expanded(py: Python<'_>, expr: PyRef) -> PyDerivedResult { +fn py_simplify_expanded(py: Python<'_>, expr: PyRef) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; alkahest_core::simplify_expanded(expr.id, &pool.inner) }; let pool_py = expr.pool.clone_ref(py); - make_derived_result(py, derived, pool_py, None) + Ok(make_derived_result(py, derived, pool_py, None)) } /// `alkahest.simplify_trig(expr)` — simplify with trigonometric identities. #[pyfunction] #[pyo3(name = "simplify_trig")] -fn py_simplify_trig(py: Python<'_>, expr: PyRef) -> PyDerivedResult { +fn py_simplify_trig(py: Python<'_>, expr: PyRef) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; let rules = trig_rules(); core_simplify_with(expr.id, &pool.inner, &rules, SimplifyConfig::default()) }; let pool_py = expr.pool.clone_ref(py); - make_derived_result(py, derived, pool_py, None) + Ok(make_derived_result(py, derived, pool_py, None)) } /// `alkahest.simplify_trig_normal_form(expr)` — reduce to a trig normal form. @@ -4710,13 +4875,14 @@ fn py_simplify_trig(py: Python<'_>, expr: PyRef) -> PyDerivedResult { /// than :func:`simplify` and is opt-in. #[pyfunction] #[pyo3(name = "simplify_trig_normal_form")] -fn py_simplify_trig_normal_form(py: Python<'_>, expr: PyRef) -> PyDerivedResult { +fn py_simplify_trig_normal_form(py: Python<'_>, expr: PyRef) -> PyResult { let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_simplify_trig_normal_form(expr.id, &pool.inner) }; let pool_py = expr.pool.clone_ref(py); - make_derived_result(py, derived, pool_py, None) + Ok(make_derived_result(py, derived, pool_py, None)) } /// `alkahest.simplify_log_exp(expr, assumptions=None)` — simplify with log/exp identities. @@ -4738,6 +4904,7 @@ fn py_simplify_log_exp( } let derived = { let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; let facts = match &assumptions { Some(a) => a.inner.facts().to_vec(), None => Vec::new(), @@ -4778,6 +4945,8 @@ fn py_simplify_log_exp( fn py_to_lean(py: Python<'_>, arg: &Bound<'_, PyAny>) -> PyResult { if let Ok(derived_bound) = arg.downcast::() { let d = derived_bound.borrow(); + // `expr_to_lean` recurses once per level with no cap. + guard_expr_depth(py, &d.value)?; // Integration results certify via the FTC derivative relation // `deriv (fun x => F) x = f` rather than a false `f = F` equality. if let Some((integrand, var)) = d.integration_verification_input { @@ -4819,6 +4988,7 @@ fn py_to_lean(py: Python<'_>, arg: &Bound<'_, PyAny>) -> PyResult { let pool_py = expr.pool.clone_ref(py); let derived = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_simplify(expr.id, &pool.inner) }; // Part C: the default simplifier may leave the expression untouched @@ -4885,6 +5055,7 @@ fn py_to_smtlib( get_model: bool, ) -> PyResult { let pool = formula.pool.borrow(py); + guard_depth(&pool.inner, formula.id)?; let opts = alkahest_core::logic::smtlib::SmtLibOptions { logic: if logic == "auto" { None } else { Some(logic) }, check_sat, @@ -4918,6 +5089,7 @@ fn py_subs(py: Python<'_>, expr: PyRef, mapping: &Bound<'_, PyDict>) -> } let result_id = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; let substituted = core_subs(expr.id, &map, &pool.inner); core_fold_predicates(substituted, &pool.inner) }; @@ -4945,20 +5117,23 @@ fn version() -> &'static str { /// the number of variables, vs. O(#vars × DAG size) for repeated `diff`. #[pyfunction] #[pyo3(name = "grad")] -fn py_grad(py: Python<'_>, expr: PyRef, vars: Vec>) -> Vec { +fn py_grad(py: Python<'_>, expr: PyRef, vars: Vec>) -> PyResult> { let pool_py = expr.pool.clone_ref(py); let var_ids: Vec = vars.iter().map(|v| v.id).collect(); let grads = { let pool = pool_py.borrow(py); + // Reverse-mode is the shallowest walker in the library: its post-order + // DFS overflowed an 8 MiB stack at depth 4 687 (see kernel::depth). + guard_depth(&pool.inner, expr.id)?; core_grad(expr.id, &var_ids, &pool.inner) }; - grads + Ok(grads .into_iter() .map(|id| PyExpr { id, pool: pool_py.clone_ref(py), }) - .collect() + .collect()) } // --------------------------------------------------------------------------- @@ -5068,11 +5243,22 @@ impl PyMatrix { }) } - fn get(&self, py: Python<'_>, r: usize, c: usize) -> PyExpr { - PyExpr { + fn get(&self, py: Python<'_>, r: usize, c: usize) -> PyResult { + // `Matrix::get` indexes `data[r * cols + c]` with no bounds check, so + // an out-of-range subscript was a panic — a `BaseException` on the + // Python side — rather than the `IndexError` a caller expects. Note + // `r * cols` also wraps in release, which would have silently returned + // a different element for huge `r`. + let (rows, cols) = (self.inner.rows, self.inner.cols); + if r >= rows || c >= cols { + return Err(pyo3::exceptions::PyIndexError::new_err(format!( + "matrix index ({r}, {c}) out of range for a {rows}x{cols} matrix" + ))); + } + Ok(PyExpr { id: self.inner.get(r, c), pool: self.pool.clone_ref(py), - } + }) } fn transpose(&self, py: Python<'_>) -> PyMatrix { @@ -5658,6 +5844,7 @@ fn py_jacobian( let x_ids: Vec = x_vec.iter().map(|e| e.id).collect(); let m = { let pool = pool_py.borrow(py); + alkahest_core::check_expr_depths(&pool.inner, &f_ids).map_err(depth_error_to_py)?; core_jacobian(&f_ids, &x_ids, &pool.inner) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? }; @@ -6342,6 +6529,7 @@ fn py_compile_expr( } let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; let input_ids: Vec = inputs .iter() .map(|item| { @@ -6415,6 +6603,7 @@ fn py_eval_expr( (e.id, e.pool.clone_ref(py)) }; let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr_id)?; let mut env = std::collections::HashMap::new(); for (key, value) in bindings.iter() { let var: PyRef = key.extract()?; @@ -6481,7 +6670,11 @@ impl PyCompiledFn { n_vars: usize, n_points: usize, ) -> PyResult> { - if inputs_flat.len() != n_vars * n_points { + // Checked: the product wraps in release, and a wrapped product that + // happens to equal `inputs_flat.len()` let a `2**63`-long slice range + // through to panic a few lines below. + let expected = n_vars.checked_mul(n_points); + if expected != Some(inputs_flat.len()) { return Err(pyo3::exceptions::PyValueError::new_err(format!( "inputs_flat length {} != n_vars({}) * n_points({})", inputs_flat.len(), @@ -6518,7 +6711,11 @@ impl PyCompiledFn { n_vars: usize, n_points: usize, ) -> PyResult> { - if inputs_flat.len() != n_vars * n_points { + // Checked: the product wraps in release, and a wrapped product that + // happens to equal `inputs_flat.len()` let a `2**63`-long slice range + // through to panic a few lines below. + let expected = n_vars.checked_mul(n_points); + if expected != Some(inputs_flat.len()) { return Err(pyo3::exceptions::PyValueError::new_err(format!( "inputs_flat length {} != n_vars({}) * n_points({})", inputs_flat.len(), @@ -6802,10 +6999,13 @@ impl PyArbBall { /// Create a real ball `[mid ± rad]`. #[new] #[pyo3(signature = (mid, rad=0.0, prec=128))] - fn new(mid: f64, rad: f64, prec: u32) -> Self { - PyArbBall { + fn new(mid: f64, rad: f64, prec: u32) -> PyResult { + // `rug::Float::with_val` panics on prec 0, and the radius path inside + // `from_midpoint_radius` doubles it — see `checked_prec`. + let prec = checked_prec(prec)?; + Ok(PyArbBall { inner: CoreArbBall::from_midpoint_radius(mid, rad, prec), - } + }) } #[getter] @@ -6943,8 +7143,9 @@ fn py_interval_eval( bindings: &Bound<'_, PyDict>, prec: Option, ) -> PyResult { - let prec = prec.unwrap_or(128); + let prec = checked_prec(prec.unwrap_or(128))?; let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; let mut eval = CoreIntervalEval::new(prec); for (key, value) in bindings.iter() { let var: PyRef = key.extract()?; @@ -7046,12 +7247,11 @@ fn py_evaluate( "mode must be 'auto', 'exact', 'f64', 'complex', or 'interval'", )); } - if precision_bits == Some(0) { - return Err(pyo3::exceptions::PyValueError::new_err( - "precision_bits must be positive", - )); + if let Some(p) = precision_bits { + checked_prec(p)?; } let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; let wants_interval = mode == "interval" || (mode == "auto" && (precision_bits.is_some() @@ -7235,6 +7435,7 @@ fn py_evaluate( #[pyo3(name = "simplify_par")] fn py_simplify_par(py: Python<'_>, expr: PyRef) -> PyResult { let pool_ref = expr.pool.borrow(py); + guard_depth(&pool_ref.inner, expr.id)?; // Bind out of the `PyRef` first: it carries a `Python` marker and so is // not `Sync`, but the pool and id themselves are safe to send. #[cfg(feature = "parallel")] @@ -7270,6 +7471,7 @@ fn py_simplify_par(py: Python<'_>, expr: PyRef) -> PyResult, expr: PyRef) -> PyResult { let pool_ref = expr.pool.borrow(py); + guard_depth(&pool_ref.inner, expr.id)?; // Bind out of the `PyRef` first: it carries a `Python` marker and so is // not `Sync`, but the pool and id themselves are safe to send. #[cfg(feature = "parallel")] @@ -7303,6 +7505,7 @@ fn py_simplify_redex(py: Python<'_>, expr: PyRef) -> PyResult, expr: PyRef) -> PyResult { let pool_ref = expr.pool.borrow(py); + guard_depth(&pool_ref.inner, expr.id)?; // Bind out of the `PyRef` first: it carries a `Python` marker and so is // not `Sync`, but the pool and id themselves are safe to send. #[cfg(feature = "parallel")] @@ -7336,6 +7539,7 @@ fn py_simplify_strategy(py: Python<'_>, expr: PyRef) -> PyResult #[cfg(feature = "parallel")] { let pool_ref = expr.pool.borrow(py); + guard_depth(&pool_ref.inner, expr.id)?; let strategy = alkahest_core::choose_strategy(expr.id, &pool_ref.inner); Ok(match strategy { alkahest_core::Strategy::ForkJoin => "fork_join".to_string(), @@ -7363,6 +7567,7 @@ fn py_horner(py: Python<'_>, expr: PyRef, var: PyRef) -> PyResul let pool_py = expr.pool.clone_ref(py); let result = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_horner(expr.id, var.id, &pool.inner) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? }; @@ -7400,6 +7605,7 @@ fn py_emit_c( ) -> PyResult { let var_id = extract_univariate_var(var)?; let pool = expr.pool.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_emit_horner_c(expr.id, var_id, var_name, fn_name, &pool.inner) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } @@ -7495,6 +7701,7 @@ fn py_emit_c_expr( // Collect (or derive) C parameter names. let pool_guard = expr.pool.borrow(py); + guard_depth(&pool_guard.inner, expr.id)?; let c_names: Vec = if let Some(names_obj) = var_names { if let Ok(s) = names_obj.extract::() { vec![s] @@ -7591,6 +7798,7 @@ fn py_emit_c_vec( // All exprs must share the same pool; use the first one. let pool_guard = exprs[0].pool.borrow(py); let expr_ids: Vec = exprs.iter().map(|e| e.id).collect(); + alkahest_core::check_expr_depths(&pool_guard.inner, &expr_ids).map_err(depth_error_to_py)?; // Collect variable ExprIds. let var_ids: Vec = if let Ok(e) = vars.extract::>() { @@ -7650,15 +7858,16 @@ fn py_emit_c_vec( /// `simplify_expanded` if you want full polynomial simplification. #[pyfunction] #[pyo3(name = "collect_like_terms")] -fn py_collect_like_terms(py: Python<'_>, expr: PyRef) -> PyDerivedResult { +fn py_collect_like_terms(py: Python<'_>, expr: PyRef) -> PyResult { use alkahest_core::{rules_for_config, simplify_with}; let pool_py = expr.pool.clone_ref(py); let derived = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; let rules = rules_for_config(&SimplifyConfig::default()); simplify_with(expr.id, &pool.inner, &rules, SimplifyConfig::default()) }; - make_derived_result(py, derived, pool_py, None) + Ok(make_derived_result(py, derived, pool_py, None)) } // --------------------------------------------------------------------------- @@ -7668,33 +7877,38 @@ fn py_collect_like_terms(py: Python<'_>, expr: PyRef) -> PyDerivedResult /// Simplify with default arithmetic rules plus the Pauli product table on ``sx``, ``sy``, ``sz``. #[pyfunction] #[pyo3(name = "simplify_pauli")] -fn py_simplify_pauli(py: Python<'_>, expr: PyRef) -> PyDerivedResult { +fn py_simplify_pauli(py: Python<'_>, expr: PyRef) -> PyResult { use alkahest_core::algebra::noncommutative::pauli_product_rules; use alkahest_core::{rules_for_config, simplify_with}; let pool_py = expr.pool.clone_ref(py); let derived = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; let mut rules = rules_for_config(&SimplifyConfig::default()); rules.extend(pauli_product_rules()); simplify_with(expr.id, &pool.inner, &rules, SimplifyConfig::default()) }; - make_derived_result(py, derived, pool_py, None) + Ok(make_derived_result(py, derived, pool_py, None)) } /// Simplify with default rules plus orthogonal Clifford anticommutation on ``cliff_e1``, ``cliff_e2``. #[pyfunction] #[pyo3(name = "simplify_clifford_orthogonal")] -fn py_simplify_clifford_orthogonal(py: Python<'_>, expr: PyRef) -> PyDerivedResult { +fn py_simplify_clifford_orthogonal( + py: Python<'_>, + expr: PyRef, +) -> PyResult { use alkahest_core::algebra::noncommutative::clifford_orthogonal_rules; use alkahest_core::{rules_for_config, simplify_with}; let pool_py = expr.pool.clone_ref(py); let derived = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; let mut rules = rules_for_config(&SimplifyConfig::default()); rules.extend(clifford_orthogonal_rules()); simplify_with(expr.id, &pool.inner, &rules, SimplifyConfig::default()) }; - make_derived_result(py, derived, pool_py, None) + Ok(make_derived_result(py, derived, pool_py, None)) } // --------------------------------------------------------------------------- @@ -7723,6 +7937,7 @@ fn py_poly_normal( let var_ids: Vec = vars.iter().map(|v| v.id).collect(); let result = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_poly_normal(expr.id, var_ids, &pool.inner).map_err(conv_error_to_py)? }; Ok(PyExpr { @@ -7762,6 +7977,7 @@ fn py_cancel( let pool_py = expr.pool.clone_ref(py); let result = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; let var_ids: Vec = match vars { Some(v) => v.iter().map(|v| v.id).collect(), None => alkahest_core::collect_free_vars(expr.id, &pool.inner), @@ -7795,6 +8011,7 @@ fn py_together( let pool_py = expr.pool.clone_ref(py); let result = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; let var_ids: Vec = match vars { Some(v) => v.iter().map(|v| v.id).collect(), None => alkahest_core::collect_free_vars(expr.id, &pool.inner), @@ -7848,6 +8065,7 @@ fn py_resultant( let pool_py = p.pool.clone_ref(py); let derived = { let pool = pool_py.borrow(py); + alkahest_core::check_expr_depths(&pool.inner, &[p.id, q.id]).map_err(depth_error_to_py)?; core_resultant(p.id, q.id, var.id, &pool.inner).map_err(resultant_error_to_py)? }; Ok(make_derived_result(py, derived, pool_py, None)) @@ -7881,6 +8099,7 @@ fn py_subresultant_prs( let pool_py = p.pool.clone_ref(py); let derived = { let pool = pool_py.borrow(py); + alkahest_core::check_expr_depths(&pool.inner, &[p.id, q.id]).map_err(depth_error_to_py)?; core_subresultant_prs(p.id, q.id, var.id, &pool.inner).map_err(resultant_error_to_py)? }; @@ -7987,6 +8206,7 @@ fn py_real_roots( var: PyRef, ) -> PyResult> { let pool = poly.pool.borrow(py); + guard_depth(&pool.inner, poly.id)?; let intervals = core_real_roots_symbolic(poly.id, var.id, &pool.inner).map_err(real_root_error_to_py)?; Ok(intervals.into_iter().map(core_interval_to_py).collect()) @@ -8029,6 +8249,7 @@ fn py_refine_root( var: PyRef, ) -> PyResult { let pool = poly.pool.borrow(py); + guard_depth(&pool.inner, poly.id)?; let uni = UniPoly::from_symbolic(poly.id, var.id, &pool.inner) .map_err(|e| real_root_error_to_py(RealRootError::NotAPolynomial(e)))?; let ball = core_refine_root(&uni, &interval.inner, 53); @@ -8089,16 +8310,29 @@ fn py_sparse_interp_univariate( term_bound: usize, prime: u64, ) -> PyResult> { + // The oracle is arbitrary user code, so it can raise anything — and the + // core signature is infallible, so an `.expect()` here turned every one of + // those exceptions into a `PanicException` (a `BaseException`, which a + // caller's `except Exception` does not catch). Park the first error and + // re-raise it after the algorithm returns. + let oracle_err: std::cell::RefCell> = std::cell::RefCell::new(None); let rust_eval = |x: u64| -> u64 { - let result = eval - .call1((x,)) - .expect("sparse_interp_univariate: oracle call failed"); - result - .extract::() - .expect("sparse_interp_univariate: oracle must return int") + if oracle_err.borrow().is_some() { + return 0; + } + match eval.call1((x,)).and_then(|r| r.extract::()) { + Ok(v) => v, + Err(e) => { + *oracle_err.borrow_mut() = Some(e); + 0 + } + } }; - let terms = core_sparse_interpolate_univariate(&rust_eval, term_bound, prime) - .map_err(sparse_interp_error_to_py)?; + let terms = core_sparse_interpolate_univariate(&rust_eval, term_bound, prime); + if let Some(e) = oracle_err.into_inner() { + return Err(e); + } + let terms = terms.map_err(sparse_interp_error_to_py)?; let _ = py; // suppress unused warning Ok(terms) } @@ -8162,18 +8396,28 @@ fn py_sparse_interp( ) -> PyResult { let var_ids: Vec = vars.iter().map(|v| v.id).collect(); + // See `sparse_interp_univariate`: a raising oracle must not become a + // `PanicException`. + let oracle_err: std::cell::RefCell> = std::cell::RefCell::new(None); let rust_eval = |pt: &[u64]| -> u64 { + if oracle_err.borrow().is_some() { + return 0; + } let py_list = pyo3::types::PyList::new_bound(py, pt.iter().copied()); - let result = eval - .call1((py_list,)) - .expect("sparse_interp: oracle call failed"); - result - .extract::() - .expect("sparse_interp: oracle must return int") + match eval.call1((py_list,)).and_then(|r| r.extract::()) { + Ok(v) => v, + Err(e) => { + *oracle_err.borrow_mut() = Some(e); + 0 + } + } }; - let fp = core_sparse_interpolate(&rust_eval, var_ids, term_bound, degree_bound, prime, seed) - .map_err(sparse_interp_error_to_py)?; + let fp = core_sparse_interpolate(&rust_eval, var_ids, term_bound, degree_bound, prime, seed); + if let Some(e) = oracle_err.into_inner() { + return Err(e); + } + let fp = fp.map_err(sparse_interp_error_to_py)?; Ok(PyMultiPolyFp { inner: fp, pool: None, @@ -8280,6 +8524,7 @@ fn require_same_pool(py: Python<'_>, a: &PyExpr, b: &PyExpr) -> PyResult<()> { #[pyfunction(name = "satisfiable")] fn py_satisfiable(py: Python<'_>, formula: PyRef) -> PyResult { let pool = formula.pool.borrow(py); + guard_depth(&pool.inner, formula.id)?; let out: PyObject = match core_satisfiable(formula.id, &pool.inner) { CoreSatisfiability::Unsat => false.to_object(py), CoreSatisfiability::Unknown => py.None(), @@ -8361,6 +8606,7 @@ fn py_decide(py: Python<'_>, formula: PyRef) -> PyResult<(bool, PyObject let pool_py = formula.pool.clone_ref(py); let bor = pool_py.borrow(py); let inner = &bor.inner; + guard_depth(inner, formula.id)?; let r = core_decide_expr(formula.id, inner).map_err(cad_error_to_py)?; let wit: PyObject = match r.witness { None => py.None(), @@ -8568,6 +8814,8 @@ fn py_bound_on_box( "bound_on_box: the box must constrain at least one variable", )); } + let prec = checked_prec(prec)?; + guard_expr_depth(py, &expr)?; let (pool_py, boxes) = parse_box(py, r#box); let opts = CoreBoundOptions { order, @@ -8608,6 +8856,8 @@ fn py_verified_integral( tol: f64, max_subdivisions: usize, ) -> PyResult { + let prec = checked_prec(prec)?; + guard_expr_depth(py, &expr)?; let pool_py = expr.pool.clone_ref(py); let opts = CoreIntegralOptions { order, @@ -8648,6 +8898,8 @@ fn py_verified_no_roots( "verified_no_roots: the box must constrain at least one variable", )); } + let prec = checked_prec(prec)?; + guard_expr_depth(py, &expr)?; let (pool_py, boxes) = parse_box(py, r#box); let opts = CoreBoundOptions { order, @@ -8685,6 +8937,8 @@ fn py_verified_sign( "verified_sign: the box must constrain at least one variable", )); } + let prec = checked_prec(prec)?; + guard_expr_depth(py, &expr)?; let pred = match predicate { "positive" => CoreSignPredicate::Positive, "negative" => CoreSignPredicate::Negative, @@ -8734,6 +8988,7 @@ fn py_sos_decompose( }; let inner = { let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; core_sos_decompose(expr.id, &var_ids, &pool.inner, &opts).map_err(sos_error_to_py)? }; Ok(PyPositivityCertificate { @@ -8758,6 +9013,7 @@ fn py_prove_nonneg( level: u32, ) -> PyResult { let pool_py = expr.pool.clone_ref(py); + guard_expr_depth(py, &expr)?; let var_ids: Vec = vars.iter().map(|v| v.id).collect(); let cons: Vec = constraints .map(|cs| cs.iter().map(|c| c.id).collect()) @@ -9015,11 +9271,17 @@ fn py_to_stablehlo( expr: PyRef, inputs: Vec>, fn_name: &str, -) -> String { +) -> PyResult { let pool_py = expr.pool.clone_ref(py); let pool = pool_py.borrow(py); + guard_depth(&pool.inner, expr.id)?; let input_ids: Vec = inputs.iter().map(|e| e.id).collect(); - core_emit_stablehlo(expr.id, &input_ids, fn_name, &pool.inner) + Ok(core_emit_stablehlo( + expr.id, + &input_ids, + fn_name, + &pool.inner, + )) } // --------------------------------------------------------------------------- @@ -10013,6 +10275,8 @@ fn py_solve( let pool_py = equations[0].pool.clone_ref(py); let eq_ids: Vec = equations.iter().map(|e| e.id).collect(); let var_ids: Vec = vars.iter().map(|v| v.id).collect(); + alkahest_core::check_expr_depths(&pool_py.borrow(py).inner, &eq_ids) + .map_err(depth_error_to_py)?; if method == "homotopy" { let opts = HomotopyOpts::default(); @@ -10537,6 +10801,7 @@ fn py_guess_relation( ) -> PyResult>> { use rug::ops::CompleteRound; use rug::Float; + let precision_bits = checked_prec(precision_bits)?; let list = constants .downcast::() .map_err(|_| PyTypeError::new_err("constants must be a list"))?; @@ -10544,7 +10809,27 @@ fn py_guess_relation( let mut xs: Vec = Vec::with_capacity(n); for i in 0..n { let item = list.get_item(i)?; - if let Ok(v) = item.extract::() { + // Python `int` is checked *before* `f64`. `extract::()` succeeds + // for an int and rounds it: `2**60 + 1` arrived as `2**60`, and + // `guess_relation([2**60+1, 2**60, 1])` then returned `[-1, 1, 0]`, + // whose residual over the values actually supplied is `-1`, not `0` + // (the true relation is `[-1, 1, 1]`). `relation_confidence` reported + // `credible=True` with `available_digits=inf`, because `_supplied_bits` + // treats an int as exact — which it is, right up until this line threw + // the low bits away. Ints take the same decimal-string route as + // strings, so the two input forms mean the same thing. + if item.is_instance_of::() { + let s = item.str()?.to_string(); + xs.push( + Float::parse(s.trim()) + .map_err(|_| { + pyo3::exceptions::PyValueError::new_err( + "could not parse integer constant as a floating constant", + ) + })? + .complete(precision_bits), + ); + } else if let Ok(v) = item.extract::() { xs.push(Float::with_val(precision_bits, v)); } else if let Ok(s) = item.extract::() { xs.push( @@ -10596,9 +10881,17 @@ fn py_plot_svg( height: u32, n_pts: usize, padding: u32, -) -> String { +) -> PyResult { + // `n_pts` reaches `Vec::with_capacity` in the renderer, so an unchecked + // Python int is a capacity-overflow panic or an OOM kill, not an error. + if n_pts > MAX_PLOT_POINTS { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "n_pts must be at most {MAX_PLOT_POINTS} (got {n_pts})" + ))); + } let pool_ref = expr.pool.borrow(py); - alkahest_core::render_svg_opts( + guard_depth(&pool_ref.inner, expr.id)?; + Ok(alkahest_core::render_svg_opts( &pool_ref.inner, expr.id, var.id, @@ -10608,14 +10901,15 @@ fn py_plot_svg( height, n_pts, padding, - ) + )) } #[pyfunction] #[pyo3(name = "plot_dot")] -fn py_plot_dot(py: Python<'_>, expr: PyRef) -> String { +fn py_plot_dot(py: Python<'_>, expr: PyRef) -> PyResult { let pool_ref = expr.pool.borrow(py); - alkahest_core::render_dot(&pool_ref.inner, expr.id) + guard_depth(&pool_ref.inner, expr.id)?; + Ok(alkahest_core::render_dot(&pool_ref.inner, expr.id)) } #[pymodule] @@ -10886,6 +11180,10 @@ fn alkahest(m: &Bound<'_, PyModule>) -> PyResult<()> { "AssumptionError", m.py().get_type_bound::(), )?; + m.add( + "DepthLimitError", + m.py().get_type_bound::(), + )?; m.add( "IntegrationError", m.py().get_type_bound::(), diff --git a/alkahest-skill/alkahest.md b/alkahest-skill/alkahest.md index 80f0f089..3d1f1711 100644 --- a/alkahest-skill/alkahest.md +++ b/alkahest-skill/alkahest.md @@ -196,6 +196,7 @@ you reach for `.value`: | `.verification` | `dict` | Evidence status, emitted artifact format, external-check status, and side conditions | | `.certificate` | `str \| None` | Generated Lean 4 `.lean` source; generation is not Lean proof checking | | `to_lean(result)` | `str` | Same as `.certificate`; also accepts `Expr` (runs `simplify` first) | +| `.to_dict(mode=…)` / `.to_json(mode=…)` | `dict` / `str` | Versioned envelope with a `"kind": "alkahest.derived_result"` discriminator. `mode="compact"` drops step `before`/`after` text and shortens keys, but **never** hides `verification["status"]` and never includes Lean source. Use this to carry a result out of a pool's lifetime, and in agent context windows. | ```python caps = ak.capabilities() @@ -405,7 +406,7 @@ For asymptotics and multivariate limits, see `experimental.asymptotic_expand` an ## Logic and real quantifier elimination ```python -from alkahest import And, Or, Not, Exists, Forall, decide, satisfiable +from alkahest import And, Or, Not, Exists, Forall, decide, satisfiable, CadError # Predicates come from the pool, not Python comparison operators pos = pool.gt(x, pool.integer(0)) @@ -419,11 +420,56 @@ satisfiable(And(pos, lt1)) # {'x': '1/2'} — witness as a rational string decide(Forall(x, pool.ge(x**2, pool.integer(0)))) # (True, None) # decide takes ONE bound symbol (not a list) and returns (truth, witness_or_none) +# ...OR RAISES CadError. See below — this is not an optional detail. # Cylindrical algebraic decomposition primitives from alkahest import cad_project, cad_lift ``` +### `decide` is NOT complete — it refuses (`E-CAD-001`) + +Always wrap `decide` in `try/except ak.CadError`. It covers polynomial bodies over ℚ in +**at most two real variables** with a quantifier prefix of **at most two**; anything +outside that raises `E-CAD-001`. Inside the fragment there is a second refusal that +matters more: + +The CAD sample set is made of rational points. For a **strict** atom (`<`, `>`) that is +complete, because strict solution sets are open. For a **non-strict** atom (`=`, `≠`, +`≤`, `≥`) the solution set can be a single boundary point, and if that point is +irrational it is never sampled. Rather than report an unsatisfiability it never checked +there — which via `∀x. φ ≡ ¬∃x. ¬φ` would become a proof of a *false universal theorem* — +`decide` refuses. + +```python +# rational double root at x = -2/3: found exactly, real verdict +body = pool.gt((pool.integer(3)*x + pool.integer(2))**pool.integer(2), pool.integer(0)) +decide(Forall(x, body)) # (False, None) + +# irrational double root at ±sqrt(2): refuses +irr = pool.gt((x**pool.integer(2) - pool.integer(2))**pool.integer(2), pool.integer(0)) +try: + decide(Forall(x, irr)) +except CadError as e: + print(e.code) # E-CAD-001 +``` + +Rules for agents: + +- **`E-CAD-001` means "undecided", never "false".** Do not report it to the user as a + disproof, and do not record it as a closed branch in a search. +- **Witnesses are verified.** `(True, {...})` means the point was substituted back and + checked. `∃x. 3x−2=0` → `(True, {'x': '2/3'})`; `∃x. x²=2` → `(True, None)`, because no + *rational* witness exists. A `None` witness with a `True` verdict is normal, not a bug. +- **Mixed alternation refuses more often.** `∀x∃y. p > 0` is decided via `¬∃x∀y. p ≤ 0`, + and De Morgan turns a strict body non-strict. +- **Both 3.7 bugs here were silent errors.** Through 3.7, `∀x. (3x+2)² > 0` returned + `True` (it is false at `x = −2/3`), and existential witnesses were interval midpoints + that did not satisfy the sentence. If you have `decide` results from 3.7, re-run them. + +Escalation when `decide` refuses: `sos_decompose` / `prove_nonneg` for a positivity +certificate, `alkahest.smt` (z3's `nlsat` is complete over the reals), or +`bound_on_box` / `verified_sign` if a rigorous statement over a box is enough. + --- ## Substitution and pattern matching @@ -674,11 +720,11 @@ A.hadamard(B) # elementwise product ```python R.det() # symbolic determinant R.trace() # Expr -R.rank() # int +R.rank() # int (may raise E-LINALG-010 — see below) R.transpose() # Matrix -R.inverse() # Matrix (raises MatrixError if singular) +R.inverse() # Matrix (E-MAT-003 if proven singular, E-MAT-004 if undecidable) R.rref() # list[list[Expr]] — reduced row echelon form -R.nullspace() # basis of the kernel +R.nullspace() # basis of the kernel (may raise E-LINALG-010) R.column_space(), R.row_space() R.eigenvals() # dict: eigenvalue Expr → algebraic multiplicity @@ -691,7 +737,7 @@ R.matrix_exp() # symbolic matrix exponential R.simplify() # simplify every entry ``` -Three methods have narrower domains than the rest and raise rather than guess — +Some methods have narrower domains than the rest and raise rather than guess — handle the error instead of assuming they apply: | Method | Raises when | Code | @@ -699,11 +745,62 @@ handle the error instead of assuming they apply: | `diagonalize()` | matrix is defective (fewer independent eigenvectors than the multiplicity) | `E-EIGEN-005` | | `minimal_polynomial()` | entries contain free symbols | `E-LINALG-004` | | `rational_canonical_form()` | any entry is not a rational constant | `E-LINALG-009` | +| `rank()`, `rref()`, `nullspace()`, `eigenvects()`, `jordan_form()` | an entry's vanishing can be proven **neither** zero nor non-zero | `E-LINALG-010` | +| `inverse()` | the determinant's vanishing cannot be decided | `E-MAT-004` | So `minimal_polynomial` and `rational_canonical_form` are **numeric-matrix only**; for symbolic matrices use `characteristic_polynomial_lambda_minus_m` or `jordan_form`. +### The three-valued zero test (new in 3.8) + +Elimination needs to know whether a pivot is zero. Alkahest's answer is three-valued — +*proven zero*, *proven non-zero*, *undecidable* — and the third case **refuses**: + +```python +import alkahest as ak + +pool = ak.ExprPool() +a = pool.symbol("a") +zero, one = pool.integer(0), pool.integer(1) +opaque = pool.func("mystery", [a]) # no eval rule → vanishing undecidable + +try: + ak.Matrix([[opaque, zero], [zero, zero]]).nullspace() +except ak.LinearAlgebraError as e: + print(e.code) # E-LINALG-010 + print(e.remediation) # substitute concrete values for the parameters + +try: + ak.Matrix([[opaque, zero], [zero, one]]).inverse() +except ak.MatrixError as e: + print(e.code) # E-MAT-004 +``` + +Before 3.8, "could not prove `det ≠ 0`" was read as "`det = 0`", and `nullspace()` +returned a **confident wrong basis** for any 2×2 with a symbolic determinant. If you +have results computed with 3.7 that came from `nullspace` on symbolic entries, recheck +them: verify `M @ v == 0` numerically rather than trusting the dimension. + +`LinearAlgebraError` and `EigenError` are subclasses of `MatrixError`, so +`except ak.MatrixError` catches all three; `eigenvects()` raises `EigenError` carrying +code `E-LINALG-010` (the code names what could not be decided, not the wrapper). + +### `eigenvals()` — two traps + +1. **Casus irreducibilis.** For a 3×3 with an irreducible cubic characteristic + polynomial and three real roots, `eigenvals()` returns the Cardano form, in which one + cube root has a **negative radicand**. That expression is correct under the *real* + cube-root convention; Alkahest is consistent about it and refuses to evaluate it + (`eval_expr` → `E-EVAL-009`, `interval_eval` → an unbounded ball). Hand the same + expression to SymPy/NumPy and the **principal** branch is taken instead: you get a + confident number that is not an eigenvalue. Never export a radical eigenvalue to + another tool without evaluating it in Alkahest first; prefer exporting a verified + numeric enclosure (`refine_root`, `interval_eval`). +2. **It is not idempotent in memory.** `eigenvals()` interns a fresh gensym per call, so + calling it repeatedly on the *same* matrix grows the pool by ~1.9 KB each time. Cache + the result. + Symbolic eigenvalues are closed-form for 2×2 and, since 3.7.0, for parametric 3×3 matrices whose characteristic polynomial is an irreducible cubic (Cardano / trigonometric path). @@ -818,6 +915,157 @@ ak.get_context_value("any_key") --- +## Budgets, cancellation, and determinism + +Use these whenever you write a loop that calls Alkahest many times. A `Budget` is an +immutable `(wall_ms, max_steps, seed)` triple pushed by `context(budget=…)`. + +```python +import alkahest as ak + +with ak.context(pool=pool, budget=ak.Budget(wall_ms=300, max_steps=50_000, seed=7)): + try: + r = ak.integrate(hard_expr, x) + except ak.BudgetExceededError as e: + e.code # E-BUDGET-001 wall clock | -002 max_steps | -003 cancelled + # deprioritise this candidate; DO NOT record it as "no antiderivative" + +ak.request_cancel() # process-wide flag, e.g. from a watchdog thread +ak.is_cancelled() # read it +ak.clear_cancel() # always clear it in a finally: +ak.budget_seed() # the active budget's seed, for reproducible sampling +ak.active_budget(), ak.is_budget_active() +``` + +What actually honours a budget today: **`integrate` and `limit`** (they raise +`BudgetExceededError`) and **`simplify`** (no error channel — it stops early and returns +the best value so far, silently). Gröbner bases, homotopy continuation and the other +heavy primitives do **not** check it yet. + +`integrate` and `limit` also **release the GIL** around their core call, so +`request_cancel()` from another thread reaches one that is already running. Nothing else +does, so a running Gröbner basis cannot be cancelled. + +Three limits to state plainly, because they change what you should write: + +1. **`wall_ms` is cooperative.** The call stops at the first checkpoint *after* the + deadline. Typical overshoot is a small additive term (a `wall_ms=300` budget trips at + ~320 ms), but the granularity is one primitive polynomial operation, and on a + high-degree integrand that operation is a **FLINT** call which nothing can interrupt — + there a 300 ms budget can return after ~2 s. +2. **`run_with_wall_fallback` does not bound wall time for an uncooperative callee.** It + joins its worker before raising, so it returns when the callee returns. + `ak.run_with_wall_fallback(time.sleep, 3.0, budget=ak.Budget(wall_ms=50))` raises + `E-BUDGET-001` after **3000 ms**. Use it to turn `simplify`'s silent truncation into a + coded error, not to contain an unknown callee. The only hard bound is an **OS-level + timeout** (subprocess / process watchdog). +3. **Budget frames are thread-local; the cancel flag is process-wide.** A + `ThreadPoolExecutor` you create yourself runs unbudgeted unless you re-enter the budget + inside the worker. `batch_map` does that for you. + +## Batch fan-out (`batch_map`, `*_many`) + +```python +from alkahest import batch_map, batch_map_iter, integrate_many, simplify_many, diff_many + +outs = ak.integrate_many([x**2, ak.log(ak.log(x)), ak.sin(x)], x, parallel=True) +for item in outs: # BatchItem(index, ok, value, error, elapsed_ms) + if item.ok: + use(item.value) # a DerivedResult + elif item.error["code"].startswith("E-BUDGET-"): + requeue(item.index) # resource limit — undecided + else: + close(item.index, item.error) # a verdict about the mathematics +``` + +- **A batch never raises for one bad element** and never drops a slot; the exception is + captured into `item.error` with the failing exception's own `E-*` code + (`E-BATCH-001` when it has none). +- `batch_map` returns in **input order** either way. `batch_map_iter` streams in input + order when sequential, completion order when `parallel=True`. +- Under `parallel=True` the active budget is snapshotted and re-entered in each worker. + `wall_ms` stays one sweep-wide deadline; `max_steps` becomes **per item**. +- One item tripping its budget never cancels its siblings. `request_cancel()` does cancel + everything in the process — that is the point of it being process-wide. + +## Autoresearch modules: `ansatz`, `crosscheck`, `smt` + +All three are `alkahest.`; they resolve on attribute access, no separate import +needed. + +```python +# --- alkahest.ansatz: guess a shape, let the CAS pin the constants --- +from alkahest.ansatz import polynomial, rational, exponential_polynomial, \ + linear_combination, quadratic_form, fit, enumerate_family, certify_nonneg + +A = polynomial(pool, [x], degree=2) # c_0 + c_1*x + c_2*x^2 +sol = fit(A, A.expr - (x**2 - pool.integer(3)*x + pool.integer(2))) +sol.expr # (2 + x^2 + (x * -3)) +sol.status # 'exactly_verified' +sol.rank, sol.free, sol.assignment, sol.residual, sol.certificate +# No member of the family fits -> AnsatzError E-ANSATZ-003. That is a CLOSED BRANCH +# for this family, not a proof that no such object exists. + +# --- alkahest.crosscheck: differential-test against another CAS (SymPy) --- +c = ak.crosscheck.check("integrate", x**2, x) +c.outcome # 'agree' | 'diverge' | 'incomparable' | 'unavailable' +report = ak.crosscheck.sweep(cases=50, seed=7) # seeded, reproducible +report.summary() +ak.crosscheck.oracles() # which oracles are installed +ak.crosscheck.to_sympy(expr) # one-way translation +# 'unavailable' = no oracle installed. It is NEVER reported as agreement. +# SWEEP_OPERATIONS is ('diff', 'integrate', 'simplify') — narrower than OPERATIONS. + +# --- alkahest.smt: hand a discrete / mixed int-real subproblem to z3 or cvc5 --- +n = pool.symbol("n", "integer") +f = ak.And(pool.gt(x, n), pool.lt(x * x, pool.integer(10))) +ak.smt.supported(f).recommendation # 'smt' | 'prefer_in_tree' — ask BEFORE solving +print(ak.to_smtlib(f)) # SMT-LIB 2 text; works with no solver installed +res = ak.smt.solve(f, budget=ak.Budget(wall_ms=5000)) +res.status # 'sat' | 'unsat' | 'unknown' +res.model # exact Fractions — substituted back and checked in-process +``` + +Trust rules for `smt`, which an agent must not blur: + +- **`sat` is checked** (`verification["status"] == "exactly_verified"`): the model was + substituted back and evaluated exactly. A model that fails raises `E-SMT-004`. +- **`unsat` is `externally_asserted`** — nothing in Alkahest verified it, and it is + deliberately excluded from `research.MACHINE_CHECKED_STATUSES`. Report it as "z3 says + unsat", not as proved. +- **Algebraic-number witnesses are refused** (`E-SMT-003`) rather than converted to + floats. Do not work around this by evaluating the `root-obj` yourself. +- `smt.solve` takes **quantifier-free** formulas; `to_smtlib` exports quantified ones. + +## Memory: `ExprPool` never reclaims + +There is no `clear`, no refcount and no GC. **The only way to free interned nodes is to +drop the whole pool**, and every `Expr` / `Matrix` / `Series` / `DerivedResult` holds a +strong reference to its pool — so keeping one result keeps every node ever interned. + +Growth on a shared pool is linear and unbounded (~200 bytes/node; ~2–3.5 KB per +`integrate` call) while per-call **latency stays flat**, so a long loop OOMs with no +slowdown to warn you. There is no `len()` on `ExprPool`, so you cannot watch it either. + +Write loops like this: + +```python +for problem in problems: + pool = ak.ExprPool() # fresh pool per problem + x = pool.symbol("x") + with ak.context(pool=pool, budget=ak.Budget(wall_ms=500)): + r = ak.integrate(build(pool, problem), x) + record(r.to_dict(mode="compact")) # a plain dict outlives the pool + del pool, x, r # dropping the pool reclaims everything +``` + +Never carry a live `Expr` between iterations — `to_dict()` / `to_json()` / `str()` exist +partly for this. One operation grows even on identical input: `Matrix.eigenvals()` +(fresh gensym per call, ~1.9 KB); cache it. And the LLVM (`+jit` / `+full`) JIT leaks an +LLVM context per compile, so do not compile in a loop under those wheels. + +--- + ## Error handling All errors inherit `AlkahestError` and carry `.code`, `.remediation`, `.span`. @@ -825,17 +1073,34 @@ All errors inherit `AlkahestError` and carry `.code`, `.remediation`, `.span`. | Exception | Code prefix | Trigger | |-----------|-------------|---------| | `ConversionError` | `E-POLY-*` | Expression is not polynomial | +| `DomainError` | `E-DOMAIN-*`, `E-EVAL-*` | Side condition violated; `E-EVAL-009` = undefined at this point | | `DiffError` | `E-DIFF-*` | Differentiation failed | -| `IntegrationError` | `E-INT-*` | No elementary antiderivative | -| `MatrixError` | `E-MAT-*` | Dimension mismatch, singular | +| `IntegrationError` | `E-INT-*` | No integration rule (`E-INT-001`); proven non-elementary (`E-INT-004`) | +| `LimitError` | `E-LIMIT-*` | Limit could not be established | +| `SeriesError` | `E-SERIES-*` | Series expansion failed | +| `MatrixError` | `E-MAT-*` | Shape mismatch, proven singular (`E-MAT-003`), **undecidable determinant (`E-MAT-004`)** | +| `LinearAlgebraError` | `E-LINALG-*` | *Subclass of `MatrixError`.* Elimination / decompositions; **undecidable entry (`E-LINALG-010`)** | +| `EigenError` | `E-EIGEN-*` | *Subclass of `MatrixError`.* Eigen/Jordan; defective matrix (`E-EIGEN-005`) | +| `CadError` | `E-CAD-*` | **`decide` refused** — outside the fragment, or an untestable irrational boundary point | +| `SosError` | `E-SOS-*` | No positivity certificate of this shape/degree (`E-SOS-002`) | +| `HolonomicError` | `E-HOLO-*` | `zeilberger` outside the proper-hypergeometric class | +| `ValidatedError` | `E-VALIDATED-*` | Rigorous-bounds request unsupported / singular / malformed | | `OdeError` | `E-ODE-*` | ODE construction failed | | `DaeError` | `E-DAE-*` | DAE index reduction failed | | `JitError` | `E-JIT-*` | JIT compilation failed | | `SolverError` | `E-SOLVE-*` | Polynomial solver failed | +| `SumError` / `ProductError` | `E-SUM-*` / `E-PROD-*` | Summation / product failed | +| `PslqError` | `E-PSLQ-*` | Integer relation not justified by the input precision (`E-PSLQ-004`) | | `IoError` | `E-IO-*` | Pool checkpoint I/O | +| `PoolError` | `E-POOL-*` | Cross-pool or closed-pool misuse | | `NumberTheoryError` | `E-NT-*` | Invalid input to number-theory helpers | | `ParseError` | `E-PARSE-*` | String parse failures | | `RsolveError` | `E-RSOLVE-*` | Recurrence / `rsolve` failures | +| `BudgetExceededError` | `E-BUDGET-*` | `001` wall clock, `002` `max_steps`, `003` cancelled | +| `AnsatzError` | `E-ANSATZ-*` | Family construction or fitting; `003` = no member fits | +| `CrossCheckError` | `E-XCHECK-*` | Check could not be posed; `002` = no oracle installed | +| `SmtError` | `E-SMT-*` | Export/solver/model-lift; `003` = algebraic witness, `004` = model failed the check | +| `CertificateUnavailableError` | `E-CERT-*` | A Lean certificate was required but withheld | ```python from alkahest import ConversionError, IntegrationError @@ -848,6 +1113,21 @@ except ConversionError as e: print(e.remediation) # human-readable fix hint ``` +### Refusal vs verdict — the distinction that matters most + +Some codes are **refusals**: "I could not establish this, and the alternative to saying +so is a confident wrong answer." Others are **verdicts** about the mathematics. Never +report a refusal to the user as a negative result, and never record one as a closed +branch in a search. + +| Refusals (⇒ *undecided*) | Verdicts (⇒ a real answer) | +|---|---| +| `E-CAD-001`, `E-LINALG-010`, `E-MAT-004`, `E-SOS-002`, `E-ANSATZ-003`, `E-SMT-003`, `E-INT-001`, `E-LIMIT-003/005`, `E-BUDGET-001..003` | `E-INT-004` (proven non-elementary), `E-MAT-003` (proven singular), `E-EVAL-009` (undefined at this point), an `unsat` from `smt` (but only as *externally asserted*) | + +When Alkahest refuses, say so precisely: *"Alkahest declined to decide this (E-CAD-001); +it is not a disproof."* Then offer an escalation route rather than substituting an +unverified answer from elsewhere. + --- ## Available math functions @@ -1036,3 +1316,9 @@ reg.coverage_report_markdown() # same, rendered as a Markdown table 10. **Symbols from different pools are incompatible.** Keep one pool per computation graph. 11. **`plot*` functions detect the backend automatically.** Never import matplotlib/plotly in user code just to call `ak.plot` — let alkahest dispatch. Use `backend="plotly"` or `backend="matplotlib"` to force one. Use `plot_svg` when no plotting library is available. 12. **`plot_dag` returns a `graphviz.Source` if the `graphviz` package is installed, otherwise a raw DOT string.** Call `.render()` or `.view()` on the returned object, or pipe the string to `dot -Tpng`. +13. **A refusal is not a negative result.** `E-CAD-001`, `E-LINALG-010`, `E-MAT-004`, `E-SOS-002`, `E-ANSATZ-003`, `E-SMT-003` and every `E-BUDGET-*` mean *undecided by this route*. Say so explicitly; do not paraphrase them as "false", "no solution exists", or "not possible". See [Refusal vs verdict](#refusal-vs-verdict--the-distinction-that-matters-most). +14. **`decide` can raise.** Always `try/except ak.CadError`. It is not complete: ≤ 2 variables, ≤ 2 quantifiers, and it refuses at irrational boundary points. +15. **One pool per problem in any loop.** `ExprPool` never reclaims and holding any `Expr` pins the whole pool. Carry `to_dict(mode="compact")` between iterations, not live expressions. +16. **Bound long calls with `context(budget=…)`, not `run_with_wall_fallback`.** The latter joins its worker and so does not bound wall time for an uncooperative callee. Only `integrate` and `limit` honour the cooperative budget and release the GIL. +17. **Do not export radical results to another CAS without evaluating them here first.** Casus-irreducibilis cube roots from `eigenvals()` are correct in Alkahest and wrong under a principal-branch evaluator. An `E-EVAL-009` or an infinite ball is the signal not to export as-is. +18. **`0 · 0⁻¹` is left unevaluated on purpose** (since 3.8). If you see `(0 * 0^-1)` in a result, that is Alkahest declining to give an indeterminate form a value — not a simplifier failure to work around. diff --git a/docs/features.md b/docs/features.md index 04cd177b..be04b9ce 100644 --- a/docs/features.md +++ b/docs/features.md @@ -175,7 +175,16 @@ Current stable feature surface. - Structured exception hierarchy with stable codes (`E-POLY-*`, `E-DIFF-*`, etc.) - Every exception: `.code`, `.message`, `.remediation`, `.span` -- Subsystems: ConversionError, DomainError, DiffError, IntegrationError, MatrixError, OdeError, DaeError, JitError, CudaError, PoolError, SolverError, LimitError, SeriesError, ProductError, DiophantineError, NumberTheoryError, EigenError, HomotopyError, DiffAlgError +- Subsystems: ConversionError, DomainError, DiffError, IntegrationError, MatrixError, LinearAlgebraError, EigenError, CadError, OdeError, DaeError, JitError, CudaError, PoolError, SolverError, SosError, HolonomicError, ValidatedError, LimitError, SeriesError, SumError, ProductError, PslqError, DiophantineError, NumberTheoryError, HomotopyError, DiffAlgError, BudgetExceededError, AnsatzError, CrossCheckError, SmtError, CertificateUnavailableError +- **Refusals are distinguished from verdicts.** `E-CAD-001`, `E-LINALG-010`, `E-MAT-004`, `E-SOS-002`, `E-ANSATZ-003`, `E-SMT-003` and `E-BUDGET-*` mean *undecided*, not *false* + +## Autoresearch modules + +- `alkahest.ansatz` — parametric families (`polynomial`, `rational`, `exponential_polynomial`, `linear_combination`, `quadratic_form`) with `fit`, `enumerate_family`, `certify_nonneg` +- `alkahest.crosscheck` — differential testing against an external CAS oracle: `check`, `sweep`, `run_frozen_corpus`, `to_sympy`, `register_oracle`; a missing oracle reports `unavailable`, never `agree` +- `alkahest.smt` — SMT-LIB 2 export (`to_smtlib`) and z3/cvc5 bridge (`solve`, `supported`, `solvers`); `sat` models are checked in-process, `unsat` is reported as `externally_asserted` +- `alkahest.research` — session claim graphs and provenance +- `Budget` / `context(budget=…)` / `request_cancel` / `batch_map` / `*_many` — bounded, cancellable, non-aborting fan-out ## Cross-CAS benchmarks diff --git a/docs/mdbook/src/ansatz.md b/docs/mdbook/src/ansatz.md index 20cdf682..07229d28 100644 --- a/docs/mdbook/src/ansatz.md +++ b/docs/mdbook/src/ansatz.md @@ -32,6 +32,17 @@ Everything here is **pure Python** composed from primitives that are already fas feature. The one path that needs Gröbner — a residual genuinely nonlinear in the unknowns — refuses with `E-ANSATZ-004` rather than degrading silently. +Two limits that follow from the primitives it is built on: + +- `Matrix.rref` uses the three-valued zero test, so a fit whose coefficient matrix + contains an entry that can be proven neither zero nor non-zero refuses with + `E-LINALG-010` rather than picking a pivot. Substituting concrete values for the + parameters is the remedy. +- **Enumerating a family costs pool.** `enumerate_family` and repeated `fit` calls intern + every candidate permanently — `ExprPool` never reclaims. Build the pool inside the + enumeration and drop it per family, or the search grows linearly and without bound. + See [`ExprPool` never reclaims](./budgets.md#exprpool-never-reclaims). + ## Honesty invariants **Solving may be heuristic; checking is exact.** The linear system is built by diff --git a/docs/mdbook/src/batch.md b/docs/mdbook/src/batch.md index 04b7cc80..5f26792d 100644 --- a/docs/mdbook/src/batch.md +++ b/docs/mdbook/src/batch.md @@ -66,9 +66,9 @@ def batch_map_iter(fn, items, *, parallel=False, max_workers=None, **kwargs) -> ``` Both call `fn(item, **kwargs)` once per item. `parallel=True` fans the calls out over a -`concurrent.futures.ThreadPoolExecutor`; some Alkahest hot paths (the parallel -simplifiers, NumPy evaluation) release the GIL for their native work, so a thread pool -can genuinely overlap them. For calls that hold the GIL throughout, `parallel=True` +`concurrent.futures.ThreadPoolExecutor`; some Alkahest hot paths (`integrate`, `limit`, +the parallel simplifiers, NumPy evaluation) release the GIL for their native work, so a +thread pool can genuinely overlap them. For calls that hold the GIL throughout, `parallel=True` mainly helps when `fn` itself does I/O or otherwise yields the GIL — it never makes anything *incorrect*, only sometimes not faster. @@ -132,6 +132,62 @@ with ak.context(pool=pool, budget=ak.Budget(wall_ms=50, max_steps=10_000, seed=7 outs = ak.integrate_many(candidates, x, parallel=True) ``` +This works under `parallel=True` as well as `parallel=False`, but the two are not +identical field-for-field, because a Rust budget frame lives on a **thread-local** +stack and a worker thread does not inherit its parent's. `batch_map` therefore +snapshots the active budget on the calling thread and re-enters it inside every +worker task: + +| Field | `parallel=False` | `parallel=True` | +|---|---|---| +| `wall_ms` | one deadline for the whole sweep (the caller's frame) | one deadline for the whole sweep, captured at the `batch_map` call | +| `max_steps` | one counter for the whole sweep | **per item** — the Rust step counter lives in the frame and is not readable from Python, so each worker counts from zero | +| `seed` | same value everywhere | same value everywhere | + +The `wall_ms` deadline is captured when `batch_map` is called, not when +`context(budget=…)` was entered — Python cannot read the frame's start instant — so +a batch launched partway through a budgeted block gets the full `wall_ms` again. +That is one budget's worth of slack for the whole fan-out, not per item. + +### A budget trip is not a mathematical verdict + +This is the reason the propagation matters more than the speed-up. Before it, a +fanned-out sweep ran completely unbudgeted, and the candidates a sequential sweep +reported as `E-BUDGET-001` came back as `E-INT-001` instead — the integrator's +verdict that *no elementary antiderivative exists*. A research loop records that as +a permanently closed branch, when in truth nothing was decided and the machine +merely ran out of the time it was given. `E-BUDGET-00x` is `Cause::Resource`; keep +the two apart when you interpret a `BatchItem`: + +```python +for item in outs: + if item.ok: + accept(item.value) + elif item.error["code"].startswith("E-BUDGET-"): + requeue(item.index) # ran out of budget — undecided, try again with more + else: + close(item.index, item.error) # a real verdict about the mathematics +``` + +### Cancellation across a batch + +`request_cancel()` needs no propagation — the flag is process-wide, so every worker +already sees it and a caller can abort a whole in-flight sweep with it (each item +then reports `E-BUDGET-003`). The converse is deliberate: **one item tripping its +budget never cancels its siblings.** `batch_map` never sets the flag itself; the +trip is recorded on the item that tripped, and the rest of the sweep runs out the +shared deadline. + +### One pool for the batch, not for the process + +A batch shares one `ExprPool` across all its items, which is right — the whole point is +that the items are related. What is *not* right is reusing that pool for the next batch, +and the next: `ExprPool` never reclaims, so a driver that keeps one module-scope pool and +runs `batch_map` in a loop grows linearly and forever at flat latency. Construct the pool +per batch and drop it, and carry `item.value.to_dict(mode="compact")` forward rather than +the `DerivedResult` itself (holding one pins the whole pool). See +[`ExprPool` never reclaims](./budgets.md#exprpool-never-reclaims). + ## See also - [Autoresearch / agent loops](./search-plumbing.md) diff --git a/docs/mdbook/src/budgets.md b/docs/mdbook/src/budgets.md index c8f41616..940bcecb 100644 --- a/docs/mdbook/src/budgets.md +++ b/docs/mdbook/src/budgets.md @@ -28,7 +28,7 @@ A `Budget` is an immutable `(wall_ms, max_steps, seed)` triple. Every field is o call entered with a bare `Budget()`. `context(budget=...)` pushes the budget into a **thread-local** stack on the Rust side -(`alkahest_core::budget`) for the scope of the `with` block, and pops it on exit — +(`alkahest_cas::budget`) for the scope of the `with` block, and pops it on exit — including on an exception, matching every other resource the context manager owns. Budgets nest like every other `context(...)` key: only the *innermost* frame is consulted, so a nested `context(budget=...)` **shadows** the outer one rather than @@ -47,21 +47,33 @@ with ak.context(pool=p, budget=ak.Budget(seed=1, max_steps=1000)): ## What checks the budget today -The Rust engines call a cheap cooperative checkpoint (`alkahest_core::budget::check`) at +The Rust engines call a cheap cooperative checkpoint (`alkahest_cas::budget::check`) at a handful of strategic points — not blanket-inserted into every loop: - **`alkahest.integrate`** — at the top-level entry (covers every route: algebraic, - Risch/transcendental, rational-function, log-derivative) and at the - `integrate_inner` recursion boundary that u-substitution and the rational-function - fallback re-enter. A trip here raises `BudgetExceededError` — integration has a - `Result` return type with a natural place to signal it. + Risch/transcendental, rational-function, log-derivative); at the `integrate_inner` + recursion boundary; at every `integrate_raw` entry, which the sum rule and the + constant-multiple rule recurse through, so a long sum is bounded *between* + summands; once per candidate of the derivative-divides u-substitution search + (each surviving candidate runs a full recursive `integrate`, and there are up to + twelve); at the stage boundaries of the rational-function route (normalisation, + Hermite reduction, Rothstein–Trager, the partial-fraction pass and each of its + irreducible factors); and inside the two Euclidean loops that dominate a hard + rational integrand — the ℚ[x] GCD used to reduce `A/D` to lowest terms and the + number-field GCD of Lazard–Rioboo–Trager. A trip raises `BudgetExceededError` — + integration has a `Result` return type with a natural place to signal it. + + Those last few are not decoration. See + [how tightly `wall_ms` binds](#how-tightly-wall_ms-binds) — before they existed a + 300 ms budget on `∫ cos x·sin¹²x/(sin⁹x + sin x + 1) dx` returned after 3.4 s, and + the same integrand at degree 40 never returned at all. - **`alkahest.limit`** — at every `limit_inner` recursion boundary, in the Gruntz comparability sweep, in the pole-clearing loop of the `x ↦ 1/t` substitution, and between Taylor coefficients of the local expansion (the loop that can grow without bound on nested radicals). `LimitError` is an exhaustive public enum and cannot grow a `Budget` variant without a major semver break, so a trip is reported internally as `LimitError::DepthExceeded` and the `E-BUDGET-*` cause is recovered out-of-band - (`alkahest_core::calculus::limits::last_budget_trip`); the Python binding raises + (`alkahest_cas::calculus::limits::last_budget_trip`); the Python binding raises `BudgetExceededError` exactly as `integrate` does. With **no** budget active the same paths are bounded by an internal work ceiling, so an unsolvable limit refuses with `LimitError` / `E-LIMIT-004` instead of running unboundedly. @@ -78,6 +90,51 @@ when no budget is active and cancellation has not been requested (an atomic load only if a budget is active — an `Instant::now()`), so it is safe to sprinkle at more call sites over time without a performance concern gating it. +## How tightly `wall_ms` binds + +`wall_ms` is **cooperative**: the call stops at the first checkpoint *after* the +deadline, so it always overshoots by however long the engine had left in the stretch +it was in. That makes the useful question "how long is the longest stretch", not "is +it exact" — and a budget whose overshoot grows without bound is not a budget at all. + +Measured on `∫ cos x·sinⁿx/(sin^d x + sin x + 1) dx`, the family that goes through +the Weierstrass half-angle substitution and then Rothstein–Trager (elapsed until the +trip, `wall_ms=300`): + +| integrand | before | now | +|---|---|---| +| `n=12, d=9` | 3384 ms | 344 ms | +| `n=16, d=9` | 2107 ms | 360 ms | +| `n=20, d=9` | 3967 ms | 345 ms | +| `n=24, d=9` | 3148 ms | 313 ms | +| `n=40, d=17` | **never returned** (killed at 90 s) | 305 ms | +| `1/(sin⁹x + sin x + 1)` | 110 s | 333 ms | + +and across budget sizes on the worst of them (`n=40, d=17`): 53 ms for `wall_ms=50`, +106 ms for 100, 305 ms for 300, 1071 ms for 1000, 3158 ms for 3000 — the overshoot +is a small additive term, not a multiple of the budget and not a function of the +problem size. + +**What is left, honestly.** The residual granularity is *one primitive polynomial +operation*, and past a certain degree that operation is a **FLINT** call — +factorisation over ℤ, or a bivariate resultant. Those are single foreign-function +calls: nothing short of an OS-level kill stops one part-way, and adding checkpoints +around them cannot help. On a degree-62 integrand (`1/(sin³¹x + sin x + 1)`) one +such call measured about 2 s, so a 300 ms budget there returns after roughly that +long. That is the honest floor, and only an **outer process timeout** goes below it — +not `run_with_wall_fallback`, which joins the same call rather than preempting it +([below](#it-does-not-bound-wall-time-for-an-uncooperative-callee)). + +(The pure-Rust loops that used to dominate — the ℚ[x] and number-field Euclidean +GCDs — *are* now checkpointed, which is what removed the growth. A per-step check +was also tried on the ℚ long division underneath them and measured no further +improvement, so it was dropped rather than kept for the look of it: it would only +have made `max_steps` count faster for nothing.) + +So the guarantee worth relying on is: *the budget is checked between operations, and +one operation on a high-degree integrand can take seconds*. It is not a hard +real-time deadline, and no cooperative mechanism can make it one. + ## Cancellation `alkahest.request_cancel()` sets a single **process-wide** flag — deliberately not @@ -104,34 +161,181 @@ finally: ak.clear_cancel() ``` +### The watchdog runs *while* the call runs + +For that example to mean anything, the watchdog thread has to be able to execute +during the call it is trying to cancel. `alkahest.integrate` and `alkahest.limit` — +the two budget-honouring engines — therefore **release the GIL** around their core +call (`py.allow_threads`, the same idiom `simplify_par` uses for its Rayon workers). +Without that the flag was only ever observed if it had been set *before* the call: +the watchdog could not run a single bytecode until the operation it wanted to stop +had already finished, which is the opposite of what a fan-out search loop needs. + +Two things that follow, and one that does not: + +- **Cancellation is cooperative, not preemptive.** The flag is observed at the + checkpoints listed above, so the call stops at the next one — not instantly. An + engine stretch with no checkpoint runs to its end. +- **Other calls still hold the GIL.** Only `integrate` and `limit` release it (plus + the parallel simplifiers and the compiled-function batch paths, for unrelated + reasons). `request_cancel()` cannot reach a running Gröbner basis or homotopy + continuation, because those do not check the budget at all yet. +- **Nothing about pool safety changes.** `ExprPool` is `Send + Sync` and interns + through a lock-free index; releasing the GIL around a call that holds only a shared + `&ExprPool` is strictly weaker than the concurrent Rayon access `simplify_par` + already performs on the same structure. + ## Determinism seed `Budget(seed=...)` doesn't do anything by itself — it makes the seed available via -`alkahest.budget_seed()` (Rust: `alkahest_core::budget::seed()`) to any RNG-consuming +`alkahest.budget_seed()` (Rust: `alkahest_cas::budget::seed()`) to any RNG-consuming sampler that chooses to consult it, instead of threading an explicit seed parameter through every call in a pipeline. Two runs entering `Budget(seed=7)` observe the same `budget_seed()` at every call site that reads it, so a search loop that seeds its own sampling from the ambient budget is reproducible run-to-run. +## Budgets and threads + +The budget frame is **thread-local**; the cancellation flag is **process-wide**. Every +surprise in this area follows from that pair, so it is worth stating plainly: + +- A worker thread does **not** inherit the budget its parent entered. Handing work to a + `concurrent.futures.ThreadPoolExecutor` yourself runs it unbudgeted unless you + re-enter the budget inside the worker. +- `alkahest.batch_map` / `batch_map_iter` / the `*_many` helpers do that for you under + `parallel=True`: the active budget is snapshotted on the calling thread and re-entered + in each worker task, so a trip is reported as `E-BUDGET-00x` on the item that tripped, + exactly as it would be sequentially. `wall_ms` stays a single sweep-wide deadline; + `max_steps` becomes per-item (the Rust step counter is not readable from Python). + See [Batch and streaming evaluation](./batch.md#combining-with-budgets). +- `alkahest.run_with_wall_fallback` likewise enters its `budget` argument *on the worker + thread* it spawns, so cooperative call sites there actually observe it. +- `request_cancel()` needs no propagation, and that cuts both ways: it stops every + in-flight cooperative call in the process, not just the one you had in mind. A single + candidate's budget trip therefore never sets it — nothing in `batch_map` touches the + flag. + ## The Python-layer wall-clock fallback Because `simplify` cannot raise through its own return type, `context(budget=...)` *alone* only bounds it the same way `max_iterations` already does — silently, by -returning early. If you need a hard deadline specifically on a call like that, -`alkahest.run_with_wall_fallback` is a **supplement**, not a replacement: it runs the -call on a worker thread and raises `BudgetExceededError` (`E-BUDGET-001`) if it doesn't -finish in time. +returning early. `alkahest.run_with_wall_fallback` turns that silent truncation into a +raised, coded error: it runs the call on a worker thread (with `budget` entered on that +thread) and raises `BudgetExceededError` (`E-BUDGET-001`) when the call overruns +`wall_ms`. ```python result = ak.run_with_wall_fallback(ak.simplify, big_expr, budget=ak.Budget(wall_ms=200)) ``` -Python cannot forcibly kill a thread, so on a timeout the call keeps running in the -background until it either finishes or reaches a Rust cooperative checkpoint — -`run_with_wall_fallback` also calls `request_cancel()` on timeout so any checkpoint the -call reaches asks it to stop. Prefer the Rust cooperative check alone -(`context(budget=...)`) wherever a call already honors it (`integrate` and `limit` -today); reach for this only when you need a hard deadline on a path that doesn't. +### It does not bound wall time for an uncooperative callee + +Read this before putting it in a loop. `run_with_wall_fallback` **joins its worker +before the exception propagates**, so it returns control when the callee returns — not +at `wall_ms`. Measured: `run_with_wall_fallback(time.sleep, 3.0, budget=Budget(wall_ms=50))` +raises `E-BUDGET-001` after **3000 ms**. The error message reports the real elapsed time +("returned control after 3000 ms") precisely so this shows up in a log instead of being +inferred later. + +For a callee that *does* reach a cooperative checkpoint the wait is short, because the +worker now runs inside the budget and stops on it (`integrate` on a hard integrand: +`wall_ms=300` returns in about 320 ms) — but that is the case where +`context(budget=...)` alone would already have bounded it. The uncooperative case, the +one this function looks like it exists for, is the one it cannot bound. + +Why not return at the deadline and let the worker run on? Python cannot kill a thread, +so "return early" means leaking a live thread that still takes the GIL in bursts, still +allocates into the pool, and can only be asked to stop through the **process-wide** +cancel flag — which aborts every unrelated in-flight call, and which nobody can then +clear safely (clearing it before the orphan observes it is a no-op; leaving it set makes +every later cooperative call fail with `E-BUDGET-003`). In a multi-day loop that trades a +bounded stall for unbounded orphan accumulation plus collateral cancellation. Joining is +the lesser evil, so it is what the function does. + +**What actually bounds wall time**, in order of preference: + +1. `context(budget=...)` around an engine that checks the cooperative budget — + `integrate` and `limit` today. This is the real mechanism; `run_with_wall_fallback` is + a reporting shim over it. +2. An **OS-level bound** for anything else: run the work in a subprocess with a timeout, + or put a process-level watchdog around the loop. Nothing inside one Python process can + preempt a thread mid-FLINT-call — see + [what is left, honestly](#how-tightly-wall_ms-binds). + +So reach for `run_with_wall_fallback` to get a *raise* out of a cooperatively-budgeted +call that would otherwise hand back a silently truncated answer. Do not reach for it to +contain an unknown callee. + +## `ExprPool` never reclaims + +`Budget` bounds *time* and *steps*. **Nothing bounds memory**, and the shape of the +memory growth is the single most likely way a multi-day loop dies. This section is as +important as everything above it. + +### The mechanism + +`ExprPool` is an **append-only** hash-consed arena. It has no `clear`, no `truncate`, no +refcount and no garbage collector; the underlying storage cannot shrink. **The only way +to reclaim interned nodes is to drop the entire pool.** And every `Expr`, `Matrix`, +`Series` and `DerivedResult` holds a *strong* reference to the pool it came from, so +keeping one interesting result alive keeps every node ever interned alive with it — which +is exactly the usage pattern a research loop has. + +Measured on this machine, 20 000 `integrate` calls with a distinct integrand each time: + +```text +one shared pool for the whole loop ...... 1 992 bytes/call, forever, linear +a fresh pool per iteration .............. 0 bytes/call +``` + +Two properties make this nastier than an ordinary leak: + +- **Time stays flat.** Per-call latency does not degrade as the pool grows, so there is + no early warning — the loop runs at full speed until the OOM killer arrives. Growth is + O(n) in memory with O(1) time. +- **You cannot measure it from Python.** `ExprPool` exposes no `__len__` and no `stats()`, + so a loop cannot watch its own footprint and decide to recycle. + +Per-call cost depends on the operation. As a rough guide, roughly 200 bytes of resident +memory per interned node, and on the order of 0.8 KB/call for `diff` or `simplify`, +2–3.5 KB for `integrate`, ~8 KB for a `crosscheck.check`, ~12.5 KB for a `series` of +order 6. At one `integrate` per second on one pool that is gigabytes within a day. + +### The supported pattern: one pool per problem + +```python +import alkahest as ak + +for problem in problems: + pool = ak.ExprPool() # fresh pool per iteration + x = pool.symbol("x") + with ak.context(pool=pool, budget=ak.Budget(wall_ms=500)): + result = ak.integrate(build(pool, problem), x) + record(str(result.value)) # keep a *string* / dict, not the Expr + del pool, x, result # dropping the pool reclaims everything +``` + +The critical line is `record(str(result.value))`. Holding the `Expr` (or the +`DerivedResult`, or a `Matrix` derived from it) pins the pool and defeats the whole +scheme. `DerivedResult.to_dict()` / `.to_json()` exist partly for this: they give you a +plain-Python envelope that outlives the pool. Do not carry live `Expr` handles between +iterations of an unattended loop; re-parse or rebuild them in the new pool if you need +them again. + +### One operation grows even on identical input + +`Matrix.eigenvals()` mints a fresh gensym (`__eigen_lambda_N`) into the pool on **every** +call, so re-asking the same eigenvalue question keeps allocating for no new information — +measured at about 1.9 KB/call on the same 2×2 integer matrix over 20 000 calls, where +`simplify` on identical input is exactly 0. Cache eigenvalue results yourself rather than +recomputing them in a loop. (Every other Python-facing entry point measured is flat on +repeated input.) + +### If you enable the LLVM JIT + +The `jit` (LLVM) feature leaks a whole LLVM `Context` per compile — a true leak with no +pool to drop, on the error paths as well as the success path. Cranelift (the default +wheel's JIT) is unaffected. Do not compile in a loop under a `+jit` / `+full` build. ## Error codes @@ -141,7 +345,7 @@ today); reach for this only when you need a hard deadline on a path that doesn't | `E-BUDGET-002` | The active budget's step counter exceeded `max_steps` | | `E-BUDGET-003` | `request_cancel()` was called and not yet cleared | -All three are `Cause::Resource` in the Rust registry (`alkahest_core::errors::codes`) — +All three are `Cause::Resource` in the Rust registry (`alkahest_cas::errors::codes`) — a budget/cancellation trip is an environment/policy limit, not a statement about the mathematics, so it is never conflated with e.g. `IntegrationError::NonElementary` (a proof that no elementary antiderivative exists). `alkahest.integrate` and @@ -170,16 +374,17 @@ except ak.IntegrationError as e: | `request_cancel()` | function | Set the process-wide cancellation flag | | `clear_cancel()` | function | Clear it | | `is_cancelled()` | function | Read it | -| `run_with_wall_fallback(fn, *args, budget, **kwargs)` | function | Python-layer wall-clock fallback for calls without a Rust checkpoint | +| `run_with_wall_fallback(fn, *args, budget, **kwargs)` | function | Raises `E-BUDGET-001` when `fn` overruns `wall_ms` — after joining its worker, so it does **not** bound wall time for an uncooperative callee ([above](#it-does-not-bound-wall-time-for-an-uncooperative-callee)) | | `BudgetExceededError` | exception | `E-BUDGET-001..003`, subclass of `AlkahestError` | -On the Rust side (`alkahest_core::budget`): `Budget`, `enter`, `BudgetGuard`, `check`, +On the Rust side (`alkahest_cas::budget`): `Budget`, `enter`, `BudgetGuard`, `check`, `seed`, `is_active`, `request_cancel`, `clear_cancel`, `is_cancelled`, `BudgetError`. ## See also - [Autoresearch / agent loops](./search-plumbing.md) - [Batch and streaming evaluation](./batch.md) — budgets compose with `*_many` / - `batch_map`; a trip becomes one failed `BatchItem`, not a killed process + `batch_map`, including under `parallel=True`; a trip becomes one failed `BatchItem` + carrying `E-BUDGET-00x`, not a killed process and not a mathematical verdict - [Error handling](./errors.md) — `E-BUDGET-*` in the exception hierarchy - [Claim graphs](./claim-graphs.md) — session-level provenance around budgeted calls diff --git a/docs/mdbook/src/claim-graphs.md b/docs/mdbook/src/claim-graphs.md index a98ec91b..6a63ae5e 100644 --- a/docs/mdbook/src/claim-graphs.md +++ b/docs/mdbook/src/claim-graphs.md @@ -206,6 +206,20 @@ report.summary() # {'ok': 2, 'numeric_ok': 1, 'skipped': 1} print(report.to_markdown()) ``` +## A long session accumulates, by design + +Two things grow monotonically for as long as a session is open, and neither is a leak — +both are the feature working. + +- **The claim graph** holds one claim per captured operation, plus one string per capture + failure. That is the point of a provenance record, but it means `capture=True` around a + million-call sweep builds a million-entry graph in memory. Snapshot with `to_dict()` / + `to_markdown()` and start a new session periodically rather than running one session for + the life of the process. +- **The pool** never reclaims, and a session pins one for its whole lifetime. See + [`ExprPool` never reclaims](./budgets.md#exprpool-never-reclaims) — a session scoped to + one problem, with its own pool, is the pattern that survives a multi-day run. + ## A complete loop [`examples/pslq_research_loop.py`](https://github.com/alkahest-cas/alkahest/blob/main/examples/pslq_research_loop.py) diff --git a/docs/mdbook/src/crosscheck.md b/docs/mdbook/src/crosscheck.md index f687dd6a..98fbd926 100644 --- a/docs/mdbook/src/crosscheck.md +++ b/docs/mdbook/src/crosscheck.md @@ -230,17 +230,27 @@ useful as a bug report if the run that found something can be reproduced exactly [budget](./budgets.md), so a nightly job and a local reproduction share one knob. `SweepReport.to_dict()` is JSON-serialisable and suitable for filing as a CI artifact. -**Neither side of a check is bounded, and this module does not pretend otherwise.** The -heavy engines hold the GIL, so a non-terminating call cannot be timed out from Python — a -worker thread cannot be stopped, and abandoning one wedges the interpreter just the same. -At this commit `limit(sqrt(x**2 + x) - x, x, oo)` is one such call. So: +**Give each sweep its own pool.** A `sweep` interns thousands of generated expressions, +and `check` costs on the order of 8 KB of pool per call — the highest of any entry point, +because it builds both sides plus the comparison. `ExprPool` never reclaims, so a nightly +job that reuses one module-scope pool across runs grows without bound; construct the pool +inside the sweep's scope and drop it afterwards. (SymPy's own global cache also warms up +to a few MB and then stops; that part is bounded.) See +[`ExprPool` never reclaims](./budgets.md#exprpool-never-reclaims). + +**Neither side of a check is bounded, and this module does not pretend otherwise.** Most +heavy engines hold the GIL, so a non-terminating call in one of them cannot be timed out +from Python — a worker thread cannot be stopped, and abandoning one wedges the +interpreter just the same. SymPy is no better placed. So: - run the nightly job under an **OS-level timeout**; - wrap the sweep in `context(budget=…)` for the engines that *are* cooperative - (`integrate`, and best-effort `simplify` — see [Budgets](./budgets.md)), where a trip - surfaces as `reason="alkahest_refused"` with an `E-BUDGET-00x` code, which is a fine - answer; -- `SWEEP_OPERATIONS` deliberately excludes `limit` for this reason. + (`integrate` and `limit`, and best-effort `simplify` — see [Budgets](./budgets.md)), + where a trip surfaces as `reason="alkahest_refused"` with an `E-BUDGET-00x` code, which + is a fine answer. Those two also release the GIL for their core call, so + `request_cancel()` from a watchdog thread reaches one that is already running; +- `SWEEP_OPERATIONS` still excludes `limit`, now only because the comparator for it is + weaker than for the three it does cover — not because the call cannot be bounded. ### Tier 2 — the frozen corpus diff --git a/docs/mdbook/src/derivations.md b/docs/mdbook/src/derivations.md index dca5d1dc..c2344391 100644 --- a/docs/mdbook/src/derivations.md +++ b/docs/mdbook/src/derivations.md @@ -52,7 +52,7 @@ for step in dr.steps: A side condition is a predicate that must hold for a rewrite to be sound: - `Positive(x)` — `x` must be positive (e.g. for `sqrt(x²) → x`) -- `NonZero(x)` — `x` must be non-zero (e.g. for `x/x → 1`) +- `NonZero(x)` — `x` must be non-zero (e.g. for `x/x → 1`). For a *symbolic* `x` the rewrite fires and the condition is recorded; for a **literal** zero base it does not fire at all, since `0 · 0⁻¹` has no value ([literal-zero carve-out](./simplification.md#the-literal-zero-carve-out)) - `Integer(n)` — `n` must be an integer (e.g. for some power rules) - `BranchCut(f, x)` — records that `f` may have a branch cut at `x` diff --git a/docs/mdbook/src/errors.md b/docs/mdbook/src/errors.md index bea62f59..bd0cf95b 100644 --- a/docs/mdbook/src/errors.md +++ b/docs/mdbook/src/errors.md @@ -10,7 +10,10 @@ AlkahestError (base) ├── DomainError (E-DOMAIN-*) — mathematical side conditions violated ├── DiffError (E-DIFF-*) — differentiation failed ├── IntegrationError (E-INT-*) — integration failed -├── MatrixError (E-MAT-*) — linear algebra errors +├── MatrixError (E-MAT-*) — matrix shape / singularity / undecidable determinant +│ ├── LinearAlgebraError (E-LINALG-*) — elimination, decompositions, canonical forms +│ └── EigenError (E-EIGEN-*) — eigenvalues, eigenvectors, Jordan form +├── CadError (E-CAD-*) — real quantifier elimination, see [Positivity](./positivity.md#decide-refuses-rather-than-guessing) ├── OdeError (E-ODE-*) — ODE construction or lowering ├── DaeError (E-DAE-*) — DAE structural analysis ├── SolverError (E-SOLVE-*) — polynomial system solving @@ -88,6 +91,54 @@ Raised when a mathematical side condition is violated. | `E-SOLVE-002` | High-degree univariate factor (> 2) | Symbolic solution not supported; use numerical solve | | `E-SOLVE-003` | Gröbner basis did not terminate | Increase node/iteration limits | +### Refusals: when Alkahest declines to answer + +A refusal is not a malfunction. These codes all mean *"I could not establish this, and +the alternative to saying so is a confident wrong answer"* — the outcome an unattended +loop must record as **undecided**, never as a negative result. + +| Code | Class | What it means | +|---|---|---| +| `E-LINALG-010` | `LinearAlgebraError` | An entry's vanishing could be proven neither zero nor non-zero, so `rank` / `rref` / `nullspace` / `eigenvects` / `jordan_form` declined to pick a branch | +| `E-MAT-004` | `MatrixError` | Same, for a determinant: `inverse()` will not divide by something it cannot show is non-zero | +| `E-CAD-001` | `CadError` | `decide` is outside its fragment, or the only candidate solutions lie at an irrational boundary point it cannot test exactly | +| `E-SOS-002` | `SosError` | No positivity certificate of this shape at this degree — a statement about the search, not a proof that none exists | +| `E-INT-004` | `IntegrationError` | Proven non-elementary. **This one is a verdict, not a refusal** — keep it apart from the rest | +| `E-BUDGET-001..003` | `BudgetExceededError` | Ran out of the time/steps it was given, or was cancelled | + +The three-valued zero test behind `E-LINALG-010` / `E-MAT-004` is new in 3.8. Before it, +"could not prove `det ≠ 0`" was silently read as "`det = 0`", and `Matrix.nullspace()` +returned a confident wrong basis for any 2×2 with a symbolic determinant. + +```python +import alkahest as ak + +pool = ak.ExprPool() +a = pool.symbol("a") +zero, one = pool.integer(0), pool.integer(1) + +# `mystery` has no evaluation rule, so its vanishing is genuinely undecidable. +opaque = pool.func("mystery", [a]) +m = ak.Matrix([[opaque, zero], [zero, one]]) + +try: + m.inverse() +except ak.MatrixError as e: + print(e.code) # E-MAT-004 + print(e.remediation) # substitute concrete values for the parameters + +try: + ak.Matrix([[opaque, zero], [zero, zero]]).nullspace() +except ak.LinearAlgebraError as e: + print(e.code) # E-LINALG-010 +``` + +`LinearAlgebraError` and `EigenError` are both subclasses of `MatrixError`, so +`except ak.MatrixError` catches all three families; catch the subclass when you want to +distinguish them. Note that `eigenvects()` raises `EigenError` — with code +`E-LINALG-010`, because the code identifies *what could not be decided*, not which +wrapper it surfaced through. + ## Catching errors by code For programmatic error handling: @@ -114,7 +165,10 @@ Every error is classified on two independent axes: **subsystem** (determines the | `E-DOMAIN-*` | `DomainError` | Side-condition violations (div-by-zero, log of 0, `sqrt` of negative) | | `E-DIFF-*` | `DiffError` | Forward/reverse differentiation, unknown derivatives | | `E-INT-*` | `IntegrationError` | Symbolic integration (Risch, heuristic, table) | -| `E-MAT-*` | `MatrixError` | Linear algebra (shape, singular, non-invertible) | +| `E-MAT-*` | `MatrixError` | Matrix shape, proven-singular, non-invertible, and (`E-MAT-004`) an undecidable determinant | +| `E-LINALG-*` | `LinearAlgebraError` *(subclass of `MatrixError`)* | Elimination, decompositions, canonical forms; `E-LINALG-010` is the undecidable-entry refusal | +| `E-EIGEN-*` | `EigenError` *(subclass of `MatrixError`)* | Eigenvalues, eigenvectors, Jordan form, diagonalisation | +| `E-CAD-*` | `CadError` | Real quantifier elimination — outside the fragment, or an untestable irrational boundary point | | `E-ODE-*` | `OdeError` | ODE construction, lowering, event handling | | `E-DAE-*` | `DaeError` | DAE structural analysis (Pantelides, index reduction) | | `E-SOLVE-*` | `SolverError` | Polynomial system solving, Gröbner basis | diff --git a/docs/mdbook/src/interop.md b/docs/mdbook/src/interop.md index 7fa4c211..bd1eb5c4 100644 --- a/docs/mdbook/src/interop.md +++ b/docs/mdbook/src/interop.md @@ -93,7 +93,60 @@ mlir_text = to_stablehlo(expr, [x, y], fn_name="my_kernel") ## SymPy interop -Alkahest does not import SymPy at runtime. The integration is one-way for validation: the test oracle in `tests/test_oracle.py` uses SymPy as a ground truth reference. The recommended pattern for mixed workflows is to convert to/from string representation. +Alkahest's kernel does not import SymPy. Two supported bridges exist on top of it: +`alkahest.crosscheck.to_sympy` translates an `Expr` into a SymPy expression, and +[`alkahest.crosscheck`](./crosscheck.md) drives SymPy as a differential-testing oracle. +The test oracle in `tests/test_oracle.py` uses SymPy as a ground-truth reference. For +ad-hoc mixed workflows, converting through the string representation is fine. + +### The interop trap: casus-irreducibilis cube roots + +Read this before round-tripping a symbolic result into another CAS. **An expression can +be correct in Alkahest and evaluate to a wrong number somewhere else**, because the two +systems do not agree on which branch a cube root denotes. + +`Matrix.eigenvals()` on a 3×3 with an irreducible cubic characteristic polynomial and +three real roots returns the Cardano form, and in the *casus irreducibilis* one of the +cube-root radicands is negative: + +```python +import alkahest as ak + +pool = ak.ExprPool() +I = pool.integer +M = ak.Matrix.from_rows([[I(2), I(0), I(-2)], [I(2), I(0), I(-1)], [I(1), I(1), I(2)]]) + +for value in M.eigenvals(): + print(value) +# two conjugate-looking siblings, then: +# (4/3 + (sqrt(298/27) + -89/27)^(1/3) + (-89/27 + (-1 * sqrt(298/27)))^(1/3)) +``` + +That expression denotes the eigenvalue **under the real cube-root convention**. Alkahest +is consistent about this and honest at the boundary: `eval_expr` on it refuses with +`E-EVAL-009`, and `interval_eval` returns `ArbBall(1.629231 ± inf)` — an enclosure that +is true and useless, rather than a number that is neither. + +Hand the *same* expression to a principal-branch evaluator — SymPy, NumPy, most +calculators — and `(negative)^(1/3)` takes the principal complex root instead. You get a +confident number back, and it is not an eigenvalue. In one sweep of 720 random integer +matrices, 14 produced eigenvalues of this shape. + +So, when a loop exports symbolic results to another tool: + +- Prefer transporting a **verified numeric enclosure** (`refine_root`, `interval_eval`, + `bound_on_box`) rather than a radical expression, whenever the consumer only needs a + number. +- If you must transport the expression, evaluate it in Alkahest **first**. A refusal + (`E-EVAL-009`, or an infinite ball) is the signal that the expression is branch-sensitive + and must not be handed over as-is. +- Never treat "the other tool produced a float" as confirmation. Substitute the value back + into the characteristic polynomial (or whatever defined it) and check the residual. + +This is the general shape of the hazard, not a quirk of `eigenvals`: **an honest refusal +inside Alkahest becomes somebody else's silent error the moment the expression crosses the +boundary.** [`alkahest.crosscheck`](./crosscheck.md) reports exactly this situation as +`incomparable` rather than `diverge`, for the same reason. ## DLPack diff --git a/docs/mdbook/src/kernel.md b/docs/mdbook/src/kernel.md index 5d22147d..a9758233 100644 --- a/docs/mdbook/src/kernel.md +++ b/docs/mdbook/src/kernel.md @@ -69,6 +69,19 @@ Available domains: `real`, `positive`, `nonnegative`, `integer`, `complex`. The `ExprId` is a 32-bit index into the pool's internal arena. It is `Copy`, `Send`, and `Sync`. Cloning an `ExprId` is free. No reference counting is needed because the pool owns all nodes; expressions are not freed until the pool is dropped. +> **That last clause is a hard limit, not an implementation detail.** The arena is +> **append-only**: there is no `clear`, no `truncate`, no refcount and no GC, so nothing +> is ever reclaimed while the pool is alive, and the storage cannot shrink. A distinct +> expression costs roughly 200 bytes of resident memory per node, permanently. A loop +> that builds a module-scope pool once and then calls into it forever grows linearly and +> without bound, at flat per-call latency — so it OOMs with no slowdown to warn you +> first. Every `Expr`, `Matrix`, `Series` and `DerivedResult` holds a strong reference to +> its pool, so retaining one result retains the whole history. +> +> The supported pattern for unattended work is **one pool per problem**, dropped when the +> problem is done: see +> [Budgets → `ExprPool` never reclaims](./budgets.md#exprpool-never-reclaims). + The kernel is designed with parallelism as a first-class property. All kernel types are `Send + Sync`. The simplification and differentiation passes can run concurrently on disjoint `ExprId`s from the same pool. ## Interning cost model @@ -81,4 +94,6 @@ Interning a new node requires: Step 4 (the common case in a running computation) is a single hash lookup plus a pointer load. The arena uses bump allocation, so step 3 is also fast. -The memory benchmark group in `alkahest-core/benches/alkahest_bench.rs` verifies that rebuilding an identical expression tree does not grow the pool. +The memory benchmark group in `alkahest-core/benches/alkahest_bench.rs` verifies that rebuilding an *identical* expression tree does not grow the pool. Note the scope of that guarantee: it covers repeated work, not new work. A stream of *distinct* inputs grows the pool by every node it interns, and none of it comes back — see the warning under [ExprId and memory](#exprid-and-memory). + +One documented exception to "identical input does not grow the pool": `Matrix.eigenvals()` interns a fresh gensym per call, so it grows by about 1.9 KB per call even on the same matrix. Cache its result rather than recomputing. diff --git a/docs/mdbook/src/positivity.md b/docs/mdbook/src/positivity.md index 0d717718..774902e9 100644 --- a/docs/mdbook/src/positivity.md +++ b/docs/mdbook/src/positivity.md @@ -1,11 +1,17 @@ # Positivity certificates (SOS / Positivstellensatz) -`decide` answers real-algebraic questions **completely**, by CAD, and pays -doubly-exponential cost for that completeness. Most positivity questions that -actually arise — is this bound valid, is this Lyapunov candidate non-negative, -is this inequality true on a box — do not need completeness. They need a -**certificate**: a short algebraic identity that makes the answer checkable by -anyone, including a proof assistant. +`decide` answers real-algebraic questions by CAD, and pays doubly-exponential +cost for it. Most positivity questions that actually arise — is this bound +valid, is this Lyapunov candidate non-negative, is this inequality true on a +box — do not need a decision procedure at all. They need a **certificate**: a +short algebraic identity that makes the answer checkable by anyone, including a +proof assistant. + +> `decide` is **not** complete in this implementation: on some sentences it +> refuses with `E-CAD-001` rather than answering. See +> [`decide` refuses rather than guessing](#decide-refuses-rather-than-guessing) +> below — this changed in 3.8 and it changed because the alternative was +> answering wrongly. ```python import alkahest as ak @@ -102,14 +108,74 @@ search that produced the certificate. | | `sos_decompose` / `prove_nonneg` | `decide` (CAD) | |---|---|---| -| Answers | Non-negativity, with a certificate | Any real-algebraic sentence | -| Completeness | No — refuses honestly | Yes | +| Answers | Non-negativity, with a certificate | Real-algebraic sentences in ≤ 2 variables with a ≤ 2-quantifier prefix | +| Completeness | No — refuses honestly (`E-SOS-002`) | No — refuses honestly (`E-CAD-001`) | | Cost | LP in exact rationals | Doubly exponential | | Output | Checkable identity, Lean-exportable | Truth value (+ witness) | The intended pattern is: try the certificate route first because it is cheap -and its output is citable; fall back to `decide` on `E-SOS-002` when you need -the complete answer and can afford it. +and its output is citable; fall back to `decide` on `E-SOS-002` when you need a +verdict rather than a certificate and can afford the cost. Note that neither +route is complete, so "both refused" is a real and expected outcome — it means +*undecided by these methods*, not *false*. + +## `decide` refuses rather than guessing + +`decide` implements CAD over a **bounded fragment**: purely polynomial bodies over +ℚ in one or two real variables, with a quantifier prefix of at most two. Outside +that fragment it raises `CadError` (`E-CAD-001`). Inside it, there is one further +refusal, and it is the important one. + +The CAD sample set is built from rational points — bracket endpoints, refined +brackets, midpoints. For a **strict** atom (`<`, `>`) that is complete: strict +solution sets are open, so if a solution exists, a whole interval of rational +points solves it too. For a **non-strict** atom (`=`, `≠`, `≤`, `≥`) the solution +set can be a single boundary point, and if that point is irrational it is never in +the sample set. Concluding "no sample satisfied it, therefore unsatisfiable" would +then be a claim about a point that was never tested — and via `∀x. φ ≡ ¬∃x. ¬φ`, +that fabricated `false` becomes a machine-checked-looking proof of a false +universal theorem. + +So when a boundary root has not been shown rational and the body has a non-strict +atom, `decide` refuses: + +```python +import alkahest as ak + +pool = ak.ExprPool() +x = pool.symbol("x") + +# Rational double root: found exactly, so the verdict is real. +body = pool.gt((pool.integer(3) * x + pool.integer(2)) ** pool.integer(2), pool.integer(0)) +ak.decide(ak.Forall(x, body)) # (False, None) — false at x = -2/3 + +# Irrational double root at ±sqrt(2): refuses instead of answering. +irr = pool.gt((x ** pool.integer(2) - pool.integer(2)) ** pool.integer(2), pool.integer(0)) +try: + ak.decide(ak.Forall(x, irr)) +except ak.CadError as e: + print(e.code) # E-CAD-001 +``` + +Three consequences worth planning for: + +- **`E-CAD-001` is "I did not establish this", not "false".** A search loop must not + record it as a closed branch. It is the same class of answer as `E-SOS-002`. +- **Witnesses are verified.** When `decide` reports `(True, {...})` for an + existential, the point is substituted back and checked; if it does not satisfy the + sentence the witness is reported as `None` rather than as a certificate that fails. + `∃x. 3x − 2 = 0` gives `(True, {'x': '2/3'})`; `∃x. x² = 2` gives `(True, None)`, + because no rational witness exists and a midpoint of the isolating interval is + not one. +- **Mixed-alternation sentences refuse more often** than same-flavour ones. `∀x∃y. p > 0` + is decided through `¬∃x∀y. p ≤ 0`, and De Morgan turns a strict body into a + non-strict one, so it can land in the refusal case even though the original body + was strict. + +If you need an answer where `decide` refuses, the routes are: a positivity +certificate (above), `alkahest.smt` with a nonlinear-real solver, or rigorous +numerics ([validated bounds](./validated-bounds.md)) if a *quantified-over-a-box* +statement is good enough. ## Scope of this release diff --git a/docs/mdbook/src/python-api.md b/docs/mdbook/src/python-api.md index 440ba5ca..bf0b066e 100644 --- a/docs/mdbook/src/python-api.md +++ b/docs/mdbook/src/python-api.md @@ -10,8 +10,27 @@ Conceptual chapters for agent-facing plumbing: | Topic | Guide | |---|---| -| Budgets, cancellation, seeds | [Budgets](./budgets.md) | +| Budgets, cancellation, seeds, **pool lifetime** | [Budgets](./budgets.md) | | Batch / streaming fan-out | [Batch](./batch.md) | | Compact machine-parseable results | [Derivation logs](./derivations.md#machine-parseable-output-to_dict--to_json) | | Session provenance | [Claim graphs](./claim-graphs.md) | | Overview | [Autoresearch / agent loops](./search-plumbing.md) | + +## Submodules + +Not everything lives on the top-level namespace as a function. These are reached as +`alkahest.` and documented in their own chapters: + +| Module | What it is | Guide | +|---|---|---| +| `alkahest.ansatz` | Parametric families (`polynomial`, `rational`, `exponential_polynomial`, `linear_combination`, `quadratic_form`) plus `fit`, `enumerate_family`, `certify_nonneg` | [Ansatz families](./ansatz.md) | +| `alkahest.crosscheck` | Differential testing against an external CAS oracle: `check`, `sweep`, `run_frozen_corpus`, `to_sympy`, `register_oracle` | [Cross-CAS testing](./crosscheck.md) | +| `alkahest.smt` | SMT-LIB 2 export and z3/cvc5 bridge: `to_smtlib`, `solve`, `supported`, `solvers` | [SMT bridge](./smt.md) | +| `alkahest.research` | Session claim graphs and provenance | [Claim graphs](./claim-graphs.md) | +| `alkahest.experimental` | Transforms, `dsolve`, asymptotics, `residue`, `Fps`, `to_jax` — may change in a minor release. **Must be imported explicitly** (`from alkahest import experimental as ex`); it is not an attribute of the top-level module until then | [Stability policy](./stability.md) | +| `alkahest.rl` | Verifiable RL environments | [Reinforcement learning](./rl.md) | +| `alkahest.number_theory`, `alkahest.modular`, `alkahest.lattice` | FLINT-backed integer and lattice routines | — | + +`alkahest.ansatz`, `alkahest.crosscheck` and `alkahest.smt` are new in 3.8. They are in +`alkahest.__all__` and resolve on attribute access without a separate import, as do +their error classes `AnsatzError`, `CrossCheckError` and `SmtError`. diff --git a/docs/mdbook/src/rules.md b/docs/mdbook/src/rules.md index 4729ec59..621e3a56 100644 --- a/docs/mdbook/src/rules.md +++ b/docs/mdbook/src/rules.md @@ -47,7 +47,7 @@ The rule sets loaded by `simplify` and the domain-specific simplifiers are: | Function | Rules | |---|---| -| `simplify` | Arithmetic identities, constant folding, polynomial normalization | +| `simplify` | Arithmetic identities, constant folding, polynomial normalization — with one carve-out: no rule folds a product that contains a **literal zero raised to a negative power**, because `0 · 0⁻¹` has no value ([details](./simplification.md#the-literal-zero-carve-out)) | | `simplify_trig` | Pythagorean identity, double-angle and half-angle formulas | | `simplify_log_exp` | Log/exp cancellation (branch-cut safe subset) | | `simplify_expanded` | Distributive expansion, like-term collection | diff --git a/docs/mdbook/src/search-plumbing.md b/docs/mdbook/src/search-plumbing.md index 353fa492..1d886829 100644 --- a/docs/mdbook/src/search-plumbing.md +++ b/docs/mdbook/src/search-plumbing.md @@ -12,6 +12,9 @@ to the mathematics: | Cheap, versioned payloads for logs / LLM context | `DerivedResult.to_dict(mode="compact")` | [Derivation logs](./derivations.md#machine-parseable-output-to_dict--to_json) | | Accumulate claims across iterations | `alkahest.research` claim graph | [Claim graphs](./claim-graphs.md) | | Ask “will this call certify?” before spending compute | `certifiable`, `require_certificate` | [Certificate coverage](./certificate-coverage.md) | +| Propose a parametric family and fit it | `alkahest.ansatz` (`polynomial`, `rational`, `fit`, …) | [Ansatz families](./ansatz.md) | +| Differential-test a result against another CAS | `alkahest.crosscheck` (`check`, `sweep`) | [Cross-CAS testing](./crosscheck.md) | +| Hand a discrete / mixed int-real subproblem to a solver | `alkahest.smt` (`to_smtlib`, `solve`, `supported`) | [SMT bridge](./smt.md) | A minimal loop shape: @@ -34,15 +37,76 @@ with ak.research.session(title="Sweep", pool=pool, capture=True) as s: print(s.graph.to_markdown()) ``` +That snippet uses **one pool for the whole sweep**, which is right for a sweep that +ends. It is wrong for a loop that runs for days: see +[running for days without dying](#running-for-days-without-dying) below. + Honesty rules that matter in a loop: - A **budget trip is a fine answer**, not a crash — catch `BudgetExceededError` (`E-BUDGET-*`) and deprioritize that candidate. +- A **refusal is not a negative result.** `E-CAD-001`, `E-SOS-002`, `E-LINALG-010`, + `E-MAT-004`, `E-SMT-003` and `E-ANSATZ-003` all mean *undecided by this route*. + Recording any of them as "proved false" or "no such object exists" is the most + expensive mistake a search loop can make, because it closes a branch permanently. + The only codes that are genuine mathematical verdicts are the ones documented as + such — e.g. `E-INT-004` (proven non-elementary). See + [Refusals](./errors.md#refusals-when-alkahest-declines-to-answer). - A **batch never drops a slot** — failures become `BatchItem(ok=False, error=…)`. - **Compact mode never hides verification status** — `verification["status"]` stays readable; Lean source is omitted on purpose. - **Certificates are withheld rather than lied about** — see [certificate coverage](./certificate-coverage.md). +## Running for days without dying + +Four limits bound an unattended run. None of them is a bug you can wait out; all four +are properties of the design, and a loop has to be written around them. + +**1. Memory is not budgeted, and `ExprPool` never reclaims.** A pool created once at +startup grows linearly and forever — roughly 200 bytes per interned node, on the order +of 2–3.5 KB per `integrate` — at *flat* per-call latency, so the run dies by OOM with no +slowdown to warn you. Use **one pool per problem**, drop it when the problem is done, and +carry `to_dict()` envelopes rather than live `Expr` handles between iterations (holding +any `Expr`, `Matrix` or `DerivedResult` pins its entire pool). +[Full treatment](./budgets.md#exprpool-never-reclaims). + +**2. `wall_ms` is cooperative, and its granularity is one primitive operation.** The +call stops at the first checkpoint after the deadline. On a high-degree integrand that +operation is a FLINT call, which nothing short of an OS-level kill interrupts — a 300 ms +budget can return after ~2 s there. [Details](./budgets.md#how-tightly-wall_ms-binds). + +**3. `run_with_wall_fallback` does not bound wall time for an uncooperative callee.** +It joins its worker before raising, so it returns when the callee returns. +`run_with_wall_fallback(time.sleep, 3.0, budget=Budget(wall_ms=50))` raises after +3000 ms. The only hard bound is an **outer process timeout**. +[Details](./budgets.md#it-does-not-bound-wall-time-for-an-uncooperative-callee). + +**4. Some questions get refused, not answered.** `decide` is not complete; it raises +`E-CAD-001` rather than fabricate a verdict it cannot justify, and the linear-algebra +zero test refuses with `E-LINALG-010` / `E-MAT-004` rather than pick a branch. Budget +for refusals in the loop's control flow, not just for failures. + +The skeleton that respects all four: + +```python +import alkahest as ak + +def run_one(problem): + pool = ak.ExprPool() # (1) fresh pool per problem + x = pool.symbol("x") + with ak.context(pool=pool, budget=ak.Budget(wall_ms=500, seed=7)): # (2) + try: + result = ak.integrate(build(pool, problem), x) + except ak.BudgetExceededError: + return {"status": "undecided", "why": "budget"} + except ak.AlkahestError as e: + kind = "verdict" if e.code == "E-INT-004" else "undecided" # (4) + return {"status": kind, "code": e.code} + return {"status": "ok", "result": result.to_dict(mode="compact")} # no live Expr escapes +``` + +Run the driver itself under an OS-level timeout (3), not `run_with_wall_fallback`. + See also the runnable experimental-mathematics demo [`examples/pslq_research_loop.py`](https://github.com/alkahest-cas/alkahest/blob/main/examples/pslq_research_loop.py). diff --git a/docs/mdbook/src/simplification.md b/docs/mdbook/src/simplification.md index e515e90e..15bd8ab6 100644 --- a/docs/mdbook/src/simplification.md +++ b/docs/mdbook/src/simplification.md @@ -71,6 +71,37 @@ simplifier and record `NonZero` side conditions in the derivation log. The colored pass runs after ordinary rule simplification and preserves repeated terms and factors. +### The literal-zero carve-out + +`b · b⁻¹ → 1` is a documented convention for a symbolic base, but it is not a +convention anybody holds when the base is the literal integer `0`: `0⁻¹` is division +by zero, so `0 · 0⁻¹` is the indeterminate form `0 · ∞` and has no value. Through 3.7 +`simplify` returned `1` for it, `simplify_egraph` returned `0`, and +`simplify(5 · 0⁻¹ · 0)` returned `0` — three answers that were their own proof that at +least two were wrong. As of 3.8 all of them decline: + +```python +import alkahest as ak + +pool = ak.ExprPool() +Z = pool.integer(0) +undefined = Z * Z ** pool.integer(-1) + +ak.simplify(undefined).value # (0 * 0^-1) — left alone +ak.simplify_egraph(undefined).value # (0 * 0^-1) — same +ak.simplify(pool.integer(5) * Z ** pool.integer(-1) * Z).value # (0 * 0^-1 * 5) +``` + +The guard tests for a **literal** zero base. Because the rule engine normalises +strictly bottom-up, that also covers every base the simplifier can itself reduce to +zero — `x - x` included, so `diff(2/(x - x), x)` no longer reports `1` for a function +whose domain is empty. A base that *is* zero but not provably so keeps the +`b · b⁻¹ → 1` convention: deciding it would need a three-valued zero test on the `Mul` +rewrite path, which costs several 128-bit ball evaluations per node. + +Unaffected controls, so you can see the boundary: `x · x⁻¹ → 1`, `0 · x → 0`, and +`2x − 2x → 0` all still fire. + ### Parallel simplification ```python @@ -112,7 +143,7 @@ wide node to fork on and runs essentially sequentially. At one thread the level scheduler is faster on every shape measured. Reproduce with `cargo run --release --features parallel --example simplify_three_way`. -Both are `experimental`: `alkahest_core::experimental::{simplify_par, simplify_redex}`. +Both are `experimental`: `alkahest_cas::experimental::{simplify_par, simplify_redex}`. ## E-graph simplification diff --git a/docs/mdbook/src/smt.md b/docs/mdbook/src/smt.md index 3abccf20..3f1ff510 100644 --- a/docs/mdbook/src/smt.md +++ b/docs/mdbook/src/smt.md @@ -93,9 +93,13 @@ support.script # the emitted script, so you don't pay for it twice - **`prefer_in_tree`** for real arithmetic with no integer variables (`QF_LRA` / `QF_NRA`). [`prove_nonneg` / `sos_decompose`](./positivity.md) return a `PositivityCertificate` that - composes with `to_lean`; `decide` is complete. z3's `nlsat` returns an answer and **no - artifact**. Reach for SMT here as a *fallback* when the in-tree route refuses or exceeds - its budget. + composes with `to_lean`, and `decide` returns a verdict plus a verified witness. z3's + `nlsat` returns an answer and **no artifact**. Reach for SMT here as a *fallback* when + the in-tree route refuses or exceeds its budget — and note that it genuinely does + refuse: `decide` is not complete, it raises `E-CAD-001` outside its fragment and on + sentences whose only solutions sit at an irrational boundary point + ([details](./positivity.md#decide-refuses-rather-than-guessing)). `nlsat` is complete + over the reals and is the right escalation when that happens. - **`smt`** for anything with integer variables — mixed integer/real/boolean is the genuinely new capability, and neither CAD nor `diophantine` covers it. @@ -284,7 +288,10 @@ arrives only after paying for the solver run reads like a bug in the solver. For the same reason `solve` takes **quantifier-free formulas only**. `to_smtlib` exports quantified ones happily — export it and drive the solver yourself, or use -`alkahest.decide` for real quantifier elimination (see [Positivity certificates](./positivity.md)). +`alkahest.decide` for real quantifier elimination within its fragment (≤ 2 variables, +≤ 2-quantifier prefix, polynomial bodies, and refusing rather than guessing at irrational +boundary points — see +[`decide` refuses rather than guessing](./positivity.md#decide-refuses-rather-than-guessing)). ## Budgets @@ -323,7 +330,7 @@ claim.machine_checked # True only for the checked sat case liability the project has to defend, in a problem class that is not Alkahest's. - **No unsat-proof checking.** See the first asymmetry above; the status vocabulary is honest about it rather than papering over it. -- **`dpll_sat` is not wired in.** `alkahest_core::logic::dpll_sat` remains a standalone CNF +- **`dpll_sat` is not wired in.** `alkahest_cas::logic::dpll_sat` remains a standalone CNF utility, sound and complete for the propositional problem it is *handed*, and it is deliberately not an engine behind this bridge. The only route from a `Formula` to it is to abstract each arithmetic atom to a fresh proposition, and that abstraction is sound in @@ -342,7 +349,7 @@ claim.machine_checked # True only for the checked sat case | `E-SMT-004` | Python driver | A model failed back-substitution. Always raised, never warned. | | `E-BUDGET-001` | Python driver | The solver hit `Budget.wall_ms`. | -Only `E-SMT-002` appears in `alkahest_core::errors::codes::REGISTRY`, because it is the only +Only `E-SMT-002` appears in `alkahest_cas::errors::codes::REGISTRY`, because it is the only one Rust raises; `scripts/check_error_codes.py` requires the registry and the Rust `AlkahestError` impls to agree exactly, so codes raised only from Python stay out of it (`E-BATCH-001` in `alkahest/_batch.py` is the same precedent). diff --git a/docs/mdbook/src/stability.md b/docs/mdbook/src/stability.md index e38cdc42..4e8ef8af 100644 --- a/docs/mdbook/src/stability.md +++ b/docs/mdbook/src/stability.md @@ -6,14 +6,14 @@ Alkahest follows semantic versioning starting at `1.0`. The stable surface is the API Alkahest commits to maintaining without breaking changes across a major version: -- **Rust:** everything re-exported from `alkahest_core::stable` +- **Rust:** everything re-exported from `alkahest_cas::stable` - **Python:** every name in `alkahest.__all__` at release time Breaking changes to the stable surface require a major-version bump (e.g. 1.x → 2.0). ## Experimental surface -- **Rust:** `alkahest_core::experimental::*`, plus anything not in `stable` +- **Rust:** `alkahest_cas::experimental::*`, plus anything not in `stable` - **Python:** `alkahest.experimental.*`, plus anything re-exported from the native module but not in `__all__` Experimental APIs may change in any minor release. Pin a specific point release if you depend on them. diff --git a/docs/sphinx/api/autoresearch.rst b/docs/sphinx/api/autoresearch.rst new file mode 100644 index 00000000..71af8db4 --- /dev/null +++ b/docs/sphinx/api/autoresearch.rst @@ -0,0 +1,134 @@ +Autoresearch modules API +======================== + +.. currentmodule:: alkahest + +Three submodules aimed at unattended math-search loops. All are reachable as +``alkahest.ansatz`` / ``alkahest.crosscheck`` / ``alkahest.smt`` without a +separate import, and all are listed in ``alkahest.__all__``. + +Conceptual guides: `Ansatz families <../ansatz.html>`_, +`Cross-CAS testing <../crosscheck.html>`_, `SMT bridge <../smt.html>`_. + +.. note:: + + Each module has one error code that means *undecided by this route*, not + *false*: ``E-ANSATZ-003`` (no member of this family fits), ``E-XCHECK-002`` + (no oracle installed), ``E-SMT-003`` (algebraic witness that cannot be + lifted exactly). A search loop that records any of them as a negative result + closes a branch it never explored. + +alkahest.ansatz +--------------- + +Parametric families with named unknown coefficients, plus the fitting step. + +.. function:: ansatz.polynomial(pool, vars, degree, *, name="c", min_degree=0, max_terms=256, reserved=()) -> Ansatz +.. function:: ansatz.rational(pool, vars, num_degree, den_degree, *, name="a", den_name="b", monic_denominator=True, max_terms=256, reserved=()) -> Ansatz +.. function:: ansatz.exponential_polynomial(pool, var, rates, *, degree=0, name="c", max_terms=256, reserved=()) -> Ansatz +.. function:: ansatz.linear_combination(pool, basis, *, vars=None, name="c", max_terms=256, reserved=()) -> Ansatz +.. function:: ansatz.quadratic_form(pool, vars, *, name="q", max_terms=256, reserved=()) -> Ansatz + + Family constructors. Each returns an ``Ansatz``, which is an object rather + than a bare ``Expr`` because a bare expression loses the distinction between + an *unknown coefficient* and an *independent variable*. + +.. function:: ansatz.fit(ansatz, residual, *, certify="residual", seed=None, oversample=None, max_points=None, degree_bound=None, tolerance=1e-08, samples=5) -> AnsatzSolution + + Solve for the coefficients that make ``residual`` vanish. ``certify`` is one + of ``"residual"``, ``"exact"``, ``"none"``. + + The returned ``AnsatzSolution`` carries ``expr``, ``assignment``, ``free``, + ``rank``, ``status``, ``verification``, ``steps``, ``residual``, ``check``, + ``points``, ``ansatz`` and ``certificate``. ``status`` is + ``"exactly_verified"`` only when the residual is symbolically zero — never + on the strength of the collocation points alone. + + Raises :exc:`AnsatzError`: ``E-ANSATZ-003`` when no member of the family + satisfies the constraints, ``E-ANSATZ-004`` when the residual is genuinely + nonlinear in the unknowns. + + Because it goes through ``Matrix.rref``, a coefficient matrix containing an + entry whose vanishing cannot be decided refuses with ``E-LINALG-010``. + +.. function:: ansatz.enumerate_family(ansatz, coeffs=(-1, 0, 1), *, max_members=100000) + + Iterate concrete members over a coefficient grid, for conjecture generation. + +.. function:: ansatz.certify_nonneg(candidate, vars=None, *, constraints=(), **kwargs) + + Hand a fitted candidate to :func:`sos_decompose` / :func:`prove_nonneg`. + +alkahest.crosscheck +------------------- + +Differential testing against an external CAS oracle (SymPy today). + +.. function:: crosscheck.check(operation, *args, oracle=None, assumptions=None, points=5, seed=None, pool=None, **kwargs) -> CrossCheck + + Run one comparison. ``CrossCheck.outcome`` is one of ``"agree"``, + ``"diverge"``, ``"incomparable"``, ``"unavailable"``, settled by the lowest + rung that can settle it: 1 syntactic, 2 symbolic, 3 rigorous-numeric, + 4 invariant. Operations with no invariant (``diff``, ``limit``, ``series``) + stop at rung 3 rather than pretending to one. + + **A missing oracle reports** ``"unavailable"`` (``E-XCHECK-002``) — never + ``"agree"``. + +.. function:: crosscheck.sweep(*, seed=None, cases=40, operations=("diff", "integrate", "simplify"), oracle=None, pool=None, points=5) -> SweepReport + + Generate and run a seeded corpus. ``SweepReport.summary()`` always prints the + seed; ``to_dict()`` is JSON-serialisable. ``seed`` defaults to + :func:`budget_seed`, then to ``DEFAULT_SEED``. + +.. function:: crosscheck.run_frozen_corpus(*, oracle=None, cases=FROZEN_CORPUS) + + Replay the pinned cases, each recording its expected outcome and why. + +.. function:: crosscheck.to_sympy(expr, *, assumptions=None) +.. function:: crosscheck.register_oracle(oracle_cls) +.. function:: crosscheck.oracles() -> dict[str, str | None] + + Installed oracles and their versions. + +alkahest.smt +------------ + +SMT-LIB 2 export and a bridge to an external solver (``z3``, ``cvc5``). + +.. function:: to_smtlib(formula, logic="auto", *, check_sat=True, get_model=True) -> str + + Emit a complete, runnable SMT-LIB 2 script. Works with no solver installed, + and accepts quantified formulas. + +.. function:: smt.solve(formula, *, solver="auto", logic="auto", budget=None, pool=None) -> SmtResult + + Run an installed solver on a **quantifier-free** formula. + + ``SmtResult`` carries ``status`` (``'sat'`` / ``'unsat'`` / ``'unknown'``), + ``model`` (exact ``Fraction`` values), ``model_exprs``, ``engine``, + ``logic``, ``smtlib``, ``verification``, ``reason_unknown``, ``elapsed_ms``, + ``raw_output`` and ``steps``. + + Trust model, which is deliberately asymmetric: + + - ``sat`` — the model is lifted to exact rationals, substituted back, and + checked **in this process**; ``verification["status"]`` is + ``"exactly_verified"``, and a model that fails raises ``E-SMT-004``. + - ``unsat`` — reported as ``"externally_asserted"`` and excluded from + ``alkahest.research.MACHINE_CHECKED_STATUSES``. Nothing checked it. + - ``unknown`` — ``"unverified"``, with ``reason_unknown`` set. A budget trip + raises :exc:`BudgetExceededError` instead, so "hard" stays distinct from + "hung". + +.. function:: smt.supported(formula, *, solver="auto") -> SmtSupport + + Ask whether this route applies **before** paying for a solver run. + ``SmtSupport`` carries ``supported``, ``exportable``, ``quantified``, + ``solver``, ``logic``, ``reason``, ``detail``, ``recommendation``, ``script`` + and ``error``. ``recommendation`` is ``'smt'`` or ``'prefer_in_tree'``. + +.. function:: smt.solvers() -> dict[str, str | None] + + Which of ``SOLVERS`` (``'z3'``, ``'cvc5'``) are installed, and their + versions. diff --git a/docs/sphinx/api/errors.rst b/docs/sphinx/api/errors.rst index f5c57b20..d4f8bba3 100644 --- a/docs/sphinx/api/errors.rst +++ b/docs/sphinx/api/errors.rst @@ -39,6 +39,20 @@ Base class print(e.code) # E-POLY-001 print(e.remediation) # "Use Expr directly, or expand sin(x) as a series first" +Refusals versus verdicts +------------------------ + +Some codes are **refusals**: Alkahest could not establish the answer, and the +only alternative to saying so was a confident wrong one. They mean *undecided*, +never *false*, and an unattended loop that records one as a negative result +closes a branch it never explored. + +Refusals: ``E-CAD-001``, ``E-LINALG-010``, ``E-MAT-004``, ``E-SOS-002``, +``E-ANSATZ-003``, ``E-SMT-003``, ``E-INT-001``, ``E-BUDGET-001..003``. + +Verdicts: ``E-INT-004`` (proven non-elementary), ``E-MAT-003`` (proven +singular), ``E-EVAL-009`` (undefined at this point). + Exception subclasses -------------------- @@ -70,8 +84,70 @@ Exception subclasses .. exception:: MatrixError - Code prefix ``E-MAT-*``. Linear algebra errors (shape mismatch, - singular matrix, non-invertible). + Code prefix ``E-MAT-*``. Matrix errors. + + Common codes: + + - ``E-MAT-001`` — shape mismatch + - ``E-MAT-002`` — operation requires a square matrix + - ``E-MAT-003`` — matrix is **proven** singular + - ``E-MAT-004`` — the determinant's vanishing could not be decided; the + inverse is refused rather than computed on an unproven assumption + +.. exception:: LinearAlgebraError + + Code prefix ``E-LINALG-*``. Subclass of :exc:`MatrixError`. Elimination, + decompositions, and canonical forms. + + Common codes: + + - ``E-LINALG-002`` — nullspace elimination failed + - ``E-LINALG-004`` — ``minimal_polynomial`` needs symbol-free entries + - ``E-LINALG-009`` — ``rational_canonical_form`` needs rational constants + - ``E-LINALG-010`` — an entry's vanishing could be proven neither zero nor + non-zero, so ``rank`` / ``rref`` / ``nullspace`` / ``eigenvects`` / + ``jordan_form`` refused. **This is "undecided", not "singular".** + +.. exception:: EigenError + + Code prefix ``E-EIGEN-*``. Subclass of :exc:`MatrixError`. Eigenvalues, + eigenvectors, Jordan form. ``E-EIGEN-005`` is a defective matrix passed to + ``diagonalize``. Note that ``eigenvects`` surfaces an undecidable entry as + an :exc:`EigenError` carrying code ``E-LINALG-010``: the code names what + could not be decided, not the wrapper it arrived in. + +.. exception:: CadError + + Code prefix ``E-CAD-*``. Real quantifier elimination (:func:`decide`). + + ``E-CAD-001`` is raised when the sentence is outside the supported fragment + (polynomial bodies over ℚ, at most two real variables, quantifier prefix of + at most two) **or** when the only candidate solutions lie at an irrational + boundary point that rational CAD sampling cannot test exactly. It means + *undecided*, never *false* — reporting it as a disproof would turn a refusal + into a fabricated theorem. + +.. exception:: AnsatzError + + Code prefix ``E-ANSATZ-*``. Ansatz family construction and fitting + (``alkahest.ansatz``). ``E-ANSATZ-003`` means no member of *this* family + satisfies the constraints — a closed branch for that family, not a proof + that no such object exists. ``E-ANSATZ-004`` is a residual genuinely + nonlinear in the unknowns, which needs the ``groebner`` route. + +.. exception:: CrossCheckError + + Code prefix ``E-XCHECK-*``. Cross-CAS differential testing + (``alkahest.crosscheck``). ``E-XCHECK-002`` means no oracle is installed — + it exists so that a missing oracle can never be mistaken for agreement. + +.. exception:: SmtError + + Code prefix ``E-SMT-*``. SMT-LIB export, solver invocation, and model lift + (``alkahest.smt``). ``E-SMT-003`` refuses a model containing an algebraic + number that cannot be lifted exactly, rather than truncating it to a float; + ``E-SMT-004`` means the returned model failed the in-process substitution + check. .. exception:: OdeError diff --git a/docs/sphinx/api/matrix.rst b/docs/sphinx/api/matrix.rst index bb3778ff..63138f13 100644 --- a/docs/sphinx/api/matrix.rst +++ b/docs/sphinx/api/matrix.rst @@ -34,11 +34,36 @@ Matrix Compute the determinant symbolically. - .. method:: inv() -> Matrix + .. method:: inverse() -> Matrix - Compute the matrix inverse symbolically. - Raises ``MatrixError`` (``E-MAT-001``) if the matrix is - symbolically singular (zero determinant). + Compute the matrix inverse symbolically. Three outcomes, kept distinct on + purpose: + + - ``E-MAT-002`` — the matrix is not square. + - ``E-MAT-003`` — the determinant is **proven** zero (singular). + - ``E-MAT-004`` — the determinant's vanishing could be decided **neither + way**. Refusing rather than returning an inverse that silently assumes + ``det ≠ 0``. + + .. method:: rank() -> int + .. method:: rref() -> list[list[Expr]] + .. method:: nullspace() -> list[Matrix] + .. method:: eigenvects() -> list + .. method:: jordan_form() -> Matrix + + All five run the same elimination, which uses a **three-valued** zero + test. An entry whose vanishing can be proven neither zero nor non-zero + raises ``E-LINALG-010`` (as :exc:`LinearAlgebraError`, or + :exc:`EigenError` from ``eigenvects``) rather than picking a branch. + Substituting concrete values for the parameters is the remedy. + + .. method:: eigenvals() -> dict[Expr, int] + + Eigenvalue → algebraic multiplicity. For an irreducible cubic with three + real roots the Cardano form is returned, whose cube roots are meant under + the **real** branch; :func:`eval_expr` refuses such a value with + ``E-EVAL-009``. Do not export it to a principal-branch evaluator — see + `Interoperability <../interop.html>`_. .. method:: transpose() -> Matrix @@ -46,11 +71,13 @@ Matrix .. method:: shape() -> tuple[int, int] - Return ``(nrows, ncols)``. + Return ``(nrows, ncols)``. ``rows`` and ``cols`` are also available as + attributes. - .. method:: __getitem__(i, j) -> Expr + .. method:: get(i, j) -> Expr - Access entry ``(i, j)``. + Access entry ``(i, j)``. ``Matrix`` is **not** subscriptable — + ``M[i, j]`` raises ``TypeError``. .. function:: jacobian(exprs: list[Expr], vars: list[Expr]) -> Matrix diff --git a/docs/sphinx/api/workload.rst b/docs/sphinx/api/workload.rst index 1c523566..cd31535b 100644 --- a/docs/sphinx/api/workload.rst +++ b/docs/sphinx/api/workload.rst @@ -22,13 +22,24 @@ Budgets Entered with ``context(budget=...)``. Trips raise :exc:`BudgetExceededError` (``E-BUDGET-001`` wall, ``E-BUDGET-002`` steps, - ``E-BUDGET-003`` cancelled) from engines that check cooperatively - (notably :func:`integrate`). + ``E-BUDGET-003`` cancelled) from engines that check cooperatively — + :func:`integrate` and :func:`limit`. :func:`simplify` has no error channel + and stops early instead of raising. Gröbner bases and homotopy continuation + do not check the budget at all. + + ``wall_ms`` is **cooperative**: the call stops at the first checkpoint + after the deadline. The granularity is one primitive polynomial operation, + and on a high-degree input that operation can be an uninterruptible FLINT + call. Budgets are **thread-local**; the cancellation flag is process-wide. .. function:: request_cancel() Set the process-wide cancellation flag so cooperative checkpoints return - ``E-BUDGET-003``. + ``E-BUDGET-003``. Because :func:`integrate` and :func:`limit` release the + GIL around their core call, this reaches one of them that is **already + running** — a watchdog thread can stop a call in flight, not only one that + has not started. No other engine releases the GIL, so none of the others + can be cancelled mid-call. .. function:: clear_cancel() @@ -49,10 +60,27 @@ Budgets .. function:: run_with_wall_fallback(fn, *args, budget=None, **kwargs) - Python-layer wall-clock fallback for callables that cannot raise - :exc:`BudgetExceededError` through their own return type (e.g. - :func:`simplify`). Prefer ``context(budget=...)`` for engines that already - honor Rust cooperative checkpoints. + Runs ``fn`` on a worker thread with ``budget`` entered on that thread, and + raises :exc:`BudgetExceededError` (``E-BUDGET-001``) when it overruns + ``wall_ms``. Its purpose is to turn a callable that cannot raise through its + own return type (e.g. :func:`simplify`, which truncates silently) into a + coded error. + + .. warning:: + + **It does not bound wall time for an uncooperative callee.** It joins its + worker before the exception propagates, so it returns control when the + callee returns, not at ``wall_ms``:: + + run_with_wall_fallback(time.sleep, 3.0, budget=Budget(wall_ms=50)) + # raises E-BUDGET-001 after 3000 ms + + The message reports the real elapsed time for exactly this reason. Python + cannot kill a thread, and abandoning one would leak a live thread that + still allocates into the pool and can only be stopped through the + process-wide cancel flag. Prefer ``context(budget=...)`` for engines that + honor the cooperative checkpoints, and an **OS-level timeout** + (subprocess or process watchdog) for anything else. Batch evaluation ---------------- @@ -76,6 +104,19 @@ Batch evaluation Call ``fn(item, **kwargs)`` for every item. **Never raises** for a single bad element. Always returns results in **input order**. + The active budget is propagated into ``parallel=True`` workers: a Rust + budget frame is thread-local, so ``batch_map`` snapshots the caller's budget + and re-enters it inside each worker task. ``wall_ms`` remains a single + sweep-wide deadline (captured at the ``batch_map`` call, not at + ``context(budget=...)`` entry); ``max_steps`` becomes **per item**, because + the Rust step counter is not readable from Python. Without this, a fanned-out + sweep ran unbudgeted and reported ``E-INT-001`` — a mathematical verdict — + where a sequential sweep reported ``E-BUDGET-001``. + + One item tripping its budget never cancels its siblings; ``batch_map`` never + sets the process-wide cancel flag. :func:`request_cancel` does abort every + in-flight worker, by design. + .. function:: batch_map_iter(fn, items, *, parallel=False, max_workers=None, **kwargs) Streaming counterpart. Under ``parallel=True``, yields in **completion diff --git a/docs/sphinx/index.rst b/docs/sphinx/index.rst index 8beb5040..ec96b737 100644 --- a/docs/sphinx/index.rst +++ b/docs/sphinx/index.rst @@ -25,8 +25,8 @@ source with ``maturin``. .. code-block:: toml [dependencies] - alkahest-cas = "2" - # alkahest-cas = { version = "2", features = ["groebner", "parallel", "egraph"] } + alkahest-cas = "3" + # alkahest-cas = { version = "3", features = ["groebner", "parallel", "egraph"] } Requires ``libflint-dev`` / ``libgmp-dev`` / ``libmpfr-dev`` at build time (``apt-get`` or ``brew install flint``). See `docs.rs/alkahest-cas `_ for the @@ -56,6 +56,7 @@ For optional Cargo features (``jit``, ``parallel``, ``cuda``, …) and full deve api/solve api/codegen api/workload + api/autoresearch api/errors For the conceptual guide (kernel design, rule engine, e-graph, derivation logs) diff --git a/python/alkahest/__init__.py b/python/alkahest/__init__.py index ffaf5ef8..994ed094 100644 --- a/python/alkahest/__init__.py +++ b/python/alkahest/__init__.py @@ -336,6 +336,7 @@ ConversionError, CrossCheckError, DaeError, + DepthLimitError, DiffError, DiophantineError, DomainError, @@ -379,6 +380,7 @@ "CadError", "ConversionError", "DaeError", + "DepthLimitError", "DiffError", "DiophantineError", "DomainError", @@ -1786,6 +1788,7 @@ def wrapper(*args, **kwargs): "CrossCheckError", "DaeError", "DaeIndexReduction", + "DepthLimitError", "DerivedResult", "DiffError", "DiophantineError", diff --git a/python/alkahest/_batch.py b/python/alkahest/_batch.py index 6339d44f..e8ce5d5b 100644 --- a/python/alkahest/_batch.py +++ b/python/alkahest/_batch.py @@ -42,6 +42,35 @@ otherwise yields the GIL):: outs = ak.batch_map(ak.simplify, candidates, parallel=True) + +Budgets under ``parallel=True`` +------------------------------- +Budget frames are **thread-local** on the Rust side, so an ambient +``context(budget=...)`` does not reach a worker on its own — a fanned-out +sweep used to run completely unbudgeted, and (worse) a candidate that would +have reported ``E-BUDGET-001`` sequentially came back with the integrator's +*mathematical* verdict ``E-INT-001`` instead, which a research loop records +as a permanently closed branch. Both paths here therefore capture the active +budget on the calling thread (:func:`alkahest._budget.capture_budget`) and +re-enter it inside every worker task, so ``parallel=True`` reports the same +code as ``parallel=False``. See :class:`~alkahest._budget.BudgetHandoff` for +what "the same budget" means for each field: + +* ``wall_ms`` is a **batch-wide deadline**, captured once at the + ``batch_map`` call, exactly as a sequential sweep shares the caller's one + frame. Items still running when it passes trip at their next cooperative + checkpoint; items not yet started trip at their first. +* ``max_steps`` becomes **per item** — the Rust step counter lives in the + frame and is not readable from Python, so each worker counts from zero. +* ``seed`` is identical on every worker, so :func:`alkahest.budget_seed` + reads the same value there as on the calling thread. + +Cancellation needs no propagation: ``request_cancel()`` sets a process-wide +flag every thread already sees, so a caller can abort a whole in-flight sweep +with it (every item then reports ``E-BUDGET-003``). The converse is +deliberate too — **one item tripping its budget never cancels its siblings**. +Nothing in this module sets the flag; a trip is recorded on the item that +tripped and the rest of the sweep runs out its shared deadline. """ from __future__ import annotations @@ -51,9 +80,13 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from ._budget import capture_budget + if TYPE_CHECKING: # pragma: no cover - typing only from collections.abc import Callable, Iterable, Iterator + from ._budget import BudgetHandoff + __all__ = [ "UNEXPECTED_ERROR_CODE", "BatchItem", @@ -129,16 +162,32 @@ def _describe_exception(exc: Exception) -> dict[str, Any]: } -def _invoke(fn: Callable[..., Any], item: Any, index: int, kwargs: dict[str, Any]) -> BatchItem: +def _invoke( + fn: Callable[..., Any], + item: Any, + index: int, + kwargs: dict[str, Any], + budget: BudgetHandoff | None = None, +) -> BatchItem: """Run ``fn(item, **kwargs)``, turning any :class:`Exception` into a :class:`BatchItem`. Deliberately catches ``Exception`` rather than ``BaseException``: a ``KeyboardInterrupt`` (or ``SystemExit``) must still propagate and stop the batch, since swallowing those would make the process unkillable. + + *budget* is the caller's budget, captured on the calling thread by + :func:`~alkahest._budget.capture_budget` and re-entered here. It is + ``None`` on the sequential path (the caller's own frame is already active + on this thread — re-entering would restart its step counter) and whenever + no budget is active at all. """ start = time.perf_counter() try: - value = fn(item, **kwargs) + if budget is None: + value = fn(item, **kwargs) + else: + with budget.applied(): + value = fn(item, **kwargs) except Exception as exc: # intentional: never abort the batch for one bad element elapsed_ms = (time.perf_counter() - start) * 1000.0 error = _describe_exception(exc) @@ -171,7 +220,11 @@ def batch_map( true. Useful when *fn* releases the GIL for some or all of its work (I/O, or a Rust call that calls ``py.allow_threads``); on pure Python, GIL-bound work it will not speed anything up, but it also - will not make anything incorrect — order is preserved either way. + will not make anything incorrect — order is preserved either way, + and the ambient ``context(budget=...)`` is re-entered inside each + worker so a trip is reported as ``E-BUDGET-00x`` on the item that + tripped, exactly as it would be sequentially (see the module + docstring for the per-field semantics). max_workers : int, optional Forwarded to :class:`~concurrent.futures.ThreadPoolExecutor`. Ignored when ``parallel=False``. @@ -200,11 +253,13 @@ def batch_map( if not parallel: return [_invoke(fn, item, i, kwargs) for i, item in enumerate(materialized)] + handoff = capture_budget() with ThreadPoolExecutor(max_workers=max_workers) as executor: # Submit in input order and collect in the same order so the return # type stays ``list[BatchItem]`` (no ``None`` placeholders for ty). futures = [ - executor.submit(_invoke, fn, item, i, kwargs) for i, item in enumerate(materialized) + executor.submit(_invoke, fn, item, i, kwargs, handoff) + for i, item in enumerate(materialized) ] return [future.result() for future in futures] @@ -262,9 +317,11 @@ def batch_map_iter( yield _invoke(fn, item, i, kwargs) return + handoff = capture_budget() with ThreadPoolExecutor(max_workers=max_workers) as executor: pending = { - executor.submit(_invoke, fn, item, i, kwargs) for i, item in enumerate(materialized) + executor.submit(_invoke, fn, item, i, kwargs, handoff) + for i, item in enumerate(materialized) } while pending: done, pending = wait(pending, return_when=FIRST_COMPLETED) diff --git a/python/alkahest/_budget.py b/python/alkahest/_budget.py index 04dd7f29..027e64bf 100644 --- a/python/alkahest/_budget.py +++ b/python/alkahest/_budget.py @@ -24,28 +24,54 @@ (yet) check the Rust cooperative budget on every path — most notably :func:`alkahest.simplify`, whose ``DerivedExpr`` return type has no error channel to raise through, so it only stops early silently. Runs the call - on a worker thread and raises :class:`~alkahest.BudgetExceededError` if it - doesn't finish within ``budget.wall_ms``. The worker thread is **not** - killed — Python has no safe way to do that — so on a timeout the call may - keep running in the background until it hits a cooperative checkpoint or - finishes. Prefer relying on the Rust cooperative check (via - ``context(budget=...)`` alone) wherever it's already wired; reach for this - only when you need a hard deadline on a path it doesn't cover. + on a worker thread (with ``budget`` entered *on that thread*, since budget + frames are thread-local) and raises + :class:`~alkahest.BudgetExceededError` when it doesn't finish within + ``budget.wall_ms``. + + **It does not bound wall time for a callee that never reaches a + cooperative checkpoint.** The worker thread is not killed — Python has no + safe way to do that, and abandoning it is worse (see + :func:`run_with_wall_fallback` for the full argument) — so the call + returns only once the callee returns. Read its docstring before relying + on ``wall_ms`` here. :func:`request_cancel` / :func:`clear_cancel` / :func:`is_cancelled` Thin wrappers over the process-wide cancellation flag (``alkahest_core::budget``): an orchestrator thread can request that a heavy call running on another thread stop *now*. + +Thread-local frames vs. the process-wide flag +--------------------------------------------- +The two mechanisms have deliberately different scopes, and code that fans work +out over threads has to keep them straight: + +* A **budget frame** is *thread-local* (``alkahest_core::budget::STACK``). A + worker thread does **not** inherit the frame its parent entered, so work + handed to a :class:`~concurrent.futures.ThreadPoolExecutor` runs unbudgeted + unless something re-enters the budget on the worker. :func:`capture_budget` + and :class:`BudgetHandoff` are that "something" — used by + :func:`alkahest.batch_map` and by :func:`run_with_wall_fallback`. +* The **cancellation flag** is *process-wide* and sticky, so it needs no + propagation at all — every thread already sees it. The corollary is that + setting it is never a private act: one candidate's timeout cancels every + other in-flight call in the process, which is why nothing here sets it + except :func:`run_with_wall_fallback` (which joins its worker and then + restores the previous value) and callers who ask for it explicitly. """ from __future__ import annotations import concurrent.futures import math +import time +from contextlib import contextmanager from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable, TypeVar if TYPE_CHECKING: + from collections.abc import Iterator + from .exceptions import BudgetExceededError __all__ = [ @@ -142,6 +168,104 @@ def _budget_exceeded( return exc +@dataclass(frozen=True) +class BudgetHandoff: + """A :class:`Budget` snapshot that can cross a thread boundary. + + Budget frames live on a **thread-local** stack on the Rust side, and the + ``BudgetGuard`` that pops one is ``!Send``, so a budget entered on the + calling thread is invisible to any worker it fans work out to. A handoff + is the transferable form: plain numbers, captured on the calling thread by + :func:`capture_budget` and re-entered on the worker by :meth:`applied`. + + The wall limit is carried as an absolute **deadline**, not as a duration. + That is what makes a fanned-out batch bounded the same way a sequential + one is: every worker re-enters *the remaining time until the shared + deadline*, so N items cannot cost N × ``wall_ms`` between them. Once the + deadline has passed, later items enter a zero-length budget and trip at + their first cooperative checkpoint — exactly what the sequential path + does with the caller's own long-running frame. + + Attributes + ---------- + deadline : float or None + A :func:`time.perf_counter` value, or ``None`` when the captured + budget set no ``wall_ms``. + max_steps : int or None + Carried through as-is. Note that the Rust step *counter* lives in the + frame and is not readable from Python, so each worker gets its own + counter starting at zero: under ``parallel=True`` ``max_steps`` is a + per-item limit, not a batch-wide one (the wall limit is batch-wide). + seed : int or None + Carried through so :func:`budget_seed` reads the same value on a + worker as it does on the calling thread. + """ + + deadline: float | None + max_steps: int | None + seed: int | None + + def remaining_ms(self) -> float | None: + """Milliseconds left until :attr:`deadline`, clamped at ``0.0``. + + ``None`` when the captured budget carried no wall limit. + """ + if self.deadline is None: + return None + return max(0.0, (self.deadline - time.perf_counter()) * 1000.0) + + @contextmanager + def applied(self) -> Iterator[None]: + """Enter this budget on the *current* thread for the duration of the block. + + Push and pop are paired in a ``finally``, and both happen on the same + thread — the invariant ``pop_budget`` needs (the guard stack it pops + from is thread-local). + """ + native = _native() + native.push_budget(wall_ms=self.remaining_ms(), max_steps=self.max_steps, seed=self.seed) + try: + yield + finally: + native.pop_budget() + + +def capture_budget(budget: Budget | None = None) -> BudgetHandoff | None: + """Snapshot a budget for hand-off to a worker thread, on the calling thread. + + Parameters + ---------- + budget : Budget, optional + The budget to snapshot. Defaults to the one established by the + innermost active ``alkahest.context(budget=...)``. + + Returns + ------- + BudgetHandoff or None + ``None`` when no budget is active — the caller should then run the + work with no frame at all rather than pushing an empty one, so + unbudgeted work stays exactly as unbudgeted as it was. + + Notes + ----- + The deadline is measured from **this call**, not from ``context(...)`` + entry: the Rust frame does not expose its start instant to Python, so a + handoff captured some time into a budgeted block gives the worker the full + ``wall_ms`` again rather than what is genuinely left. The overshoot is + bounded by one ``wall_ms`` for the whole fan-out (not per item), and it is + why :func:`alkahest.batch_map` captures at batch entry rather than + per-item. + """ + if budget is None: + from ._context import active_budget + + budget = active_budget() + if budget is None: + return None + deadline = None if budget.wall_ms is None else time.perf_counter() + budget.wall_ms / 1000.0 + return BudgetHandoff(deadline=deadline, max_steps=budget.max_steps, seed=budget.seed) + + def run_with_wall_fallback( fn: Callable[..., _T], /, @@ -149,14 +273,45 @@ def run_with_wall_fallback( budget: Budget, **kwargs: Any, ) -> _T: - """Run ``fn(*args, **kwargs)``, enforcing ``budget.wall_ms`` even if ``fn`` - doesn't check the Rust cooperative budget on every path. - - This is a *supplement* to, not a replacement for, entering the budget via - ``context(budget=...)`` — call this from inside such a block (or pass a - budget that also carries ``max_steps``/``seed``) so cooperative call sites - still see it. See the module docstring for why the worker thread is not - forcibly stopped on timeout. + """Run ``fn(*args, **kwargs)`` under ``budget``, raising ``E-BUDGET-001`` + when it overruns ``budget.wall_ms`` — but **without** a hard deadline. + + Read this before relying on it + ------------------------------- + This turns "the callee quietly gave up early" into a raised, coded error, + and it re-enters ``budget`` on the worker thread so cooperative + checkpoints actually see it. What it does **not** do is return control at + ``wall_ms``: the worker is joined before the exception propagates, so for + a callee that never reaches a cooperative checkpoint — + ``time.sleep(3)``, a single long FLINT call, third-party code — + ``Budget(wall_ms=50)`` raises the right error *after the callee finishes*. + Three seconds, in that example. The error message reports how long control + was actually withheld, so this is visible in a log rather than inferred. + + Why not just abandon the worker and return at the deadline? Because + Python cannot kill a thread, so "return early" means leaking a live + thread that still holds the GIL in bursts, still allocates into the pool, + and cannot be stopped except through the **process-wide** cancellation + flag — which would also abort every unrelated in-flight call in the + process, and which nobody could then safely clear (clearing it before the + orphan observes it is a no-op; leaving it set poisons every subsequent + cooperative call). In a multi-day loop that trades a bounded stall for + unbounded orphan-thread accumulation plus collateral cancellation. Joining + is the honest lesser evil, so it is what this does. + + What *does* bound wall time + --------------------------- + - ``context(budget=...)`` for engines that check the cooperative budget — + :func:`alkahest.integrate` and :func:`alkahest.limit` today. That is the + real mechanism; this function is a reporting shim over it. + - An OS-level bound — a subprocess with a timeout, or a process-level + watchdog — for anything else. Nothing inside one Python process can + preempt a thread. + + So: reach for this to get a *raise* out of a cooperatively-budgeted call + that would otherwise return a silently-truncated answer (the documented + case is :func:`alkahest.simplify`, whose ``DerivedExpr`` return type has + no error channel). Do not reach for it to contain an unknown callee. Parameters ---------- @@ -165,14 +320,17 @@ def run_with_wall_fallback( *args, **kwargs Forwarded to ``fn``. budget : Budget - If ``budget.wall_ms`` is ``None``, this is equivalent to - ``fn(*args, **kwargs)`` — no thread is spawned. + Entered on the worker thread for the duration of the call, so + ``max_steps`` and ``seed`` reach cooperative call sites too. If + ``budget.wall_ms`` is ``None``, this is equivalent to + ``fn(*args, **kwargs)`` — no thread is spawned, and the caller's own + ambient budget (if any) applies unchanged. Raises ------ BudgetExceededError (``E-BUDGET-001``) if ``fn`` does not return within ``budget.wall_ms`` - milliseconds. + milliseconds. Raised once ``fn`` has actually finished — see above. Examples -------- @@ -183,22 +341,62 @@ def run_with_wall_fallback( if budget.wall_ms is None: return fn(*args, **kwargs) - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(fn, *args, **kwargs) + # Budget frames are thread-local: without this the worker ran the callee + # with *no* budget active, so the only thing that could stop it was the + # process-wide cancel flag below. Entering it on the worker is what makes + # a cooperative callee stop on its own, promptly, and without touching + # global state. + handoff = capture_budget(budget) + + def _run_on_worker() -> _T: + if handoff is None: # pragma: no cover - budget.wall_ms is not None here + return fn(*args, **kwargs) + with handoff.applied(): + return fn(*args, **kwargs) + + # The cancellation flag is process-wide and sticky, so a timeout here must + # not outlive this call: without the restore below, one expired candidate + # in a long search loop leaves `CANCELLED` set and *every* subsequent + # cooperative call in the process fails with E-BUDGET-003 forever. Only + # restore a flag this call raised — an orchestrator that had already + # requested cancellation keeps its request. + cancelled_before = is_cancelled() + requested_here = False + timed_out: concurrent.futures.TimeoutError | None = None + started = time.perf_counter() + pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) + try: + future = pool.submit(_run_on_worker) try: return future.result(timeout=budget.wall_ms / 1000.0) except concurrent.futures.TimeoutError as exc: - # Best-effort: ask any cooperative Rust checkpoint the call has - # reached (or will reach) to stop, since we can't stop the - # Python thread itself. + # Belt and braces alongside the worker's own budget frame: it also + # reaches checkpoints the frame cannot (Rayon workers the callee + # fanned out to, or a callee that shadowed our frame with a nested + # `context(budget=...)` of its own). request_cancel() - raise _budget_exceeded( - f"[E-BUDGET-001] budget exceeded: wall-clock limit {budget.wall_ms} ms elapsed", - remediation=( - "raise Budget(wall_ms=...), or accept a heuristic/numeric result for this " - "candidate instead of an exact one" - ), - ) from exc + requested_here = True + timed_out = exc + finally: + # `shutdown(wait=True)` — the join this function is honest about, and + # the reason the flag can be restored safely: by the time we get here + # the call we cancelled has already observed the flag and stopped. + pool.shutdown(wait=True) + if requested_here and not cancelled_before: + clear_cancel() + + blocked_ms = (time.perf_counter() - started) * 1000.0 + raise _budget_exceeded( + f"[E-BUDGET-001] budget exceeded: wall-clock limit {budget.wall_ms} ms elapsed; " + f"run_with_wall_fallback returned control after {blocked_ms:.0f} ms " + f"(it joins its worker rather than abandoning the thread)", + remediation=( + "raise Budget(wall_ms=...), or accept a heuristic/numeric result for " + "this candidate instead of an exact one; if the overrun above is large, the " + "callee does not reach a cooperative checkpoint and only an OS-level timeout " + "can bound it -- see docs/mdbook/src/budgets.md" + ), + ) from timed_out def request_cancel() -> None: diff --git a/python/alkahest/crosscheck.py b/python/alkahest/crosscheck.py index 84d1c6dc..04cd44af 100644 --- a/python/alkahest/crosscheck.py +++ b/python/alkahest/crosscheck.py @@ -2528,11 +2528,13 @@ def summary(self) -> str: #: Operations the default sweep exercises. Chosen so the *comparator* is what #: gets stressed — every one of these has a rung 4 or a rigorous rung 3. #: -#: ``limit`` is deliberately absent, and the reason is worth recording: at this -#: commit ``limit(sqrt(x**2 + x) - x, x, oo)`` does not terminate, and because -#: the kernel holds the GIL throughout there is no in-process way to bound it — -#: a worker thread cannot be stopped, and an abandoned one wedges the -#: interpreter just the same. See :func:`sweep` for what to do about that. +#: ``limit`` is deliberately absent. The original reason — the call could run +#: away and the kernel held the GIL throughout, so nothing in-process could +#: bound it — no longer holds: ``limit`` now has cooperative checkpoints and an +#: internal work ceiling, and its binding releases the GIL, so +#: ``context(budget=...)`` and ``request_cancel()`` both reach it. What is left +#: is that its comparator is weaker than the three below, which is a reason to +#: promote findings by hand rather than to sweep it randomly. SWEEP_OPERATIONS = ("diff", "integrate", "simplify") diff --git a/python/alkahest/exceptions.py b/python/alkahest/exceptions.py index 3787573d..4cfa79d8 100644 --- a/python/alkahest/exceptions.py +++ b/python/alkahest/exceptions.py @@ -48,6 +48,9 @@ E-ANSATZ-001 … E-ANSATZ-004 AnsatzError (Python-only; P2 item 1 — conjecture generation) E-XCHECK-001 … E-XCHECK-004 CrossCheckError (Python-only; P2 item 2 — differential testing) E-SMT-001 … E-SMT-004 SmtError (P2 item 3 — SMT/SAT bridge) + E-DEPTH-001 DepthLimitError (expression nesting ceiling — see + alkahest_core::kernel::depth; refuses rather than + letting a recursive walk overflow the native stack) """ from __future__ import annotations @@ -127,6 +130,30 @@ def __init__( super().__init__(message, code="E-SIMPLIFY-001", remediation=remediation, span=span) +class DepthLimitError(AlkahestError): + """An expression was too deeply nested to walk by recursion. + + Alkahest processes expressions by structural recursion, and a native stack + overflow is a ``SIGSEGV`` rather than an exception — it would kill the + interpreter outright, with no traceback for a caller to log. Past a + measured ceiling the operation therefore declines instead, which is + something ``except Exception`` can actually catch. + + Depth is *nesting*, not size. ``pool.add([t1, ..., t100000])`` has depth 2 + and is fine; ``t1 + t2 + ... + t100000`` written with repeated ``+`` builds + 100 000 nested binary ``Add`` nodes and is not. Building the wide form, or + splitting the work into subexpressions, is the fix. + """ + + def __init__( + self, + message: str, + remediation: str | None = None, + span: tuple[int, int] | None = None, + ): + super().__init__(message, code="E-DEPTH-001", remediation=remediation, span=span) + + class DiffError(AlkahestError): """Differentiation failed (e.g. unknown function).""" diff --git a/tests/silent_errors/corpus.py b/tests/silent_errors/corpus.py index fe61f382..29db7290 100644 --- a/tests/silent_errors/corpus.py +++ b/tests/silent_errors/corpus.py @@ -16,6 +16,7 @@ from __future__ import annotations import math +from fractions import Fraction from typing import Any, Callable import alkahest as ak @@ -34,6 +35,8 @@ POOL = ak.ExprPool() X = POOL.symbol("x") +#: Second variable, for the two-variable `decide` cases. +Y = POOL.symbol("y") N = POOL.symbol("n") K = POOL.symbol("k") @@ -153,6 +156,24 @@ def op() -> bool: return op +def _witness_residual(sentence: ak.Expr, body: ak.Expr) -> float: + """Answer = |body(witness)| for the witness ``decide`` returns. + + A witness is a certificate, and the only thing a certificate means is that + substituting it back works. Scoring the *residual* rather than the witness's + value keeps the case independent of which of several solutions is reported. + A missing witness is scored as a refusal, not as zero. + """ + _truth, witness = ak.decide(sentence) + if not witness: + # No `code=` kwarg: `CadError.__init__` does not take one, and passing it + # raised `TypeError`, which the runner scores `no_answer` (a corpus bug) + # instead of the intended honest refusal. + raise ak.CadError("decide reported no witness (E-CAD-001)") + value = Fraction(witness[str(X)]) + return abs(float(ak.eval_expr(body, {X: float(value)}))) + + def _matrix(rows: list[list[int]]) -> ak.Matrix: return ak.Matrix([[_int(v) for v in row] for row in rows]) @@ -199,6 +220,52 @@ def _matrix(rows: list[list[int]]) -> ak.Matrix: ] ) +#: ``det = mystery(a)``, so whether this matrix is invertible is exactly as +#: undecidable as whether ``mystery`` is the zero function. ``rank()`` refuses +#: it; ``nullspace()`` used to return the 1-dimensional basis ``(-1, mystery(a))`` +#: — the answer that is right only when ``det = 0``. +UNDECIDABLE_DETERMINANT = ak.Matrix( + [ + [POOL.func("mystery", [_A]), _int(1)], + [_int(0), _int(1)], + ] +) + +#: ``det = x``: generically non-zero, so the kernel is trivial. This needs no +#: uninterpreted function at all — it is an ordinary symbolic matrix, and the +#: cheapest possible trigger for the same defect. +GENERICALLY_INVERTIBLE = ak.Matrix([[X, _int(0)], [_int(0), _int(1)]]) + +#: ``det = x·x − x·x = 0`` identically: genuinely rank 1, so the kernel really is +#: 1-dimensional. The control that stops the gate being passed by refusing every +#: symbolic matrix. +GENUINELY_RANK_ONE = ak.Matrix([[X, X], [X, X]]) + + +def _nullspace_dim(m: ak.Matrix) -> Callable[[], int]: + """Answer = the dimension of ``m.nullspace()``.""" + return lambda: len(m.nullspace()) + + +def _kernel_residual(m: ak.Matrix, at: float = 0.7) -> Callable[[], float]: + """Answer = max |M·v| over the returned basis, sampled at ``x = at``. + + A basis vector that is not annihilated is the whole failure: the dimension + can be right while the vector is wrong, so scoring the dimension alone would + miss it. Sampled numerically rather than compared structurally so the case + does not depend on the form the entries come back in. + """ + + def op() -> float: + worst = 0.0 + for v in m.nullspace(): + for row in (m @ v).to_list(): + for entry in row: + worst = max(worst, abs(float(ak.eval_expr(entry, {X: at, _A: at})))) + return worst + + return op + def _rref_zero_rows(m: ak.Matrix, at: float = 0.7) -> Callable[[], int]: """Answer = how many rows of ``m.rref()`` vanish, sampled at ``a = at``. @@ -252,6 +319,98 @@ def _sin_log_pair(x: float) -> float: CALCULUS = "first-course calculus fact, re-derived by hand" +# --------------------------------------------------------------------------- +# Round-two helpers (3.8 silent-error hunt #2) +# --------------------------------------------------------------------------- + +#: A free *parameter*, distinct from the integration variable ``X``. +_A_PARAM = POOL.symbol("aparam") + + +def parametric_definite( + integrand: ak.Expr, lo: ak.Expr, hi: ak.Expr, at: float +) -> Callable[[], float]: + """Answer = ∫_lo^hi integrand dx, with the parameter ``aparam`` set to *at*. + + A parametric answer must be scored at a concrete parameter value, not left + symbolic: an expression with an unbound symbol fails ``eval_expr`` and would + score as a *refusal*, hiding the very thing under test. The library's + contract here is that the closed form is returned unconditionally, so + substituting afterwards is exactly what a caller does with it. + """ + + def op() -> float: + r = ak.integrate(integrand, X, lo, hi) + return float(ak.eval_expr(r.value, {_A_PARAM: at})) + + return op + + +def _real_root_count(coeffs: list[int]) -> Callable[[], int]: + """Answer = how many real-root intervals ``real_roots`` reports. + + *coeffs* is in ascending degree order. + """ + + def op() -> int: + expr = _int(0) + for i, c in enumerate(coeffs): + expr = expr + _int(c) * X ** _int(i) + return len(ak.real_roots(expr, X)) + + return op + + +def _refined_ball_brackets_root(coeffs: list[int], index: int) -> Callable[[], bool]: + """Answer = does ``refine_root``'s ball actually contain a root? + + Checked in exact ``Fraction`` arithmetic on the ball's own endpoints: the + polynomial must vanish at one of them or change sign across them. This is + the only thing the word "rigorous" can mean for an enclosure, and it needs + no reference value — the root itself may be irrational. + """ + + def op() -> bool: + expr = _int(0) + for i, c in enumerate(coeffs): + expr = expr + _int(c) * X ** _int(i) + ball = ak.refine_root(expr, ak.real_roots(expr, X)[index], X) + mid, rad = Fraction(ball.mid), Fraction(ball.rad) + + def value_at(t: Fraction) -> Fraction: + return sum((Fraction(c) * t**i for i, c in enumerate(coeffs)), Fraction(0)) + + lo_v, hi_v = value_at(mid - rad), value_at(mid + rad) + return lo_v == 0 or hi_v == 0 or (lo_v > 0) != (hi_v > 0) + + return op + + +def _enclosure_contains(expr: ak.Expr, lo: float, hi: float, truth: float) -> Callable[[], bool]: + """Answer = does the *validated* enclosure of ``expr`` over the box contain + the value it claims to enclose?""" + + def op() -> bool: + enc = ak.bound_on_box(expr, [(X, lo, hi)]) + return bool(enc.lower <= truth <= enc.upper) + + return op + + +def _relation_residual(values: list[int]) -> Callable[[], int]: + """Answer = the *exact* integer residual ``Σ aᵢ·valuesᵢ`` of the relation + ``guess_relation`` reports. Zero, or nothing at all, are the only honest + answers; any other integer means the reported "relation" is not one.""" + + def op() -> int: + coeffs = ak.guess_relation(values) + if coeffs is None: + raise ak.PslqError("guess_relation reported no relation") + return sum(a * v for a, v in zip(coeffs, values)) + + return op + + CASES: list[Case] = [ # ── real quantifier elimination ────────────────────────────────────────── # @@ -314,6 +473,169 @@ def _sin_log_pair(x: float) -> float: contract=Returns(False), verified_by="Exact evaluation finds a point where the polynomial is positive.", ), + # The CAD sample set is built from isolating-bracket endpoints and their + # midpoints, which are all *dyadic* rationals. A statement whose truth turns + # on the value at a root with any other denominator was therefore decided + # without that point ever being tested. x^2 > 0 above passes because 0 is + # dyadic; these three are the same trap one denominator to the right. + Case( + id="decide_forall_square_touching_at_two_thirds", + subsystem="real_qe", + statement="forall x. (3x+2)^2 > 0 is FALSE (x = -2/3)", + op=universal_holds((_int(3) * X + _int(2)) ** _int(2), "gt"), + contract=Returns(False), + verified_by=( + "9x^2+12x+4 at x=-2/3 is 9(4/9) + 12(-2/3) + 4 = 4 - 8 + 4 = 0 exactly, and 0 > 0 " + "is false. -2/3 has denominator 3, so no bisection of a rational bracket ever " + "lands on it." + ), + ), + Case( + id="decide_forall_square_touching_at_one_fifth", + subsystem="real_qe", + statement="forall x. (5x-1)^2 > 0 is FALSE (x = 1/5)", + op=universal_holds((_int(5) * X - _int(1)) ** _int(2), "gt"), + contract=Returns(False), + verified_by="25x^2-10x+1 at x=1/5 is 25/25 - 10/5 + 1 = 1 - 2 + 1 = 0; 0 > 0 is false.", + ), + Case( + id="decide_exists_nonstrict_boundary_at_two_thirds", + subsystem="real_qe", + statement="exists x. (3x+2)^2 <= 0 is TRUE (x = -2/3)", + op=lambda: ak.decide(ak.Exists(X, POOL.le((_int(3) * X + _int(2)) ** _int(2), _int(0))))[0], + contract=Returns(True), + verified_by=( + "The square vanishes at x=-2/3 (see decide_forall_square_touching_at_two_thirds), " + "and 0 <= 0 holds. The dual of the forall case: a missed existential witness is " + "what makes the universal come back true." + ), + ), + Case( + id="decide_witness_satisfies_linear_equation", + subsystem="real_qe", + statement="the witness decide returns for exists x. 3x - 2 = 0 must satisfy it", + op=lambda: _witness_residual( + ak.Exists(X, POOL.pred_eq(_int(3) * X - _int(2), _int(0))), + _int(3) * X - _int(2), + ), + contract=Returns(0.0, tol=1e-12), + verified_by=( + "3x = 2 has the single solution x = 2/3, and 3(2/3) - 2 = 0. A witness is a " + "certificate: substituting it back is the whole of its meaning, so a witness " + "with a non-zero residual is a wrong answer no matter what the truth value says." + ), + ), + Case( + id="decide_forall_square_touching_at_irrational_root", + subsystem="real_qe", + statement="forall x. (x^2-2)^2 > 0 is FALSE (x = ±sqrt(2)); no rational sample shows it", + op=universal_holds((X ** _int(2) - _int(2)) ** _int(2), "gt"), + contract=RefusesOr(False), + verified_by=( + "(x^2-2)^2 vanishes at x=±sqrt(2), where 0 > 0 is false. sqrt(2) is irrational, " + "so a decision procedure that only evaluates at rational points cannot exhibit " + "the counterexample — refusing is honest, returning True is a proof of a false " + "theorem." + ), + note="Passes by refusal (E-CAD-001); deciding it needs algebraic-number CAD lifting.", + ), + # The same completeness gap, one variable up. `project_and_sample_x` flags + # an irrational projection root as untested, but the flag only escalated to + # a refusal when the body contained an `=` / `≠` atom — so `≤` and `≥` in + # two variables kept reporting an unsatisfiability that was never checked at + # the one point that could have satisfied them. + Case( + id="decide_exists_exists_nonstrict_boundary_at_irrational_x", + subsystem="real_qe", + statement="exists x. exists y. (x^2-2)^2 + y^2 <= 0 is TRUE (at x = ±√2, y = 0)", + op=lambda: ak.decide( + ak.Exists( + X, + ak.Exists(Y, POOL.le((X ** _int(2) - _int(2)) ** _int(2) + Y ** _int(2), _int(0))), + ) + )[0], + contract=RefusesOr(True), + verified_by=( + "Both summands are squares, so the sum is >= 0 and equals 0 exactly when " + "x^2 = 2 and y = 0, i.e. at (±√2, 0) — two real points. So the sentence is TRUE. " + "√2 is irrational, so no rational sample point ever lands on it: a procedure " + "that only evaluates at rationals must refuse, and a `False` is a claim that " + "these two points do not exist." + ), + note="Passes by refusal (E-CAD-001); deciding it needs algebraic-number CAD lifting.", + ), + Case( + id="decide_forall_forall_strict_positive_at_irrational_root", + subsystem="real_qe", + statement="forall x. forall y. (x^2-2)^2 + y^2 > 0 is FALSE (0 at x = ±√2, y = 0)", + op=lambda: ak.decide( + ak.Forall( + X, + ak.Forall(Y, POOL.gt((X ** _int(2) - _int(2)) ** _int(2) + Y ** _int(2), _int(0))), + ) + )[0], + contract=RefusesOr(False), + verified_by=( + "The negation of the case above: the sum vanishes at (√2, 0), where 0 > 0 is " + "false, so the universal is FALSE. `∀x∀y φ` is decided as `¬∃x∃y ¬φ`, so a " + "missed existential witness surfaces here as a proof of a false theorem — the " + "shape of error a stability proof or a bound check would inherit whole." + ), + note="Passes by refusal (E-CAD-001); the dual of the exists/exists case.", + ), + Case( + id="decide_exists_exists_nonstrict_boundary_at_two_thirds", + subsystem="real_qe", + statement="exists x. exists y. (3x-2)^2 + y^2 <= 0 is TRUE (at x = 2/3, y = 0)", + op=lambda: ak.decide( + ak.Exists( + X, ak.Exists(Y, POOL.le((_int(3) * X - _int(2)) ** _int(2) + Y ** _int(2), _int(0))) + ) + )[0], + contract=Returns(True), + verified_by=( + "(3x-2)^2 + y^2 = 0 exactly at x = 2/3, y = 0: 3(2/3) - 2 = 0. The boundary point " + "is rational here, so the CAD sample set can reach it and there is nothing to " + "refuse. The control for the two irrational-root cases above: without it the " + "gate would be passed by a `decide` that refuses every non-strict two-variable " + "sentence." + ), + ), + Case( + id="decide_exists_exists_nonstrict_genuinely_unsatisfiable", + subsystem="real_qe", + statement="exists x. exists y. (x^2-2)^2 + y^2 + 1 <= 0 is FALSE (the sum is >= 1)", + op=lambda: ak.decide( + ak.Exists( + X, + ak.Exists( + Y, + POOL.le((X ** _int(2) - _int(2)) ** _int(2) + Y ** _int(2) + _int(1), _int(0)), + ), + ) + )[0], + contract=Returns(False), + verified_by=( + "Two squares plus 1 is >= 1 > 0 everywhere, so nothing satisfies `<= 0` and the " + "sentence is FALSE. Same polynomial shape and the same `<=` atom as the " + "irrational-root case, so this is the control that the completeness guard " + "refuses only where a boundary point is genuinely untested, rather than " + "refusing every `<=` it sees." + ), + ), + Case( + id="decide_forall_forall_control_two_squares_plus_one", + subsystem="real_qe", + statement="forall x. forall y. x^2 + y^2 + 1 > 0 is TRUE", + op=lambda: ak.decide( + ak.Forall(X, ak.Forall(Y, POOL.gt(X ** _int(2) + Y ** _int(2) + _int(1), _int(0)))) + )[0], + contract=Returns(True), + verified_by=( + "Squares are non-negative, so x^2 + y^2 + 1 >= 1 > 0 for every real (x, y). " + "The positive control for the two-variable universal path." + ), + ), # ----------------------------------------------------------------------- # Definite integration through an interior pole. Naive FTC produces a # clean finite number for every one of these; every one of them diverges. @@ -1214,6 +1536,131 @@ def _sin_log_pair(x: float) -> float: verified_by="Valid for x≠0, which is where the expression is defined.", ), # ----------------------------------------------------------------------- + # Division by a literal zero. `x · x^-1 → 1` and `x · 0 → 0` are both + # deliberate conventions (see simplify_control_cancel_x_over_x), and both + # are false when the base really is zero: `0 · 0^-1` is `0 · ∞`, which has + # no value under any convention. `simplify(0^-1)` already leaves the power + # alone and `eval_expr(0^-1)` raises E-EVAL-009, so a product that quietly + # collapses to a number is contradicting the rest of the library. + # ----------------------------------------------------------------------- + Case( + id="simplify_zero_times_zero_reciprocal", + subsystem="simplification", + statement="0 · 0^-1 is undefined — not 1, not 0", + op=simplified_value(ak.simplify, _int(0) * _int(0) ** _int(-1)), + contract=RefusesOr(), + verified_by=( + "0^-1 is division by zero, so the product has no value: it is the indeterminate " + "form 0·∞. Summing the exponents to 0^0 = 1 is invalid precisely because the " + "base is zero — b^k·b^m = b^(k+m) needs b ≠ 0 once one exponent is negative." + ), + note="Passes by a weak refusal: eval_expr raises E-EVAL-009 on the preserved 0^-1.", + ), + Case( + id="simplify_zero_reciprocal_in_longer_product", + subsystem="simplification", + statement="5 · 0^-1 · 0 is undefined — the arrangement must not change the answer", + op=simplified_value(ak.simplify, _int(5) * _int(0) ** _int(-1) * _int(0)), + contract=RefusesOr(), + verified_by=( + "Same undefined product with a spectator factor: 5·(0·∞) is still indeterminate. " + "This arrangement is folded by the numeric constant folder rather than by the " + "exponent collector, so it is a second, independent route to the same lie — and " + "it used to give 0 where the two-factor form gave 1, which is its own proof that " + "at least one of them is wrong." + ), + note="Passes by a weak refusal: eval_expr raises E-EVAL-009 on the preserved 0^-1.", + ), + Case( + id="simplify_symbolic_zero_times_its_reciprocal", + subsystem="simplification", + statement="(x-x) · (x-x)^-1 is undefined: the base is identically zero", + op=simplified_value(ak.simplify, (X - X) * (X - X) ** _int(-1), at=2.0), + contract=RefusesOr(), + verified_by=( + "x - x is the zero function, so (x-x)^-1 is nowhere defined and the product has " + "no value at any x. Cancelling b·b^-1 → 1 asserts b ≠ 0, which is false here. " + "This is the shape `diff(2/(x-x), x)` reaches, so it is not a hand-written " + "curiosity." + ), + note="Passes by a weak refusal: eval_expr raises E-EVAL-009 on the preserved 0^-1.", + ), + Case( + id="simplify_egraph_zero_times_zero_reciprocal", + subsystem="simplification", + statement="the e-graph simplifier must not give 0 · 0^-1 a value either", + op=simplified_value(ak.simplify_egraph, _int(0) * _int(0) ** _int(-1)), + contract=RefusesOr(), + verified_by=( + "Same undefined product; checked separately because the e-graph engine has its " + "own rule set. It is the worse of the two failures: its shrink rules contain " + "both (Mul ?x (Num 0)) → (Num 0) and (Mul ?x (Pow ?x (Num -1))) → (Num 1), so " + "on this input it unions 0 and 1 into a single e-class — every other e-class in " + "the run is then equally suspect." + ), + note="Passes by a weak refusal: eval_expr raises E-EVAL-009 on the preserved 0^-1.", + ), + Case( + id="diff_reciprocal_of_identically_zero_denominator", + subsystem="simplification", + statement="d/dx [2/(x-x)] has no value: the function is nowhere defined", + op=lambda: _num(ak.diff(_int(2) / (X - X), X)), + contract=RefusesOr(), + verified_by=( + "2/(x-x) = 2/0 has empty domain, so it has no derivative anywhere; 1 is a value " + "it can never take. Reached through an ordinary `diff` call, without writing " + "0^-1 by hand: the quotient rule produces 0·0^-1 terms and the simplifier used " + "to collapse them." + ), + note="Passes by a weak refusal: eval_expr raises E-EVAL-009 on the preserved 0^-1.", + ), + Case( + id="simplify_control_symbol_over_symbol", + subsystem="simplification", + statement="simplify(x · x^-1) = 1 for a symbolic x", + op=simplified_value(ak.simplify, X * X ** _int(-1), at=2.0), + contract=Returns(1.0), + verified_by=( + "2 · (1/2) = 1. The documented convention for a base that is not provably zero, " + "and the control that the zero-base guard did not simply switch factor " + "collection off." + ), + ), + Case( + id="simplify_control_zero_times_symbol", + subsystem="simplification", + statement="simplify(0 · x) = 0", + op=simplified_value(ak.simplify, _int(0) * X, at=3.0), + contract=Returns(0.0), + verified_by=( + "0 · 3 = 0. The control for the absorption rule: it must keep firing on products " + "that really are zero, and only decline when a co-factor is undefined." + ), + ), + Case( + id="simplify_control_like_terms_cancel_to_zero", + subsystem="simplification", + statement="simplify(2x - 2x) = 0", + op=simplified_value(ak.simplify, _int(2) * X - _int(2) * X, at=5.0), + contract=Returns(0.0), + verified_by=( + "10 - 10 = 0. The control for like-term collection, which must still drop terms " + "whose coefficients cancel — the guard only applies when the surviving factor is " + "a division by zero." + ), + ), + Case( + id="simplify_egraph_control_symbol_over_symbol", + subsystem="simplification", + statement="simplify_egraph(x · x^-1) = 1 for a symbolic x", + op=simplified_value(ak.simplify_egraph, X * X ** _int(-1), at=2.0), + contract=Returns(1.0), + verified_by=( + "2 · (1/2) = 1. The e-graph control: it must still cancel a symbolic base, so " + "the zero-base bail-out cannot be passed by disabling the engine." + ), + ), + # ----------------------------------------------------------------------- # Linear algebra on singular and ill-conditioned inputs. # ----------------------------------------------------------------------- Case( @@ -1322,6 +1769,64 @@ def _sin_log_pair(x: float) -> float: "library that only did the first would pass that case by pivoting on anything it " "failed to reduce, which is the bug that motivated both.", ), + Case( + id="matrix_nullspace_undecidable_determinant_refuses", + subsystem="linear_algebra", + statement="the nullspace of [[mystery(a), 1], [0, 1]] cannot be stated — its " + "dimension is 0 or 1 depending on whether mystery(a) vanishes", + op=_nullspace_dim(UNDECIDABLE_DETERMINANT), + contract=RefusesOr(), + verified_by="det = mystery(a)·1 − 1·0 = mystery(a). If mystery is not identically " + "zero the matrix is invertible and the kernel is {0}; if it is, the kernel is " + "1-dimensional. Both are consistent with everything alkahest knows about an " + "uninterpreted function symbol, so neither dimension is derivable. The wrong " + "answer alkahest gave was the basis v = (-1, mystery(a)): multiplying back, " + "M·v = (mystery(a)·(-1) + 1·mystery(a), 0·(-1) + 1·mystery(a)) = (0, mystery(a)), " + "which is the zero vector only when mystery(a) = 0 — precisely the thing that was " + "never established. rank() already refuses this matrix, so the two calls also " + "contradicted each other.", + note="Shipped in 3.7: the 2x2 fast path's full-rank gate only recognised a " + "*literal* non-zero determinant, so any symbolic determinant fell through into " + "the rank-1 branch. That reads 'cannot prove det != 0' as 'det = 0' — the mirror " + "of the rref defect that motivated the three-valued zero test, which read " + "'cannot prove zero' as 'non-zero'.", + ), + Case( + id="matrix_nullspace_generic_determinant_is_trivial", + subsystem="linear_algebra", + statement="the nullspace of [[x, 0], [0, 1]] is {0} — dimension 0", + op=_nullspace_dim(GENERICALLY_INVERTIBLE), + contract=Returns(0), + verified_by="det = x·1 − 0·0 = x, which is not the zero function, so the matrix is " + "invertible for all x != 0 and its kernel is trivial — the same generic-rank " + "reading rank() uses when it reports 2. The wrong answer was the 1-dimensional " + "basis v = (0, x), for which M·v = (x·0 + 0·x, 0·0 + 1·x) = (0, x) != 0. Needs no " + "uninterpreted function: an ordinary symbolic matrix was enough, and rank 2 with " + "nullity 1 makes 3 for a 2-column matrix, violating rank–nullity across two " + "public calls.", + ), + Case( + id="matrix_nullspace_singular_symbolic_still_answers", + subsystem="linear_algebra", + statement="the nullspace of [[x, x], [x, x]] is 1-dimensional", + op=_nullspace_dim(GENUINELY_RANK_ONE), + contract=Returns(1), + verified_by="det = x·x − x·x = 0 identically, and the matrix is not the zero " + "matrix, so it has rank 1 and by rank–nullity a 1-dimensional kernel, spanned by " + "(1, -1). The control for the two cases above: a library that fixed them by " + "refusing every symbolic matrix would pass both and fail this one.", + ), + Case( + id="matrix_nullspace_basis_is_actually_annihilated", + subsystem="linear_algebra", + statement="every returned nullspace basis vector v of [[x, x], [x, x]] satisfies M·v = 0", + op=_kernel_residual(GENUINELY_RANK_ONE), + contract=Returns(0.0), + verified_by="M·(1, -1) = (x − x, x − x) = (0, 0) for every x, so the residual is " + "exactly zero; sampled at x = 0.7. Scoring the dimension alone would miss the " + "actual failure mode, which was a basis of the right *size* whose vector was not " + "in the kernel.", + ), Case( id="matrix_rank_exp_independent_rows", subsystem="linear_algebra", @@ -1570,6 +2075,288 @@ def _sin_log_pair(x: float) -> float: contract=Returns(2), verified_by="2 is the smallest prime.", ), + # ── 3.8 round two ─────────────────────────────────────────────────────── + # + # Every guard in `integrate_definite` binds only the integration variable, + # so one free *parameter* in the integrand switched all of them off and the + # FTC difference was returned as if it held for every parameter value. + Case( + id="int_pole_interior_with_symbolic_parameter", + subsystem="integration_definite", + statement=( + "∫_{-1}^{1} (x-a)^-2 dx diverges for every a in (-1,1); at a=0 it is the archetype" + ), + op=parametric_definite((X - _A_PARAM) ** _int(-2), _int(-1), _int(1), 0.0), + contract=Raises("E-INT-001"), + verified_by=( + "(x-a)^-2 >= 0 wherever it is defined, and for |a| < 1 the double pole at x=a is " + "strictly inside, so the integral is +inf. The FTC difference -1/(1-a) - 1/(1+a) is " + "negative there; at a=0 it is exactly the -2 that README.md names as the archetype. " + "A negative value for a non-negative integrand needs no oracle." + ), + ), + Case( + id="int_control_parametric_no_pole", + subsystem="integration_definite", + statement="∫_0^1 a·x² dx = a/3, a parametric integral with no pole anywhere", + op=parametric_definite(_A_PARAM * X ** _int(2), _int(0), _int(1), 3.0), + contract=Returns(1.0), + verified_by=( + "∫_0^1 x² dx = 1/3 by the power rule, so the answer is a/3 = 1 at a = 3. The control " + "for int_pole_interior_with_symbolic_parameter: the parametric guard must refuse " + "poles, not parameters." + ), + ), + Case( + id="int_tan_squared_across_pole", + subsystem="integration_definite", + statement="∫_0^2 tan²x dx diverges (double pole at π/2 ≈ 1.5708, strictly interior)", + op=definite(ak.tan(X) ** _int(2), _int(0), _int(2)), + contract=Raises("E-INT-001"), + verified_by=( + "tan²x >= 0 everywhere it is defined and π/2 < 2, so the integral is +inf. The FTC " + "difference tan(2) - 2 = -4.185 is negative. Internally decisive too: tan² = sec² - 1, " + "and ∫_0^2 sec²x dx was already refused, so the two answers cannot both stand." + ), + ), + Case( + id="int_tan_squared_grid_lands_on_pole", + subsystem="integration_definite", + statement="∫_0^π tan²x dx diverges — and here the sampling grid falls on the pole itself", + op=definite(ak.tan(X) ** _int(2), POOL.float(0.0, 53), POOL.float(math.pi, 53)), + contract=Raises("E-INT-001"), + verified_by=( + "tan²x >= 0 and π/2 is interior, so the integral is +inf; alkahest returned -π. A " + "separate cause from int_tan_squared_across_pole: on [0, π] coarse sample 128 of 257 " + "falls within 1e-5 of π/2, so the blow-up had already happened before refinement and " + "a growth test measured against the coarse *maximum* could not fire." + ), + ), + Case( + id="int_control_bounded_trig_over_period", + subsystem="integration_definite", + statement="∫_0^π cos²x dx = π/2 — a bounded trig integrand over the same interval", + op=definite(ak.cos(X) ** _int(2), POOL.float(0.0, 53), POOL.float(math.pi, 53)), + contract=Returns(math.pi / 2, tol=1e-12), + verified_by=( + "cos²x = (1 + cos 2x)/2, and ∫_0^π cos 2x dx = 0, so the value is π/2. The control for " + "the two tan cases: the pole scan must not start refusing every trig integrand on " + "[0, π] just because one of them has a pole there." + ), + ), + Case( + id="int_weierstrass_jump_across_pi", + subsystem="integration_definite", + statement=( + "∫_0^{3.2} dx/(cos x - 3)² = 0.4202: bounded integrand, but the half-angle " + "antiderivative jumps at π" + ), + op=definite((ak.cos(X) - _int(3)) ** _int(-2), POOL.float(0.0, 53), POOL.float(3.2, 53)), + contract=RefusesOr(0.42017177259447200), + verified_by=( + "1/(cos x - 3)² is continuous with values in [1/16, 1/4] on [0, 3.2], so the integral " + "lies in [0.2, 0.8] — a negative answer is impossible. Value from mpmath.quad at " + "dps=30, anchored by the closed form ∫_0^π dx/(3-cos x)² = 3π/8^{3/2} = " + "0.4165202754523468, " + "which the same quadrature reproduces to 20 digits. alkahest returned -0.41287, the " + "Weierstrass-substitution error: tan(x/2) blows up at x = π, inside the interval." + ), + ), + Case( + id="int_control_weierstrass_below_pi", + subsystem="integration_definite", + statement="∫_0^3 dx/(cos x - 3)² = 0.40766 — same integrand, interval stops short of π", + op=definite((ak.cos(X) - _int(3)) ** _int(-2), POOL.float(0.0, 53), POOL.float(3.0, 53)), + contract=Returns(0.40765593108334156, tol=1e-9), + verified_by=( + "mpmath.quad at dps=30, anchored by ∫_0^π dx/(3-cos x)² = 3π/8^{3/2}: the [0,3] value " + "must be slightly below it and the [0,3.2] value slightly above, since the integrand " + "is positive. The control for int_weierstrass_jump_across_pi — the jump guard must " + "refuse intervals that cross π, not the whole (a + b·cos x) family." + ), + ), + # ── root isolation ────────────────────────────────────────────────────── + # + # `real_roots` is load-bearing under `decide`, `solve` and the integrator's + # own interior-pole detector, so a dropped root is inherited everywhere. + Case( + id="real_roots_three_rational_roots_kept", + subsystem="solving", + statement="25x³ - 325x² + 804x - 540 = 25(x - 6/5)(x - 9/5)(x - 10) has three real roots", + op=_real_root_count([-540, 804, -325, 25]), + contract=Returns(3), + verified_by=( + "Expanding 25(x - 6/5)(x - 9/5)(x - 10) gives the stated coefficients, and exact " + "rational evaluation confirms p(6/5) = p(9/5) = p(10) = 0. alkahest reported only " + "x = 10: the continued-fraction lower bound assumed 'p(k) has the sign of p(0) ⇒ no " + "root below k', which is false when the count below k is even." + ), + ), + Case( + id="real_roots_chebyshev_t6_all_six", + subsystem="solving", + statement="the Chebyshev polynomial T₆ = 32x⁶ - 48x⁴ + 18x² - 1 has six real roots", + op=_real_root_count([-1, 0, 18, 0, -48, 0, 32]), + contract=Returns(6), + verified_by=( + "T₆(cos θ) = cos 6θ, so the roots are cos((2k+1)π/12) for k = 0..5 — six distinct " + "values in (-1, 1). alkahest reported two." + ), + ), + Case( + id="refine_root_ball_brackets_sqrt_two", + subsystem="solving", + statement="refine_root's ball for x² - 2 must actually contain √2", + op=_refined_ball_brackets_root([-2, 0, 1], 1), + contract=Returns(True), + verified_by=( + "Checked in exact Fraction arithmetic on the ball's own endpoints: x² - 2 must vanish " + "at one of them or change sign across them. alkahest returned mid = 1.414213562373095, " + "rad = 1.11e-16, for which (mid + rad)² - 2 = -4.06e-17 < 0 — the entire ball lies " + "strictly below √2, so it does not contain the root it claims to enclose." + ), + ), + Case( + id="refine_root_ball_brackets_large_coefficients", + subsystem="solving", + statement=( + "refine_root must not report a zero-radius ball at a non-root of " + "10⁹x³ - 1414213562x² - 2·10⁹x + 2828427124" + ), + op=_refined_ball_brackets_root([2828427124, -2000000000, -1414213562, 1000000000], 2), + contract=Returns(True), + verified_by=( + "The polynomial is (10⁹x - 1414213562)(x² - 2), so the third bracket isolates √2. " + "alkahest returned an *exact* (radius-0) ball at 1.4142135620573204, where the " + "polynomial is -5.12e-11 ≠ 0 in exact arithmetic: the f64 Horner sign test is " + "unreliable at these coefficient sizes and the bracket collapsed onto its endpoint." + ), + ), + # ── validated bounds ──────────────────────────────────────────────────── + # + # An enclosure that does not contain the value it encloses is the one thing + # a "validated" subsystem may never do: downstream it is not a wrong number + # but a false theorem. + Case( + id="validated_cos_enclosure_contains_cos_one", + subsystem="evaluation", + statement="the validated enclosure of cos x at x = 1 must contain cos 1 = 0.5403…", + op=_enclosure_contains(ak.cos(X), 1.0, 1.0, math.cos(1.0)), + contract=Returns(True), + verified_by=( + "cos 1 = 0.5403023058681398 (math.cos, and alkahest's own interval_eval agrees). " + "bound_on_box returned [-0.5403023058681398, -0.5403023058681397]: the Taylor-model " + "evaluator negated every cosine coefficient while leaving the symmetric remainder " + "bound alone, so the enclosure came back tight, confident and sign-flipped." + ), + ), + Case( + id="validated_no_roots_respects_a_real_root", + subsystem="evaluation", + statement="cos x - 0.9 has a root at arccos(0.9) = 0.4510 ∈ [0,1], so 'no roots' is false", + op=lambda: ak.verified_no_roots(ak.cos(X) - POOL.float(0.9, 53), [(X, 0.0, 1.0)]), + contract=RefusesOr("false"), + verified_by=( + "arccos(0.9) = 0.45102681179626236 lies in [0,1] and cos is continuous, so a root " + "certainly exists there. alkahest answered 'true' — a machine-checked-looking proof " + "of a false theorem, not merely a wrong number." + ), + ), + Case( + id="validated_control_sin_enclosure", + subsystem="evaluation", + statement="the validated enclosure of sin x at x = 1 contains sin 1 = 0.8415…", + op=_enclosure_contains(ak.sin(X), 1.0, 1.0, math.sin(1.0)), + contract=Returns(True), + verified_by=( + "sin 1 = 0.8414709848078965 (math.sin). The control for the cos cases: sin was always " + "correct, so a gate that simply stopped trusting the Taylor-model path would not pass." + ), + ), + # ── integer relations ─────────────────────────────────────────────────── + Case( + id="pslq_exact_integer_inputs_are_not_rounded", + subsystem="number_theory", + statement="guess_relation([2⁶⁰+1, 2⁶⁰, 1]) must report a relation that actually holds", + op=_relation_residual([2**60 + 1, 2**60, 1]), + contract=Returns(0), + verified_by=( + "-(2⁶⁰+1) + 2⁶⁰ + 1 = 0 exactly, so [-1, 1, 1] is a relation. alkahest returned " + "[-1, 1, 0], whose residual over the values supplied is -1, and relation_confidence " + "called it credible with available_digits = inf: the binding extracted every Python " + "int through f64 first, discarding the low bit that the guard then assumed was exact." + ), + ), + Case( + id="pslq_control_small_rational_relation", + subsystem="number_theory", + statement="guess_relation([1, 2, 3]) must find a genuine relation among exact integers", + op=_relation_residual([1, 2, 3]), + contract=Returns(0), + verified_by=( + "1, 2, 3 are integers, so integer relations certainly exist (e.g. [1, 1, -1]). The " + "control for pslq_exact_integer_inputs_are_not_rounded: refusing every integer input " + "must not pass the gate." + ), + ), + # ── known broken: reported in 3.8-silent-error-hunt-2.md, not yet fixed ── + Case( + id="solve_spurious_solution_two_by_two", + subsystem="solving", + statement="solve([x²-xy, xy-y]) must not report (-1, 1), which satisfies neither equation", + op=lambda: max( + abs(float(ak.eval_expr(eq, {X: _num(sol[X]), Y: _num(sol[Y])}))) + for sol in ak.solve([X ** _int(2) - X * Y, X * Y - Y], [X, Y]) + for eq in (X ** _int(2) - X * Y, X * Y - Y) + ), + contract=Returns(0.0, tol=1e-9), + verified_by=( + "xy - y = y(x-1) = 0 forces y = 0 or x = 1; y = 0 gives x² = 0 so (0,0), and x = 1 " + "gives 1 - y = 0 so (1,1). The solution set is {(0,0), (1,1)}. Substituting alkahest's " + "third answer (-1, 1) gives x² - xy = 1 + 1 = 2 ≠ 0 — self-certifying, no oracle." + ), + xfail=( + "SILENT ERROR: solve returns the spurious tuple (-1, 1) with residual 2, and reports " + "four entries for a two-point variety. try_backsolve_generators " + "(alkahest-core/src/solver/mod.rs:475) picks one lex-Groebner generator per variable " + "and never re-checks the finished assignment against the remaining generators. See " + "temp-alkahest/testing/3.8-silent-error-hunt-2.md." + ), + ), + Case( + id="sum_definite_interior_pole_refused", + subsystem="sums_products", + statement="Σ_{k=1}^{10} 1/((k-3)(k-2)) is undefined — the k=2 and k=3 terms divide by zero", + op=lambda: _num( + ak.sum_definite(((K - _int(3)) * (K - _int(2))) ** _int(-1), K, _int(1), _int(10)) + ), + contract=RefusesOr(), + verified_by=( + "The k=2 term is 1/((-1)·0) and the k=3 term is 1/(0·1); neither is a number, so the " + "sum has no value. alkahest returned -5/8. Its own docstring promises E-SUM-003 for " + "exactly this." + ), + xfail=( + "SILENT ERROR: sum_definite tests contains_zero_to_negative_power only on the " + "telescoped difference G(hi+1) - G(lo) (alkahest-core/src/sum/mod.rs:181), so a pole " + "strictly between the endpoints is invisible and only poles landing exactly on lo or " + "hi+1 are caught. See temp-alkahest/testing/3.8-silent-error-hunt-2.md." + ), + ), + Case( + id="product_definite_keeps_rational_scale", + subsystem="sums_products", + statement="Π_{k=1}^{5} 1/2 = 1/32", + op=lambda: _num(ak.product_definite(_rat(1, 2), K, _int(1), _int(5))), + contract=Returns(1.0 / 32.0, tol=1e-12), + verified_by="Five factors of 1/2 multiply to 2^-5 = 1/32, by the definition of a product.", + xfail=( + "SILENT ERROR: product_definite returns 1. ratuni_poly_to_univ " + "(alkahest-core/src/sum/product.rs:109-144) clears coefficient denominators by " + "multiplying through by their LCM and never returns or reapplies that scale, so the " + "answer is off by c^(hi-lo+1). See temp-alkahest/testing/3.8-silent-error-hunt-2.md." + ), + ), ] diff --git a/tests/test_batch_workload.py b/tests/test_batch_workload.py index edc6ba70..98e68064 100644 --- a/tests/test_batch_workload.py +++ b/tests/test_batch_workload.py @@ -17,12 +17,25 @@ from alkahest._batch import UNEXPECTED_ERROR_CODE, BatchItem from alkahest.exceptions import AlkahestError +#: Far above what the budgeted sweeps below take when they work; a stuck sweep +#: is a bug, not a slow machine. +HEAVY_TIMEOUT = 180 + @pytest.fixture def pool(): return ak.ExprPool() +@pytest.fixture(autouse=True) +def _clear_cancel_before_and_after(): + """Cancellation is a process-wide flag — never let a failing assertion in + one test leave it set for the next test (or the next *file*) to inherit.""" + ak.clear_cancel() + yield + ak.clear_cancel() + + # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- @@ -285,6 +298,164 @@ def test_many_helpers_never_raise_on_a_bad_element(pool): assert outs[1].error is not None +# --------------------------------------------------------------------------- +# Budgets reach ``parallel=True`` workers +# +# Budget frames are thread-local on the Rust side, so `context(budget=...)` +# used to stop at the thread boundary: a fanned-out sweep ran unbudgeted, and +# the candidates that a sequential sweep reported as `E-BUDGET-001` came back +# as `E-INT-001` — the integrator's *mathematical* verdict, which a research +# loop records as a permanently closed branch. Both harms are tested here: the +# sweep must stay bounded, and it must say which kind of thing stopped it. +# --------------------------------------------------------------------------- + + +def _hard_trig_integrand(x, n: int, d: int): + """`∫ cos x·sinⁿx/(sin^d x + sin x + 1) dx` — declined by every rule, so it + reaches the Weierstrass half-angle route and hands a degree-2n rational + function to Rothstein-Trager. Unbudgeted, each of the instances used below + runs about 5 s (see ``test_budget.py``); budgeted, each stops in about a + budget's worth of time. + + ``d`` was raised from 17 to 31 for 3.8: the ``d=17`` instances now decline + in ~200 ms, which is inside the 300 ms budget, so nothing tripped and these + tests were asserting a trip that no longer happened. See the rebuilt-ladder + note in ``test_budget.py``.""" + s = ak.sin(x) + return ak.cos(x) * s**n / (s**d + s + 1) + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_parallel_batch_reports_a_budget_trip_as_a_budget_trip(pool): + """The headline: `parallel=True` must behave like `parallel=False`. + + Two assertions, and the first is the important one — `E-INT-001` here is a + claim that no elementary antiderivative exists, and these integrands were + never decided either way. A budget trip is an environment limit and has to + be reported as one. + + The elapsed bound is deliberately loose (a bound is the property under + test, not a stopwatch reading): unbudgeted these four integrands take + minutes, so any factor small enough to catch "the budget never reached the + workers" is fine, and 20x leaves room for a loaded box. + """ + x = pool.symbol("x", "real") + wall_ms = 300 + items = [_hard_trig_integrand(x, n, 31) for n in (40, 41, 42, 43)] + + started = time.perf_counter() + with ak.context(pool=pool, budget=ak.Budget(wall_ms=wall_ms)): + outs = ak.integrate_many(items, x, parallel=True, max_workers=4) + elapsed_ms = (time.perf_counter() - started) * 1000.0 + + codes = [o.error["code"] for o in outs if not o.ok] + assert codes == ["E-BUDGET-001"] * len(items), ( + f"a budget trip must not be reported as a mathematical verdict: {codes}" + ) + assert elapsed_ms < 20 * wall_ms, f"sweep ran {elapsed_ms:.0f} ms against a {wall_ms} ms budget" + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_parallel_batch_still_reports_a_genuine_decline_as_a_decline(pool): + """The control that stops the fix above from being "call everything a + budget trip". Under a budget far larger than the work, a candidate the + integrator genuinely declines must still come back as `E-INT-001`, and a + candidate it can do must still come back with a value.""" + x = pool.symbol("x", "real") + with ak.context(pool=pool, budget=ak.Budget(wall_ms=60_000, max_steps=10_000_000)): + outs = ak.integrate_many([ak.log(ak.log(x)), x**2, ak.sin(x)], x, parallel=True) + + assert [o.ok for o in outs] == [False, True, True] + assert outs[0].error["code"] == "E-INT-001" + assert outs[1].value.value is not None + + +def test_max_steps_reaches_parallel_workers(pool): + """No timing involved: `max_steps=0` trips at the first cooperative + checkpoint, so every item must report `E-BUDGET-002` — under `parallel=True` + exactly as it does sequentially.""" + x = pool.symbol("x", "real") + exprs = [x**n for n in range(1, 5)] + with ak.context(pool=pool, budget=ak.Budget(max_steps=0)): + sequential = ak.integrate_many(exprs, x) + parallel = ak.integrate_many(exprs, x, parallel=True, max_workers=2) + + assert [o.error["code"] for o in sequential] == ["E-BUDGET-002"] * len(exprs) + assert [o.error["code"] for o in parallel] == ["E-BUDGET-002"] * len(exprs) + + +def test_max_steps_reaches_streaming_parallel_workers(pool): + """`batch_map_iter(parallel=True)` fans out through the same path and must + be budgeted too — it is the one a streaming loop actually calls.""" + x = pool.symbol("x", "real") + with ak.context(pool=pool, budget=ak.Budget(max_steps=0)): + items = list(ak.batch_map_iter(lambda e: ak.integrate(e, x), [x, x**2], parallel=True)) + assert {o.index for o in items} == {0, 1} + assert [o.error["code"] for o in items] == ["E-BUDGET-002"] * 2 + + +def test_seed_reaches_parallel_workers(pool): + """`budget_seed()` is what a sampler consults for reproducibility; a worker + that reads `None` there silently makes the sweep non-deterministic.""" + with ak.context(pool=pool, budget=ak.Budget(wall_ms=30_000, seed=7)): + outs = ak.batch_map( + lambda _: (ak.is_budget_active(), ak.budget_seed()), range(4), parallel=True + ) + assert [o.value for o in outs] == [(True, 7)] * 4 + + +def test_parallel_batch_leaves_no_budget_frame_behind(pool): + """Push/pop are paired inside each worker task, and the calling thread's + own state is untouched — a leaked frame would silently budget unrelated + later work on a pooled thread.""" + with ak.context(pool=pool, budget=ak.Budget(wall_ms=30_000, seed=3)): + ak.batch_map(lambda i: i, range(4), parallel=True) + assert ak.is_budget_active() + assert ak.budget_seed() == 3 + assert not ak.is_budget_active() + assert ak.budget_seed() is None + + +def test_unbudgeted_parallel_batch_stays_unbudgeted(pool): + """With no ambient budget, nothing is pushed at all — a sweep that used to + run unlimited must not start tripping on an empty frame.""" + outs = ak.batch_map(lambda _: ak.is_budget_active(), range(3), parallel=True) + assert [o.value for o in outs] == [False] * 3 + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_one_workers_budget_trip_does_not_cancel_its_siblings(pool): + """Cancellation is process-wide; budget frames are not. Reporting a trip by + setting the cancel flag would abort every other in-flight candidate (and + every unrelated call in the process) — so a trip must leave the flag alone. + """ + x = pool.symbol("x", "real") + with ak.context(pool=pool, budget=ak.Budget(wall_ms=200)): + outs = ak.integrate_many( + [_hard_trig_integrand(x, 40, 31), _hard_trig_integrand(x, 41, 31)], + x, + parallel=True, + max_workers=2, + ) + assert all(o.error["code"] == "E-BUDGET-001" for o in outs) + assert not ak.is_cancelled(), "a budget trip must not set the process-wide cancel flag" + # ... and the process is still usable afterwards. + assert ak.integrate(x**2, x).value is not None + + +def test_a_cancel_request_does_reach_parallel_workers(pool): + """The other half of that distinction: an orchestrator *asking* for a + batch-wide abort is honoured, because the flag is process-wide — and it is + reported as `E-BUDGET-003`, not as a mathematical verdict.""" + x = pool.symbol("x", "real") + try: + ak.request_cancel() + outs = ak.integrate_many([x, x**2, x**3], x, parallel=True, max_workers=2) + finally: + ak.clear_cancel() + assert [o.error["code"] for o in outs] == ["E-BUDGET-003"] * 3 + + def test_many_helpers_are_batch_map_over_the_underlying_op(pool): x = pool.symbol("x") exprs = [x**2, x**3] diff --git a/tests/test_budget.py b/tests/test_budget.py index 85888f57..574ca827 100644 --- a/tests/test_budget.py +++ b/tests/test_budget.py @@ -14,6 +14,10 @@ import alkahest as ak import pytest +#: Far above the few seconds the heavy cases below take when they work; a stuck +#: call is a bug, not a slow machine. +HEAVY_TIMEOUT = 120 + @pytest.fixture def pool() -> ak.ExprPool: @@ -177,6 +181,102 @@ def test_wall_budget_trips_after_elapsed(pool, x): assert excinfo.value.code == "E-BUDGET-001" +# --------------------------------------------------------------------------- +# `wall_ms` has to bound the call, not merely be consulted by it +# +# A budget that trips *eventually* is not a budget. These integrands used to +# overshoot `wall_ms=300` by 7-12x, growing with problem size until the last one +# ran for over 90 seconds without ever coming back — the checkpoints existed but +# the seconds were being spent between them, in the Weierstrass half-angle route +# and the rational-function normalisation it feeds. +# +# Deliberately no assertion on elapsed time: the honest claim is "the call comes +# back, and it comes back saying the budget stopped it". A regression puts the +# uninterruptible stretch back and the test hangs until `pytest.mark.timeout` +# kills it, which is the signal we want — a wall-clock assertion would instead +# go red on a loaded CI box for no reason. +# +# The ladder was rebuilt for 3.8. The original rungs — `(12,9) … (40,17)` and +# the pure `1/(sin⁹x + sin x + 1)` — are no longer hard: the LRT `RootSum` +# suppression on the two verify-gated routes and the FLINT-backed `poly_gcd` +# took them from 3.7 s / 14.2 s / ~110 s down to 12 ms / 200 ms / 15 ms, so a +# 300 ms budget has nothing to trip on and the test asserted a decline that no +# longer happens. These tests are about the *budget*, not about those specific +# integrands, so the rungs were moved up to inputs that still cost seconds. +# --------------------------------------------------------------------------- + + +def _hard_trig_integrand(x: ak.Expr, n: int, d: int) -> ak.Expr: + """`∫ cos x·sinⁿx/(sin^d x + sin x + 1) dx`. + + Declined by every rule, so it reaches the Weierstrass half-angle + substitution, which doubles the degree and hands a degree-2n rational + function to Rothstein–Trager. Hard, and hard in a way that scales. + """ + s = ak.sin(x) + return ak.cos(x) * s**n / (s**d + s + 1) + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +@pytest.mark.parametrize(("n", "d"), [(40, 29), (52, 29), (40, 31), (48, 31), (60, 31)]) +def test_wall_budget_stops_a_hard_trig_integral(pool, x, n, d): + """Every rung of the ladder must trip. Unbudgeted these run 1.6-5.4 s, so a + 300 ms budget that does not trip means an uninterruptible stretch is back. + + Cost is *not* monotone in `n` for fixed `d` — `(72, 31)` declines in 13 ms + while `(60, 31)` costs 5.4 s — so the rungs are measured choices, not a + range.""" + with ( + ak.context(pool=pool, budget=ak.Budget(wall_ms=300)), + pytest.raises(ak.BudgetExceededError) as excinfo, + ): + ak.integrate(_hard_trig_integrand(x, n, d), x) + assert excinfo.value.code == "E-BUDGET-001" + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_wall_budget_stops_the_pure_weierstrass_route(pool, x): + """No `cos` factor, so u-substitution cannot apply and the Weierstrass route + is the only thing running. Unbudgeted this integral takes about 2.2 s (the + `sin⁹` original is now 15 ms — see the rebuilt-ladder note above).""" + s = ak.sin(x) + with ( + ak.context(pool=pool, budget=ak.Budget(wall_ms=300)), + pytest.raises(ak.BudgetExceededError) as excinfo, + ): + ak.integrate(1 / (s**25 + s + 1), x) + assert excinfo.value.code == "E-BUDGET-001" + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_wall_budget_stops_between_summands(pool, x): + """The sum rule recurses through `integrate_raw`, which had no checkpoint at + all: a sum of eight hard rational terms ran to completion under a 50 ms + budget because nothing was consulted between terms.""" + total = None + for k in range(1, 9): + term = x ** (k + 12) / (x**11 + k) + total = term if total is None else total + term + with ( + ak.context(pool=pool, budget=ak.Budget(wall_ms=50)), + pytest.raises(ak.BudgetExceededError) as excinfo, + ): + ak.integrate(total, x) + assert excinfo.value.code == "E-BUDGET-001" + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_a_budget_that_is_not_hit_still_returns_the_integral(pool, x): + """The control. Checkpoints that refuse work they could have finished would + pass every test above and be a regression, so pin the other direction: an + integral inside its budget still comes back with a value.""" + with ak.context(pool=pool, budget=ak.Budget(wall_ms=30_000, max_steps=10_000_000)): + assert ak.integrate(x**2 + 1, x).value is not None + assert ak.integrate(1 / (x**2 + 1), x).value is not None + s = ak.sin(x) + assert ak.integrate(ak.cos(x) * s**3, x).value is not None + + # --------------------------------------------------------------------------- # Cancellation # --------------------------------------------------------------------------- @@ -206,7 +306,11 @@ def test_cancel_trips_even_without_a_budget_context(pool, x): def test_cancel_from_another_thread_trips_check_on_this_thread(pool, x): """The whole point of a process-wide flag: an orchestrator thread can - cancel a heavy call running on a different thread.""" + cancel a heavy call running on a different thread. + + The flag is set *before* the call here — the easy half. See + ``test_request_cancel_reaches_a_running_*`` below for the half that + actually matters.""" barrier = threading.Event() def watchdog(): @@ -222,9 +326,147 @@ def watchdog(): ak.integrate(x**2, x) +# --------------------------------------------------------------------------- +# Cancelling a call that is already running +# +# `integrate` and `limit` used to hold the GIL for their whole run, so a +# watchdog thread could not execute a single bytecode until the call it wanted +# to cancel had already finished: only a flag set *before* the call was ever +# observed. For a fan-out search loop — decide a candidate has had enough time, +# stop it, move on — that is the entire use case, so it is worth its own tests. +# +# Both bindings now release the GIL around the core call, and both workloads +# below are chosen to run for seconds so the flag lands somewhere in the middle +# rather than before the engine has started. Nothing here asserts a wall-clock +# bound: the discriminator is *which exception* comes back. With the GIL held +# the call would run to its own verdict (`LimitError` / `IntegrationError`) and +# the cancellation would arrive too late to matter — a loud failure, not a +# timing flake. +# --------------------------------------------------------------------------- + + +def _slow_unanswerable_limit(x: ak.Expr) -> ak.Expr: + """A limit the engine cannot answer, and takes seconds to give up on. + + Triply-nested radicals sit on scales the leading-order route declines, so + the call falls through to the expansion path and runs until the internal + work ceiling stops it — a few seconds of uninterrupted Rust. + """ + return ak.sqrt(ak.sqrt(ak.sqrt(x**2 + x) + x) + x) + + +def _slow_unanswerable_integrand(x: ak.Expr) -> ak.Expr: + """An integrand the rules and the rational path both decline. + + That hands it to the derivative-divides u-substitution search, whose + candidates each run a full recursive `integrate` over a high-degree + rational function in ``u = sin x`` — seconds of work, with a cooperative + checkpoint between candidates. + """ + s = ak.sin(x) + return ak.cos(x) * s**60 / (s**31 + s + 1) + + +def _cancelled_mid_flight(call): + """Run *call* with a watchdog that cancels only once it is already running. + + The watchdog waits for the main thread to say it is about to enter the + engine and *then* sleeps before setting the flag, so a trip proves the + engine observed a cancellation raised during its own run. Setting the flag + beforehand would make this pass with the GIL held, which is the bug. + """ + entering = threading.Event() + cancelled = threading.Event() + + def watchdog(): + entering.wait(timeout=HEAVY_TIMEOUT) + time.sleep(0.05) + ak.request_cancel() + cancelled.set() + + t = threading.Thread(target=watchdog, daemon=True) + t.start() + try: + entering.set() + return call() + finally: + # Always, on every path: the flag is process-wide, and leaving it set + # would trip every later test in this process. + cancelled.wait(timeout=HEAVY_TIMEOUT) + ak.clear_cancel() + t.join(timeout=HEAVY_TIMEOUT) + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_request_cancel_reaches_a_running_limit(pool, x): + with pytest.raises(ak.BudgetExceededError) as excinfo: + _cancelled_mid_flight(lambda: ak.limit(_slow_unanswerable_limit(x), x, pool.pos_infinity())) + assert excinfo.value.code == "E-BUDGET-003" + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_request_cancel_reaches_a_running_integrate(pool, x): + with pytest.raises(ak.BudgetExceededError) as excinfo: + _cancelled_mid_flight(lambda: ak.integrate(_slow_unanswerable_integrand(x), x)) + assert excinfo.value.code == "E-BUDGET-003" + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_engines_still_work_after_a_mid_flight_cancellation(pool, x): + """A cancelled call must leave the engines usable, not wedged.""" + with pytest.raises(ak.BudgetExceededError): + _cancelled_mid_flight(lambda: ak.limit(_slow_unanswerable_limit(x), x, pool.pos_infinity())) + assert not ak.is_cancelled() + assert ak.limit(ak.sqrt(x**2 + x) - x, x, pool.pos_infinity()) == pool.rational(1, 2) + assert ak.integrate(x**2, x).value is not None + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_two_threads_can_run_the_engines_on_one_pool(pool): + """Releasing the GIL is what lets a watchdog run — and also what lets two + workers genuinely overlap on the same `ExprPool` for the first time. + + `ExprPool` interns through a lock-free index and is `Send + Sync`, so this + is sound; the test is here because "sound in principle" is what everyone + says right before a data race. Same answers from every thread, no crash. + """ + oo = pool.pos_infinity() + half = pool.rational(1, 2) + errors: list[BaseException] = [] + answers: list[bool] = [] + lock = threading.Lock() + + def work(k: int) -> None: + try: + xk = pool.symbol("x", "real") + for _ in range(20): + got = ak.limit(ak.sqrt(xk**2 + xk) - xk, xk, oo) + anti = ak.integrate(xk**2 + k, xk) + with lock: + answers.append(got == half and anti.value is not None) + except BaseException as exc: # reported on the main thread, not swallowed + with lock: + errors.append(exc) + + threads = [threading.Thread(target=work, args=(k,)) for k in (1, 2, 3, 4)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=HEAVY_TIMEOUT) + assert not errors, f"engine raised on a worker thread: {errors[0]!r}" + assert len(answers) == 80 + assert all(answers) + + # --------------------------------------------------------------------------- # run_with_wall_fallback — Python-layer supplement for calls with no Rust # checkpoint on every path (documented use case: simplify). +# +# It raises `E-BUDGET-001` when `wall_ms` is overrun, and it enters the budget +# on the worker thread so cooperative call sites there see it. It does *not* +# return control at the deadline: it joins the worker first. Both halves are +# tested below — the second one deliberately, because an unenforced deadline +# that nobody documented is worse than one that is. # --------------------------------------------------------------------------- @@ -262,6 +504,91 @@ def boom(): ak.run_with_wall_fallback(boom, budget=ak.Budget(wall_ms=5_000)) +def test_run_with_wall_fallback_timeout_does_not_poison_the_process(pool, x): + """A timeout must not leave the process-wide cancel flag set. + + ``request_cancel`` is global and sticky, so without a restore one expired + candidate in a long search loop would make *every* later cooperative call + in the process fail with E-BUDGET-003 for the rest of its lifetime. + """ + ak.clear_cancel() + assert not ak.is_cancelled() + + with pytest.raises(ak.BudgetExceededError): + ak.run_with_wall_fallback(lambda: time.sleep(0.2), budget=ak.Budget(wall_ms=10)) + + assert not ak.is_cancelled() + # The next unrelated call still works. + assert ak.integrate(x**2, x) is not None + + +def test_run_with_wall_fallback_preserves_an_existing_cancel_request(pool, x): + """An orchestrator's own outstanding cancellation survives a timeout.""" + ak.request_cancel() + try: + with pytest.raises(ak.BudgetExceededError): + ak.run_with_wall_fallback(lambda: time.sleep(0.2), budget=ak.Budget(wall_ms=10)) + assert ak.is_cancelled() + finally: + ak.clear_cancel() + + +def test_run_with_wall_fallback_enters_the_budget_on_the_worker_thread(): + """Budget frames are thread-local, so the worker used to run the callee + with *no* budget active at all — the docstring's "cooperative call sites + still see it" was false, and the only thing that could stop a runaway call + was the process-wide cancel flag (which stops everything else too).""" + seen = ak.run_with_wall_fallback( + lambda: (ak.is_budget_active(), ak.budget_seed()), + budget=ak.Budget(wall_ms=30_000, seed=11), + ) + assert seen == (True, 11) + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_run_with_wall_fallback_bounds_a_cooperative_callee(pool, x): + """Because the worker now enters the budget, a callee that honours the + cooperative checkpoint stops on its own budget rather than only on the + global cancel flag. Unbudgeted this integrand does not come back at all + (see ``test_wall_budget_stops_a_hard_trig_integral``); the loose bound is + the property under test.""" + s = ak.sin(x) + hard = ak.cos(x) * s**60 / (s**31 + s + 1) + started = time.perf_counter() + with pytest.raises(ak.BudgetExceededError) as excinfo: + ak.run_with_wall_fallback(ak.integrate, hard, x, budget=ak.Budget(wall_ms=300)) + elapsed_ms = (time.perf_counter() - started) * 1000.0 + assert excinfo.value.code == "E-BUDGET-001" + assert elapsed_ms < 20 * 300 + + +@pytest.mark.timeout(HEAVY_TIMEOUT) +def test_run_with_wall_fallback_does_not_bound_an_uncooperative_callee(): + """Pins the documented limitation, so nobody "discovers" it in production. + + ``run_with_wall_fallback`` joins its worker before propagating: Python + cannot kill a thread, and abandoning one trades a bounded stall for + unbounded orphan accumulation plus collateral cancellation (the flag is + process-wide). So for a callee that never reaches a cooperative + checkpoint, ``wall_ms`` selects the *error*, not the *deadline* — and the + message has to say how long control was actually withheld, because that is + the only thing distinguishing this from a real deadline in a log. + + The assertion is deliberately one-sided and far below the callee's own + duration: it fails if the function ever starts returning early (which + would mean an orphan thread), not on a slow machine. + """ + started = time.perf_counter() + with pytest.raises(ak.BudgetExceededError) as excinfo: + ak.run_with_wall_fallback(time.sleep, 1.0, budget=ak.Budget(wall_ms=50)) + elapsed_ms = (time.perf_counter() - started) * 1000.0 + + assert excinfo.value.code == "E-BUDGET-001" + assert elapsed_ms > 500, "documented behaviour is to wait for the callee, not to abandon it" + assert "returned control after" in str(excinfo.value) + assert not ak.is_cancelled() + + # --------------------------------------------------------------------------- # Error codes present and well-formed # --------------------------------------------------------------------------- diff --git a/tests/test_cad_decide.py b/tests/test_cad_decide.py index 37c745c0..4dd85783 100644 --- a/tests/test_cad_decide.py +++ b/tests/test_cad_decide.py @@ -38,8 +38,66 @@ def test_decide_exists_x_squared_equals_two(): phi = alkahest.Exists(x, body) truth, wit = alkahest.decide(phi) assert truth is True - assert isinstance(wit, dict) - assert "x" in wit + # ±√2 is irrational, so there is no rational witness. This used to assert a + # witness dict and was satisfied by the isolating interval's midpoint — a + # "solution" of x² = 2 that does not solve it. A witness is a certificate; + # reporting a wrong one is worse than reporting none. + assert wit is None + + +def test_decide_exists_witness_actually_satisfies_the_equation(): + """A reported witness must satisfy the sentence it witnesses. + + ``∃x. 3x − 2 = 0`` has the rational solution ``2/3``. Root isolation used + to leave the bracket at ``[0, 1]`` and the witness came back as its midpoint + ``1/2``, which fails the very equation it was offered as a solution to. + """ + from fractions import Fraction + + import alkahest + from alkahest import ExprPool + + pool = ExprPool() + x = pool.symbol("x") + body = pool.pred_eq(pool.integer(3) * x - pool.integer(2), pool.integer(0)) + truth, wit = alkahest.decide(alkahest.Exists(x, body)) + assert truth is True + assert wit is not None + assert Fraction(wit["x"]) == Fraction(2, 3) + + +def test_decide_forall_strict_square_false_at_non_dyadic_root(): + """``∀x. (3x + 2)² > 0`` is FALSE — the square vanishes at x = −2/3. + + The CAD sample set is built from isolating-bracket endpoints and midpoints, + all dyadic, so ``−2/3`` was never tested and the sentence came back ``True``: + a machine-checked-looking proof of a false theorem. + """ + import alkahest + from alkahest import ExprPool + + pool = ExprPool() + x = pool.symbol("x") + inner = pool.integer(3) * x + pool.integer(2) + body = pool.gt(inner**2, pool.integer(0)) + truth, _ = alkahest.decide(alkahest.Forall(x, body)) + assert truth is False + + +def test_decide_forall_strict_square_refuses_at_irrational_root(): + """``∀x. (x² − 2)² > 0`` is FALSE at ±√2, and no rational sample shows it. + + The honest answer is a refusal, not ``True``. + """ + import alkahest + from alkahest import ExprPool + + pool = ExprPool() + x = pool.symbol("x") + inner = x**2 - pool.integer(2) + body = pool.gt(inner**2, pool.integer(0)) + with pytest.raises(alkahest.CadError): + alkahest.decide(alkahest.Forall(x, body)) def test_cad_lift_quadratic_roots(): @@ -181,8 +239,10 @@ def test_decide_univariate_regression_exists_quadratic_root(): phi = alkahest.Exists(x, body) truth, wit = alkahest.decide(phi) assert truth is True - assert isinstance(wit, dict) - assert "x" in wit + # No *rational* witness exists for ±√2 — see + # test_decide_exists_x_squared_equals_two for why reporting the isolating + # interval's midpoint instead is a wrong certificate rather than a weak one. + assert wit is None def test_decide_univariate_regression_forall_square_nonneg(): diff --git a/tests/test_expression_depth_limit.py b/tests/test_expression_depth_limit.py new file mode 100644 index 00000000..3f12ec28 --- /dev/null +++ b/tests/test_expression_depth_limit.py @@ -0,0 +1,200 @@ +"""Deeply nested expressions must be refused, not fatal. + +Every operation on an expression is a structural recursion over the DAG, and a +native stack overflow is a ``SIGSEGV`` — the process dies with no traceback and +no exception, so an unattended loop's ``except Exception`` never runs and the +whole run is lost. Past a measured ceiling those operations raise +:class:`~alkahest.DepthLimitError` (``E-DEPTH-001``) instead. + +Before this guard the following killed the interpreter outright, at these +depths, on a release build with the usual 8 MiB main-thread stack: + +=============================== ============ ========== +operation deepest OK segfaulted +=============================== ============ ========== +``symbolic_grad`` 4 625 4 687 +``simplify`` / ``to_lean`` 9 216 9 472 +``latex`` 13 312 13 824 +``unicode_str`` 15 360 15 872 +``str`` / ``repr`` 23 552 24 576 +=============================== ============ ========== + +Every test here stays **just past** the limit rather than out at the old crash +depths: a regression must fail the assertion, not take the test process down +with it. +""" + +from __future__ import annotations + +import alkahest as ak +import pytest + +#: Mirrors ``alkahest_core::kernel::depth::MAX_EXPR_DEPTH``. Hard-coded rather +#: than imported so that lowering the Rust constant without updating the +#: documented contract shows up here. +MAX_EXPR_DEPTH = 2048 + + +@pytest.fixture +def pool() -> ak.ExprPool: + return ak.ExprPool() + + +@pytest.fixture +def x(pool: ak.ExprPool) -> ak.Expr: + return pool.symbol("x", "real") + + +def nest(x: ak.Expr, depth: int) -> ak.Expr: + """``sin(sin(...sin(x)...))`` with ``depth`` applications.""" + e = x + for _ in range(depth): + e = ak.sin(e) + return e + + +@pytest.fixture +def too_deep(x: ak.Expr) -> ak.Expr: + """Exactly one level past the ceiling — the cheapest input that must fail.""" + return nest(x, MAX_EXPR_DEPTH) # depth = MAX_EXPR_DEPTH + 1 counting `x` + + +# --------------------------------------------------------------------------- +# The boundary itself +# --------------------------------------------------------------------------- + + +def test_at_the_limit_is_accepted(x: ak.Expr): + """The documented number is the deepest that still works, not the first + that fails — otherwise the limit in the docs is off by one.""" + ok = nest(x, MAX_EXPR_DEPTH - 1) + assert str(ok).count("sin") == MAX_EXPR_DEPTH - 1 + + +def test_one_past_the_limit_is_refused(too_deep: ak.Expr): + with pytest.raises(ak.DepthLimitError) as excinfo: + str(too_deep) + assert excinfo.value.code == "E-DEPTH-001" + assert "2048" in str(excinfo.value) + + +def test_refusal_is_catchable_as_a_plain_exception(too_deep: ak.Expr): + """The whole point: a loop wrapping work in ``except Exception`` must + survive. A ``PanicException`` (a ``BaseException``) or a segfault would + both slip past the ``except Exception`` a real loop is written with.""" + caught: Exception | None = None + try: + ak.simplify(too_deep) + except Exception as e: # deliberately broad: catching it here is the test + caught = e + assert isinstance(caught, ak.AlkahestError), ( + f"a too-deep expression must be refused, got {caught!r}" + ) + assert caught.code == "E-DEPTH-001" + + +def test_width_is_not_depth(pool: ak.ExprPool, x: ak.Expr): + """A wide n-ary node is shallow and must still be printable: the guard + measures nesting, and confusing it with size would refuse ordinary work.""" + wide = pool.add([pool.integer(i) for i in range(50_000)] + [x]) + assert len(str(wide)) > 100_000 + + +# --------------------------------------------------------------------------- +# Every entry point that used to die +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "call", + [ + pytest.param(lambda e, x, p: str(e), id="str"), + pytest.param(lambda e, x, p: repr(e), id="repr"), + pytest.param(lambda e, x, p: ak.latex(e), id="latex"), + pytest.param(lambda e, x, p: ak.unicode_str(e), id="unicode_str"), + pytest.param(lambda e, x, p: ak.simplify(e), id="simplify"), + pytest.param(lambda e, x, p: ak.simplify_par(e), id="simplify_par"), + pytest.param(lambda e, x, p: ak.simplify_redex(e), id="simplify_redex"), + pytest.param(lambda e, x, p: ak.simplify_auto(e), id="simplify_auto"), + pytest.param(lambda e, x, p: ak.simplify_egraph(e), id="simplify_egraph"), + pytest.param(lambda e, x, p: ak.simplify_expanded(e), id="simplify_expanded"), + pytest.param(lambda e, x, p: ak.simplify_trig(e), id="simplify_trig"), + pytest.param(lambda e, x, p: ak.collect_like_terms(e), id="collect_like_terms"), + pytest.param(lambda e, x, p: ak.diff(e, x), id="diff"), + pytest.param(lambda e, x, p: ak.symbolic_grad(e, [x]), id="symbolic_grad"), + pytest.param(lambda e, x, p: ak.jacobian([e], [x]), id="jacobian"), + pytest.param(lambda e, x, p: ak.subs(e, {x: p.integer(1)}), id="subs"), + pytest.param(lambda e, x, p: ak.eval_expr(e, {x: 0.5}), id="eval_expr"), + pytest.param(lambda e, x, p: ak.evaluate(e, {x: 0.5}), id="evaluate"), + pytest.param(lambda e, x, p: ak.compile_expr(e, [x]), id="compile_expr"), + pytest.param(lambda e, x, p: ak.to_lean(e), id="to_lean"), + pytest.param(lambda e, x, p: ak.to_stablehlo(e, [x]), id="to_stablehlo"), + pytest.param(lambda e, x, p: ak.plot_dag(e), id="plot_dag"), + pytest.param(lambda e, x, p: ak.integrate(e, x), id="integrate"), + pytest.param(lambda e, x, p: ak.limit(e, x, p.integer(0)), id="limit"), + pytest.param(lambda e, x, p: ak.series(e, x, p.integer(0), 3), id="series"), + pytest.param(lambda e, x, p: ak.sum_indefinite(e, x), id="sum_indefinite"), + pytest.param(lambda e, x, p: ak.poly_normal(e, [x]), id="poly_normal"), + pytest.param(lambda e, x, p: ak.cancel(e), id="cancel"), + pytest.param(lambda e, x, p: ak.together(e), id="together"), + pytest.param(lambda e, x, p: ak.apart(e, x), id="apart"), + pytest.param(lambda e, x, p: ak.horner(e, x), id="horner"), + pytest.param(lambda e, x, p: ak.emit_c(e, x, "v", "f"), id="emit_c"), + pytest.param(lambda e, x, p: ak.real_roots(e, x), id="real_roots"), + pytest.param(lambda e, x, p: ak.resultant(e, e, x), id="resultant"), + pytest.param(lambda e, x, p: ak.prove_nonneg(e, [x]), id="prove_nonneg"), + pytest.param(lambda e, x, p: ak.sos_decompose(e, [x]), id="sos_decompose"), + pytest.param(lambda e, x, p: ak.to_smtlib(p.lt(e, p.integer(1))), id="to_smtlib"), + pytest.param(lambda e, x, p: ak.satisfiable(p.lt(e, p.integer(1))), id="satisfiable"), + pytest.param(lambda e, x, p: ak.decide(ak.Forall(x, p.lt(e, p.integer(1)))), id="decide"), + pytest.param(lambda e, x, p: ak.match_pattern(ak.sin(x), e), id="match_pattern"), + pytest.param( + lambda e, x, p: ak.interval_eval(e, {x: ak.ArbBall(0.5, 0.1)}), + id="interval_eval", + ), + ], +) +def test_entry_point_refuses_instead_of_recursing( + call, too_deep: ak.Expr, x: ak.Expr, pool: ak.ExprPool +): + with pytest.raises(ak.DepthLimitError) as excinfo: + call(too_deep, x, pool) + assert excinfo.value.code == "E-DEPTH-001" + + +def test_batch_entry_points_report_the_refusal_per_item(too_deep: ak.Expr, x: ak.Expr): + """``*_many`` collect per-item outcomes rather than raising, so the refusal + has to show up in the item, not as a crash.""" + (item,) = ak.simplify_many([too_deep]) + assert item.error is not None + assert item.error["code"] == "E-DEPTH-001" + + (item,) = ak.diff_many([too_deep], x) + assert item.error is not None + assert item.error["code"] == "E-DEPTH-001" + + +# --------------------------------------------------------------------------- +# Deep polynomials — a different shape that reached different converters +# --------------------------------------------------------------------------- + + +def test_deep_polynomial_is_refused_by_the_polynomial_converters(pool: ak.ExprPool, x: ak.Expr): + """``sin`` chains are rejected early by anything polynomial-only, so the + converters needed a polynomial-shaped deep input to be exercised at all — + and that shape crashed a different set of entry points.""" + one = pool.integer(1) + e = x + for _ in range(MAX_EXPR_DEPTH): + e = pool.mul([pool.add([e, one]), pool.integer(2)]) + + for call in ( + lambda: ak.poly_normal(e, [x]), + lambda: ak.real_roots(e, x), + lambda: ak.prove_nonneg(e, [x]), + lambda: ak.sum_indefinite(e, x), + lambda: ak.product_indefinite(e, x), + lambda: ak.solve([pool.pred_eq(e, one)], [x]), + ): + with pytest.raises(ak.DepthLimitError): + call() diff --git a/tests/test_linear_algebra.py b/tests/test_linear_algebra.py index 13dfe6f7..24d58050 100644 --- a/tests/test_linear_algebra.py +++ b/tests/test_linear_algebra.py @@ -323,3 +323,35 @@ def test_proven_singular_keeps_its_own_code_after_a_refusal(): with pytest.raises(alkahest.MatrixError) as exc_info: singular.inverse() assert exc_info.value.code == "E-MAT-003" + + +@pytest.mark.parametrize("op", ["nullspace", "eigenvects", "jordan_form"]) +def test_undecidable_entry_keeps_its_code_through_the_kernel_routines(op): + """`nullspace`, `eigenvects` and `jordan_form` share one elimination. + + All three used to flatten an undecidable entry into their own generic + "kernel failed" verdict — `E-LINALG-002` ("could not compute nullspace + basis") or `E-EIGEN-006` — because the routine they share returned an error + with no payload, so the reason died at that boundary. A caller could not + tell "this matrix is hard for the kernel routine" (nothing to be done) from + "one entry's vanishing is undecidable" (substitute concrete parameters and + it works). + """ + pool = alkahest.ExprPool() + zero = pool.integer(0) + m = alkahest.Matrix([[_undecidable(pool), zero], [zero, zero]]) + with pytest.raises(alkahest.AlkahestError) as exc_info: + getattr(m, op)() + assert exc_info.value.code == "E-LINALG-010" + assert "mystery" in str(exc_info.value) + assert exc_info.value.remediation + + +def test_a_computable_nullspace_is_still_computed(): + """The control: refusing everything would pass the test above and be + useless. A rank-1 symbolic matrix must still give a 1-dimensional kernel.""" + pool = alkahest.ExprPool() + a = pool.symbol("a") + exp_a = alkahest.exp(a) + m = alkahest.Matrix([[pool.integer(1), exp_a], [exp_a, exp_a * exp_a]]) + assert len(m.nullspace()) == 1 diff --git a/tests/test_panic_boundary.py b/tests/test_panic_boundary.py new file mode 100644 index 00000000..ed689d27 --- /dev/null +++ b/tests/test_panic_boundary.py @@ -0,0 +1,186 @@ +"""Rust panics must not reach Python as ``PanicException``. + +A panic that crosses the PyO3 boundary surfaces as ``pyo3_runtime.PanicException``, +which inherits from ``BaseException``, **not** ``Exception``. An unattended loop +that wraps each candidate in ``except Exception`` therefore does not catch it, +and the run dies — the same failure mode as a segfault, minus the core dump. + +Each test below drives an argument that used to panic inside Rust and asserts +both that it raises something ``except Exception`` can see, and that it is the +specific error type a caller would expect. +""" + +from __future__ import annotations + +import alkahest as ak +import pytest +from alkahest.alkahest import Fps + + +@pytest.fixture +def pool() -> ak.ExprPool: + return ak.ExprPool() + + +@pytest.fixture +def x(pool: ak.ExprPool) -> ak.Expr: + return pool.symbol("x", "real") + + +def assert_ordinary_exception(fn) -> Exception: + """Call ``fn`` and require the failure to be a catchable ``Exception``. + + The ``BaseException`` arm is what a ``PanicException`` would land in — the + regression this whole module is about. + """ + try: + fn() + except Exception as e: # deliberately broad: catching it here is the test + return e + except BaseException as e: # pragma: no cover - reaching this arm is the bug + pytest.fail(f"escaped as {type(e).__name__}, which except Exception misses") + pytest.fail("expected a refusal") + + +# --------------------------------------------------------------------------- +# rug precision: Float::with_val panics outside [1, i32::MAX] +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("prec", [0, 4_000_000_000]) +def test_arbball_rejects_out_of_range_precision(prec: int): + e = assert_ordinary_exception(lambda: ak.ArbBall(1.0, 0.0, prec)) + assert isinstance(e, ValueError) + + +@pytest.mark.parametrize("prec", [0, 4_000_000_000]) +def test_pool_float_rejects_out_of_range_precision(pool: ak.ExprPool, prec: int): + e = assert_ordinary_exception(lambda: pool.float(1.5, prec)) + assert isinstance(e, ValueError) + + +def test_interval_eval_rejects_zero_precision(pool: ak.ExprPool, x: ak.Expr): + """``evaluate`` already validated this; ``interval_eval`` was its + unguarded twin and panicked deep inside the ball evaluator.""" + e = assert_ordinary_exception(lambda: ak.interval_eval(x, {x: ak.ArbBall(0.5, 0.1)}, prec=0)) + assert isinstance(e, ValueError) + + +def test_guess_relation_rejects_zero_precision(): + e = assert_ordinary_exception(lambda: ak.guess_relation([1.0, 2.0], precision_bits=0)) + assert isinstance(e, ValueError) + + +def test_bound_on_box_rejects_zero_precision(pool: ak.ExprPool, x: ak.Expr): + e = assert_ordinary_exception(lambda: ak.bound_on_box(x, [(x, 0.0, 1.0)], prec=0)) + assert isinstance(e, ValueError) + + +def test_a_valid_precision_still_works(pool: ak.ExprPool, x: ak.Expr): + """The validator must not have narrowed the useful range.""" + assert ak.ArbBall(1.0, 0.0, 256).mid == 1.0 + assert ak.interval_eval(x, {x: ak.ArbBall(0.5, 0.1)}, prec=256) is not None + + +# --------------------------------------------------------------------------- +# Matrix element access +# --------------------------------------------------------------------------- + + +def test_matrix_get_out_of_range_raises_index_error(pool: ak.ExprPool, x: ak.Expr): + """``Matrix::get`` indexes a flat vector with no bounds check, so an + off-by-one loop bound used to panic rather than raise.""" + m = ak.Matrix([[x, x], [x, x]]) + e = assert_ordinary_exception(lambda: m.get(0, 5)) + assert isinstance(e, IndexError) + e = assert_ordinary_exception(lambda: m.get(5, 0)) + assert isinstance(e, IndexError) + # A huge row index used to wrap `r * cols` and silently read the wrong + # element instead of failing. + assert_ordinary_exception(lambda: m.get(2**62, 0)) + assert m.get(1, 1) is not None + + +# --------------------------------------------------------------------------- +# Budget +# --------------------------------------------------------------------------- + + +def test_enormous_wall_ms_saturates_instead_of_panicking(): + """``Duration::from_secs_f64`` panics past ``u64::MAX`` seconds, and + ``wall_ms=1e30`` is a plausible way to spell 'effectively unlimited'.""" + with ak.context(budget=ak.Budget(wall_ms=1e30)): + assert ak.is_budget_active() + + +# --------------------------------------------------------------------------- +# A Python callback invoked from Rust +# --------------------------------------------------------------------------- + + +def test_raising_sparse_interp_oracle_propagates_its_own_exception(): + """The oracle is user code called from Rust through an infallible + signature; it used to be ``.expect()``-ed, so *any* exception it raised + became a ``PanicException``.""" + + class Sentinel(Exception): + pass + + def oracle(_x: int) -> int: + raise Sentinel("oracle exploded") + + with pytest.raises(Sentinel): + ak.sparse_interp_univariate(oracle, 3, 997) + + +def test_sparse_interp_oracle_returning_a_bad_type_is_a_type_error(): + e = assert_ordinary_exception( + lambda: ak.sparse_interp_univariate(lambda _x: "not an int", 3, 997) + ) + assert isinstance(e, TypeError) + + +def test_a_working_sparse_interp_oracle_is_unaffected(): + p = 997 + + def f(v: int) -> int: + return (v**5 + 3) % p + + terms = ak.sparse_interp_univariate(f, 3, p) + assert sorted(terms) == [(1, 5), (3, 0)] + + +# --------------------------------------------------------------------------- +# Unbounded allocation from a user-supplied size +# --------------------------------------------------------------------------- + + +def test_plot_svg_rejects_an_absurd_point_count(pool: ak.ExprPool, x: ak.Expr): + """``n_pts`` reached ``Vec::with_capacity`` unchecked: a capacity-overflow + panic, or an allocation the OOM killer resolves.""" + e = assert_ordinary_exception(lambda: ak.plot_svg(x, x, n=10**15)) + assert isinstance(e, ValueError) + assert ak.plot_svg(x, x, n=50).startswith("