diff --git a/.github/workflows/alkahest-semver-check.yml b/.github/workflows/alkahest-semver-check.yml index b1a14183..e828ef4c 100644 --- a/.github/workflows/alkahest-semver-check.yml +++ b/.github/workflows/alkahest-semver-check.yml @@ -3,8 +3,12 @@ name: semver-check on: pull_request: branches: [main] + # `release/**` is included because a release branch accumulates merges that + # would otherwise reach a tag with no CI at all: `pull_request` runs did not + # fire for a PR based on `release/3.8.0`, and push only covered `main`, so + # everything merged there was verified by local gates alone. push: - branches: [main] + branches: [main, 'release/**'] env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/ci-cross.yml b/.github/workflows/ci-cross.yml index 27dc16fe..8c51e07d 100644 --- a/.github/workflows/ci-cross.yml +++ b/.github/workflows/ci-cross.yml @@ -13,8 +13,12 @@ name: ci-cross on: pull_request: branches: [main] + # `release/**` is included because a release branch accumulates merges that + # would otherwise reach a tag with no CI at all: `pull_request` runs did not + # fire for a PR based on `release/3.8.0`, and push only covered `main`, so + # everything merged there was verified by local gates alone. push: - branches: [main] + branches: [main, 'release/**'] env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f689d921..38c84eb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,24 @@ name: CI on: + # `release/**` is included because a release branch accumulates merges that + # would otherwise reach a tag with no CI at all: `pull_request` runs did not + # fire for a PR based on `release/3.8.0`, and push only covered `main`, so + # everything merged there was verified by local gates alone. push: - branches: [main] + branches: [main, 'release/**'] pull_request: schedule: - cron: "0 2 * * *" # 02:00 UTC daily — triggers the nightly job workflow_dispatch: # allow re-running nightly shards after CI fixes +# No job here reads or writes repository state through the API — these are +# build-and-test jobs only. They do run build scripts and test code from the +# head of a pull request, so the checkout token is dropped to read-only rather +# than left at whatever the repository default happens to be. +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUSTFLAGS: "-D warnings" @@ -154,30 +165,54 @@ jobs: PYTHONUNBUFFERED: "1" run: pytest --timeout=900 --ignore=tests/silent_errors + + # ── AddressSanitizer, as its own job rather than a step inside Tier 1a. + # + # It was 467s of Tier 1a's ~1000s, which made it half the wait on every PR. + # But CI wall-clock is the slowest *job*, not the sum of steps: run in + # parallel it costs nothing on the critical path, so the coverage comes back + # at PR time and Tier 1a still finishes first. Scoped to `-p alkahest-cas` + # here for speed; the nightly `asan` shard runs the whole workspace. + asan: + name: AddressSanitizer — alkahest-cas + runs-on: ubuntu-latest + if: github.event_name != 'schedule' + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v6 + - name: Install Rust nightly (for ASan) uses: dtolnay/rust-toolchain@nightly with: targets: x86_64-unknown-linux-gnu components: rust-src - # Scope to alkahest-cas: full-workspace + sanitizer + build-std often exceeds - # runner wall-clock with little log output (looks "stuck"). MLIR/Python ASan - # stays uninstrumented here; TSAN/LSan still cover the workspace on schedule. + - name: Install system dependencies + run: sudo apt-get install -y libflint-dev llvm-15-dev llvm-15 + + - name: Cache Cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-asan-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-asan- + + # --lib --tests: doc-test binaries link separately and do not receive the + # ASan runtime under -Z build-std ("undefined symbol: __asan_init"). + # LSAN_OPTIONS=detect_leaks=0: leaks belong to the nightly lsan shard, and + # the GMP/rug thread caches are false positives here. - name: cargo test (AddressSanitizer, alkahest-cas only) - # --lib --tests: skip doc tests — doc-test binaries link separately and - # don't receive the ASan runtime when built via -Z build-std, causing - # "undefined symbol: __asan_init" linker errors. Unit + integration - # tests are the meaningful targets for buffer-overflow detection anyway. - # LSAN_OPTIONS=detect_leaks=0: suppress GMP/rug thread-cache false positives. env: LSAN_OPTIONS: detect_leaks=0 run: | - echo "::group::ASan build + test (alkahest-cas)" RUSTFLAGS="-Zsanitizer=address" \ cargo +nightly test -p alkahest-cas --lib --tests \ --target x86_64-unknown-linux-gnu \ -Z build-std - echo "::endgroup::" # ── Wheel smoke: README quickstart + fresh-interpreter parse, against a # *built wheel* installed into its own clean venv -- not tier1's @@ -241,10 +276,15 @@ jobs: # ── Tier 1b: slow Python integration (roadmap-level sparse_interp) # Runs on the nightly schedule only so push/PR CI stays fast; run locally with: # pytest tests/test_sparse_interp.py -m slow --timeout=0 -v --override-ini="addopts=-v" + # Runs on PRs as well as nightly. It was nightly-only because the + # sparse_interp roadmap case took long enough to matter; #292 replaced the + # recursive formulation with Zippel's iterative algorithm and the whole job is + # now about a minute, of which the tests are half a second. Running in + # parallel it costs nothing on the critical path, and it guards the oracle + # call-count property — the thing a regression there would silently undo. tier1-python-slow: name: Tier 1b — Python slow tests runs-on: ubuntu-latest - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' timeout-minutes: 360 steps: - uses: actions/checkout@v6 @@ -306,6 +346,7 @@ jobs: fail-fast: false matrix: shard: + - asan - proptest - hypothesis - tsan @@ -327,8 +368,8 @@ jobs: - name: Install Rust nightly if: >- - matrix.shard == 'tsan' || matrix.shard == 'lsan' || - matrix.shard == 'valgrind' + matrix.shard == 'asan' || matrix.shard == 'tsan' || + matrix.shard == 'lsan' || matrix.shard == 'valgrind' uses: dtolnay/rust-toolchain@nightly with: targets: x86_64-unknown-linux-gnu @@ -417,6 +458,24 @@ jobs: --target x86_64-unknown-linux-gnu \ -Z build-std + # Moved out of Tier 1a, where it was 467s of a ~1000s job — nearly half + # the time a contributor waits on a PR — for a check whose value is depth + # rather than immediacy. Nightly can also afford the *whole workspace* + # rather than the `-p alkahest-cas` subset Tier 1a was scoped to. + - name: cargo test (AddressSanitizer) + if: matrix.shard == 'asan' + # --lib --tests: doc-test binaries link separately and do not receive the + # ASan runtime under -Z build-std ("undefined symbol: __asan_init"). + # LSAN_OPTIONS=detect_leaks=0: leaks are the `lsan` shard's job, and the + # GMP/rug thread caches are false positives here. + env: + LSAN_OPTIONS: detect_leaks=0 + run: | + RUSTFLAGS="-Zsanitizer=address" \ + cargo +nightly test --workspace --lib --tests \ + --target x86_64-unknown-linux-gnu \ + -Z build-std + - name: Install Valgrind if: matrix.shard == 'valgrind' # Without `update` the runner's package index is stale and the fetch @@ -430,12 +489,65 @@ jobs: env: RUSTFLAGS: "-C debuginfo=2 -Z dwarf-version=4" run: | - cargo +nightly build --all --target x86_64-unknown-linux-gnu -Z build-std - for bin in target/x86_64-unknown-linux-gnu/debug/deps/alkahest_cas-*; do - [ -x "$bin" ] || continue + # Ask cargo which executables it produced instead of globbing a path. + # The previous version globbed `target//debug/deps/alkahest_cas-*` + # after a `cargo build`, which produces no test binaries at all — so the + # loop body never ran and the shard reported success having valgrinded + # nothing. Switching to `test --no-run` fixed the build but the glob + # still missed, because `-Z build-std` does not place them where the + # path assumed. `--message-format=json` reports the real paths, so + # there is nothing left to drift. + cargo +nightly test --no-run --all \ + --target x86_64-unknown-linux-gnu -Z build-std \ + --message-format=json > /tmp/cargo-test.json + mapfile -t bins < <( + jq -r 'select(.profile.test == true) | .executable | select(. != null)' \ + /tmp/cargo-test.json | grep alkahest_cas + ) + # Scope to the FFI boundary this step is named for. Valgrinding every + # unit test in a -Z build-std debug build took 2h49m of the 6h budget + # on its first real run, which ties up a runner for most of a night to + # re-check pure-Rust logic the other shards already cover. The tests + # that actually cross into GMP/MPFR/FLINT are the ones worth the 20-50x + # slowdown. + # + # libtest takes filters as separate positional arguments and matches + # them as substrings, not as one regex — a single "a|b" argument would + # match nothing, run zero tests, and report clean. Set + # ALKAHEST_VALGRIND_FILTER (space separated) to override, or empty for + # a full sweep. + read -r -a filters <<< "${ALKAHEST_VALGRIND_FILTER-ball:: flint rug number_theory:: lattice:: validated::}" + if [ "${#bins[@]}" -eq 0 ]; then + echo "::error title=Valgrind ran nothing::cargo reported no alkahest_cas test executables" >&2 + jq -r 'select(.executable != null) | .executable' /tmp/cargo-test.json >&2 || true + exit 1 + fi + for bin in "${bins[@]}"; do + echo "== valgrind $bin" + # --errors-for-leak-kinds=definite,indirect: fail on real leaks and + # on any memory-safety error, but not on "possibly lost". The first + # real run of this gate reported 0 definite and 0 indirect bytes and + # four "possibly lost" records — a thread-local Thread handle, MPFR's + # const_log2 cache, and two hashbrown tables. Those are interior + # pointers into intentional caches and process-lifetime globals, not + # defects, and suppressing them by stack shape would be four brittle + # rules that rot the first time an inlining decision changes. + # An invalid read/write, or a definitely-lost block, still fails. + # Tee so the run can be asserted on: a filter that matches nothing + # would execute zero tests and valgrind would report a clean bill, + # which is precisely the shape of gate this shard just stopped being. valgrind --leak-check=full --error-exitcode=1 \ - --suppressions=valgrind.supp "$bin" 2>&1 + --errors-for-leak-kinds=definite,indirect \ + --show-leak-kinds=definite,indirect,possible \ + --suppressions=valgrind.supp "$bin" "${filters[@]}" 2>&1 | tee /tmp/vg-out.txt + ran=$(grep -oE '^test result: ok\. [0-9]+ passed' /tmp/vg-out.txt | grep -oE '[0-9]+' | head -1) + echo "tests executed under valgrind: ${ran:-0}" + if [ -z "$ran" ] || [ "$ran" -eq 0 ]; then + echo "::error title=Valgrind executed no tests::filter matched nothing in $bin" >&2 + exit 1 + fi done + echo "valgrind ran over ${#bins[@]} test binary/binaries" - name: Install cargo-afl if: matrix.shard == 'fuzz-expr' || matrix.shard == 'fuzz-simplifier' @@ -443,22 +555,143 @@ jobs: - name: AFL++ fuzzing — expr builder (2 h cap) if: matrix.shard == 'fuzz-expr' + env: + # Lets AFL proceed even if core_pattern could not be changed, instead + # of refusing to start. AFL_SKIP_CPUFREQ likewise: the runner exposes + # no CPU governor and AFL treats that as fatal. + AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES: "1" + AFL_SKIP_CPUFREQ: "1" run: | if [ -f fuzz/src/bin/fuzz_expr_builder.rs ]; then + # AFL refuses to start unless the kernel core_pattern points at a + # file rather than a pipe. Without this it printed its instructions, + # exited non-zero, and `|| true` turned that into a green tick — the + # shard "passed" having executed zero test cases. + # + # `system-config` runs sudo *internally* (it says so in the message + # AFL prints), so it must NOT be prefixed with sudo: cargo lives in + # ~/.cargo/bin, which sudo's secure_path does not include. Best + # effort, because the env fallback below covers it either way. + cargo afl system-config || echo "system-config failed; relying on AFL_* fallbacks" cargo afl build --manifest-path fuzz/Cargo.toml --bin fuzz_expr_builder + # `fuzz/out/` is gitignored, so it does not exist in a fresh + # checkout and AFL cannot create a nested path under a missing + # parent: it aborted with + # SYSTEM ERROR : Unable to create 'fuzz/out/expr_builder' + # *after* clearing the core_pattern hurdle. + mkdir -p fuzz/out + # A missing seed corpus would make AFL exit instantly, which the + # execs_done check would catch — but the message would be obscure. + if [ ! -d fuzz/in/expr_builder ] || [ -z "$(ls -A fuzz/in/expr_builder 2>/dev/null)" ]; then + echo "::error title=No seed corpus::fuzz/in/expr_builder is missing or empty" >&2 + exit 1 + fi + # `fuzz/` is excluded from the root workspace (see Cargo.toml), so + # `--manifest-path fuzz/Cargo.toml` builds into `fuzz/target/`, not + # `target/`. The hardcoded `target/debug/fuzz_expr_builder` produced + # PROGRAM ABORT : Program ... not found or not executable + # Resolve it instead of assuming, and say what was built if not. + bin=$(find fuzz/target target -type f -name fuzz_expr_builder -perm -u+x 2>/dev/null | head -1) + if [ -z "$bin" ]; then + echo "::error title=Fuzz binary not found::fuzz_expr_builder was not produced" >&2 + find fuzz/target target -maxdepth 3 -type f -perm -u+x 2>/dev/null | head -20 >&2 + exit 1 + fi + echo "fuzzing $bin" + set +e timeout 7200 cargo afl fuzz \ -i fuzz/in/expr_builder -o fuzz/out/expr_builder \ - target/debug/fuzz_expr_builder || true + "$bin" + status=$? + set -e + # 124 is `timeout` doing its job after the 2 h cap; 0 means AFL + # chose to stop. Anything else is a real failure and must not be + # swallowed. + if [ "$status" -ne 124 ] && [ "$status" -ne 0 ]; then + echo "::error title=AFL exited abnormally::status $status" >&2 + exit "$status" + fi + # A fuzzer that ran zero executions is not a passing fuzz job. + stats=fuzz/out/expr_builder/default/fuzzer_stats + if [ ! -f "$stats" ]; then + echo "::error title=AFL produced no stats::$stats missing" >&2 + exit 1 + fi + execs=$(awk '/^execs_done/ {print $3}' "$stats") + echo "AFL executions: $execs" + if [ -z "$execs" ] || [ "$execs" -eq 0 ]; then + echo "::error title=AFL executed nothing::execs_done=$execs" >&2 + exit 1 + fi fi - name: AFL++ fuzzing — simplifier (2 h cap) if: matrix.shard == 'fuzz-simplifier' + env: + AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES: "1" + AFL_SKIP_CPUFREQ: "1" run: | if [ -f fuzz/src/bin/fuzz_simplifier.rs ]; then + # AFL refuses to start unless the kernel core_pattern points at a + # file rather than a pipe. Without this it printed its instructions, + # exited non-zero, and `|| true` turned that into a green tick — the + # shard "passed" having executed zero test cases. + # + # `system-config` runs sudo *internally* (it says so in the message + # AFL prints), so it must NOT be prefixed with sudo: cargo lives in + # ~/.cargo/bin, which sudo's secure_path does not include. Best + # effort, because the env fallback below covers it either way. + cargo afl system-config || echo "system-config failed; relying on AFL_* fallbacks" cargo afl build --manifest-path fuzz/Cargo.toml --bin fuzz_simplifier + # `fuzz/out/` is gitignored, so it does not exist in a fresh + # checkout and AFL cannot create a nested path under a missing + # parent: it aborted with + # SYSTEM ERROR : Unable to create 'fuzz/out/simplifier' + # *after* clearing the core_pattern hurdle. + mkdir -p fuzz/out + # A missing seed corpus would make AFL exit instantly, which the + # execs_done check would catch — but the message would be obscure. + if [ ! -d fuzz/in/simplifier ] || [ -z "$(ls -A fuzz/in/simplifier 2>/dev/null)" ]; then + echo "::error title=No seed corpus::fuzz/in/simplifier is missing or empty" >&2 + exit 1 + fi + # `fuzz/` is excluded from the root workspace (see Cargo.toml), so + # `--manifest-path fuzz/Cargo.toml` builds into `fuzz/target/`, not + # `target/`. The hardcoded `target/debug/fuzz_simplifier` produced + # PROGRAM ABORT : Program ... not found or not executable + # Resolve it instead of assuming, and say what was built if not. + bin=$(find fuzz/target target -type f -name fuzz_simplifier -perm -u+x 2>/dev/null | head -1) + if [ -z "$bin" ]; then + echo "::error title=Fuzz binary not found::fuzz_simplifier was not produced" >&2 + find fuzz/target target -maxdepth 3 -type f -perm -u+x 2>/dev/null | head -20 >&2 + exit 1 + fi + echo "fuzzing $bin" + set +e timeout 7200 cargo afl fuzz \ -i fuzz/in/simplifier -o fuzz/out/simplifier \ - target/debug/fuzz_simplifier || true + "$bin" + status=$? + set -e + # 124 is `timeout` doing its job after the 2 h cap; 0 means AFL + # chose to stop. Anything else is a real failure and must not be + # swallowed. + if [ "$status" -ne 124 ] && [ "$status" -ne 0 ]; then + echo "::error title=AFL exited abnormally::status $status" >&2 + exit "$status" + fi + # A fuzzer that ran zero executions is not a passing fuzz job. + stats=fuzz/out/simplifier/default/fuzzer_stats + if [ ! -f "$stats" ]; then + echo "::error title=AFL produced no stats::$stats missing" >&2 + exit 1 + fi + execs=$(awk '/^execs_done/ {print $3}' "$stats") + echo "AFL executions: $execs" + if [ -z "$execs" ] || [ "$execs" -eq 0 ]; then + echo "::error title=AFL executed nothing::execs_done=$execs" >&2 + exit 1 + fi fi - name: Oracle + benches + cross-CAS + artifact upload diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 89912d4b..10e3270b 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -1,8 +1,12 @@ name: CodSpeed on: + # `release/**` is included because a release branch accumulates merges that + # would otherwise reach a tag with no CI at all: `pull_request` runs did not + # fire for a PR based on `release/3.8.0`, and push only covered `main`, so + # everything merged there was verified by local gates alone. push: - branches: [main] + branches: [main, 'release/**'] pull_request: # Benchmarks must be reproducible — no parallel jobs touching the same runner. diff --git a/.github/workflows/cuda_nightly.yml b/.github/workflows/cuda_nightly.yml index e6ea1725..d7c64b29 100644 --- a/.github/workflows/cuda_nightly.yml +++ b/.github/workflows/cuda_nightly.yml @@ -2,9 +2,19 @@ # Runs memcheck and racecheck on CUDA tests. Requires self-hosted GPU runner. name: CUDA Nightly (compute-sanitizer) +# NOT SCHEDULED. This job targets `[self-hosted, gpu-3090]`, and no such runner +# is registered — a scheduled run does not fail, it queues forever, so the +# schedule produced a permanently pending job on `main` and no signal either +# way. `workflow_dispatch` is kept so it can be run the moment a GPU runner +# exists, and the file is kept because it is the specification for running +# these suites: an agent on a GPU box follows it step for step. +# +# Until a runner is registered, the CUDA surface is verified by hand on GPU +# hardware. As of 3.8.0 that run passed: 17 Rust CUDA tests, 17/17 in +# `tests/test_cuda.py`, memcheck and racecheck clean, and GPU/CPU agreement +# within 0-2 ulp checked against numpy's own ufuncs (not `numpy_eval`, which +# shares the Cranelift backend and is therefore not an independent oracle). on: - schedule: - - cron: '0 2 * * *' # 02:00 UTC daily workflow_dispatch: jobs: @@ -29,20 +39,69 @@ jobs: ALKAHEST_GPU_TESTS: "1" run: cargo test --features cuda,groebner-cuda --no-fail-fast + # The Python surface of the CUDA feature had never been built by any CI + # job: this workflow ran `cargo` only. That is how `ak.compile_cuda` came + # to raise AttributeError on a build whose own capabilities() advertised + # `cuda: true`, and survived three releases — the Rust tests all passed + # the entire time, because the gap was in the re-export, not the kernel. + - name: Install Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - uses: astral-sh/setup-uv@v6 + + - name: Create venv and install Python tooling + run: | + uv sync --no-install-project --group dev + echo "$PWD/.venv/bin" >> $GITHUB_PATH + echo "VIRTUAL_ENV=$PWD/.venv" >> $GITHUB_ENV + + - name: maturin develop (cuda + groebner-cuda) + run: maturin develop --manifest-path alkahest-py/Cargo.toml --features "groebner egraph cuda groebner-cuda" + + # ALKAHEST_GPU_TESTS=1 makes the device probe assert rather than skip, so + # a missing or broken GPU fails here instead of quietly reporting green. + - name: pytest (CUDA surface) + env: + ALKAHEST_GPU_TESTS: "1" + run: pytest tests/test_cuda.py -v + + # The capability contract is only falsifiable on a build that has the + # feature; on every other runner these assertions are vacuously true. + - name: Capability contract on a real CUDA build + run: pytest tests/test_agent_contract.py -v + + # Both sanitizer steps are scoped to the two integration targets that + # actually launch kernels. Wrapping the whole `cargo test` instead drags + # rustdoc's doc-test runner under the sanitizer, where it segfaults + # (SIGSEGV, exit 139) *after* the CUDA suites have passed but *before* + # the summary is printed — so racecheck produced no RACECHECK SUMMARY at + # all, and with `continue-on-error: true` that looked like a pass. + # Scoping costs no coverage: the only GPU work in the workspace lives in + # these two targets. The in-`src` unit tests reach `compile_cuda` for PTX + # generation only, and `compute_groebner_basis_gpu(.., None)` takes the + # `reduce_cpu` path, so neither issues a CUDA API call. - name: Run compute-sanitizer memcheck env: ALKAHEST_GPU_TESTS: "1" run: | - compute-sanitizer --tool memcheck \ - cargo test --features cuda,groebner-cuda --no-fail-fast + # --target-processes all is load-bearing: the default is + # application-only, which instruments `cargo` — a process that makes + # no CUDA calls whatsoever. Without it this step emits a banner, no + # ERROR SUMMARY, and a green tick while checking nothing. + compute-sanitizer --target-processes all --tool memcheck \ + cargo test --features cuda,groebner-cuda --no-fail-fast \ + --test nvptx_gpu --test groebner_cuda continue-on-error: false - name: Run compute-sanitizer racecheck env: ALKAHEST_GPU_TESTS: "1" run: | - compute-sanitizer --tool racecheck \ - cargo test --features cuda,groebner-cuda + compute-sanitizer --target-processes all --tool racecheck \ + cargo test --features cuda,groebner-cuda \ + --test nvptx_gpu --test groebner_cuda continue-on-error: true # racecheck may have false positives - name: Upload sanitizer logs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ae7a0ca6..0da61d3b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,8 +2,12 @@ name: docs on: workflow_dispatch: + # `release/**` is included because a release branch accumulates merges that + # would otherwise reach a tag with no CI at all: `pull_request` runs did not + # fire for a PR based on `release/3.8.0`, and push only covered `main`, so + # everything merged there was verified by local gates alone. push: - branches: [main] + branches: [main, 'release/**'] paths: - 'docs/**' - 'python/alkahest/**' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index d28d50ab..51c12a7f 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -77,11 +77,12 @@ jobs: print(ak.simplify_egraph(x + 0).value) assert hasattr(ak, 'solve'), 'expected groebner APIs in default wheel' f = ak.capabilities()["features"] + # `numpy` and `groebner_cuda` are deliberately absent (contract v3): + # neither named anything a Python caller could reach. assert f == { "egraph": True, "groebner": True, "jit": False, "cranelift": True, "llvm_jit": False, "cranelift_jit": True, - "parallel": False, "numpy": False, "cuda": False, - "groebner_cuda": False, + "parallel": False, "cuda": False, } assert ak.jit_is_available(), "expected Cranelift JIT in default wheel" print('default wheel ok (groebner + cranelift)') diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd27954..e8876fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,13 @@ # Changelog -## Unreleased +## 3.8.0 — 2026-08-12 ### 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 +exception, no `NaN` and no verification flag. Eleven were found and fixed this +release. **Eight of them shipped in 3.7 or earlier**, so if you have results +from an affected call, re-run them. The other three were in code added during this release cycle and never reached a published wheel. | Affected call | Wrong answer it gave | First shipped in | Recheck? | @@ -18,6 +18,11 @@ this release cycle and never reached a published wheel. | `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 | +| `product_definite` on a term with any non-integer coefficient | Off by `c^(hi−lo+1)`: `Π_{k=1}^{5} ½` returned `1` instead of `1/32`, `Π (2k−1)/(2k)` at `n = 6` returned `14.4375` instead of `0.2255859375` | ≤ 3.7 | **Yes** — any `product_definite` / `product_indefinite` result | +| `sum_definite` where the summand has a pole strictly *between* the bounds | A clean finite number for a sum with an undefined term: `Σ_{k=1}^{10} 1/((k−3)(k−2))` returned `−5/8` | ≤ 3.7 | **Yes** — any `sum_definite` over a range containing a denominator root | +| `euler_maclaurin` when `corrections` is too small for the summand | A fabricated additive constant — the missing term frozen at the fitting point. `Σ k⁹` at the default `corrections = 2` acquired `34359738368 = 512⁴/2` in a Faulhaber polynomial whose constant term is `0` | this cycle (Euler–Maclaurin is new) | No published release affected | +| `rsolve` on a **forward-shift** spelling with a non-zero right-hand side | The solution of a *different* equation: `f(n+1) − f(n) = n²` with `f(0) = 0` returned `Σ_{j=1}^{n} j²` instead of `Σ_{j=0}^{n−1} j²` | ≤ 3.7 | **Yes** — any `rsolve` written with `f(n+i)`, `i > 0`, and an inhomogeneous term | +| `rsolve` / `solve_linear_recurrence_homogeneous` on an order-2 recurrence with a **repeated** characteristic root | `C₀·rⁿ + C₁·rⁿ` — a one-parameter family presented as the general solution of a second-order equation, losing the `n·rⁿ` branch | ≤ 3.7 | **Yes** — check the discriminant of `r² + b r + c` | 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 @@ -26,10 +31,14 @@ from `BaseException` and therefore slips past `except Exception`. Shipped in 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. +scores **0 silent errors out of 213 scored cases** across evaluation, +integration, limits, linear algebra, number theory, real QE, series, +simplification, solving, and sums/products. Every trap added this cycle was +re-run against a build with the fix reverted and confirmed to score +`silent_error` there, and every trap is paired with a **control** — its nearest +convergent neighbour — so a subsystem cannot pass the gate by refusing +everything. That is a statement about the corpus, not a guarantee about the +library. ### Behaviour changes to plan for @@ -50,6 +59,79 @@ of these is a call whose previous answer was not justified: - **`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. +- **`sum_definite` raises `SumError` (`E-SUM-003`) when the summand is undefined + at an integer inside `[lo, hi]`**, not only when the pole lands on `lo` or + `hi+1`. The refusal names the offending index. Sums whose poles lie outside + the range are unaffected: `Σ_{k=4}^{10} 1/((k−3)(k−2))` still returns `7/8`. +- **`euler_maclaurin` may return a shorter expansion, with no additive + constant.** The constant is now fitted at a point outside the gate's check + points and re-fitted at a second one; if the two disagree it is not a constant + and none is claimed. The report says which way that went in `derivation`, and + the `"fitted numerically"` hypothesis is only listed when a fitted constant is + actually part of the answer. Genuine constants (`γ`, `ζ(2)`, `½log 2π`, …) are + unaffected — they agree across fitting points to 13+ digits. +- **`product_definite(term, k, lo, hi)` with `lo > hi` returns `1` even for a + zero term.** The empty product takes no factors; it previously returned `0` + for `Π_{k=1}^{0} 0` while returning `1` for `Π_{k=1}^{0} k`. +- **`capabilities()["contract_version"]` is `3`, and `features` lost two keys: + `groebner_cuda` and `numpy`.** Indexing either now raises `KeyError`; use + `features.get(name, False)` if you need to span versions. Both were removed + rather than wired up because neither was *falsifiable* — no observation a + Python caller could make distinguished `True` from `False`: + - `groebner_cuda` reported that the CUDA Macaulay-matrix kernel had been + compiled in. The string `groebner_cuda` occurred exactly once anywhere in + `alkahest-py` — the capability line itself. There was no binding, no + `*gpu*` name in the public or the private module, and `GroebnerBasis` + exposes only CPU methods. The kernel is unchanged and still reachable from + Rust as `alkahest_cas::poly::groebner::compute_groebner_basis_gpu`; if + dispatch ever prefers it, the binding lands first and a bit follows it. + - `numpy` mapped to a Cargo feature gating the `numpy` crate, which + `alkahest-py` never used an item from. The feature and the dependency are + both gone. `ak.numpy_eval` and `ak.numpy_eval_par` are unaffected — they go + through the buffer protocol and always worked with the bit `False`, which + is its value on every wheel ever published. + + An unreachable `True` makes a caller trust something it should not, which is + the same class of defect as a silent wrong answer; a bit that correlates with + nothing is better removed than left to be misread. + `tests/test_agent_contract.py::test_every_advertised_feature_has_an_entry_point` + now walks `features` and fails on any key without a named, reachable entry + point, so the next one cannot ship. +- **Rust, `--features groebner-cuda`: `compute_groebner_basis_gpu` and + `reduce_batch` return `(polys, GpuBackendReport)` instead of `polys`.** Both + fall back to CPU row reduction — when `device_id` is `None`, and when the + driver fails — and the basis is identical either way, so a caller previously + had no way to tell a GPU run from a CPU one. `GpuBackendReport::ran_on_gpu()` + is true only when at least one mod-p reduction ran on a device and none fell + back; `reductions_on_gpu`, `reductions_on_cpu` and `first_gpu_error` carry + the detail. A compile error on upgrade is the intended failure mode for code + that was recording these results as GPU results. Nothing at the Python + surface changes: the feature has no binding. +- **`residue(f, z, point)` refuses a non-constant `point` with + `AlkahestError` / `E-RESIDUE-005`** instead of leaking + `AttributeError: 'Expr' object has no attribute 'numerator'` from the + argument parser. `AttributeError` is not an `AlkahestError`, so + `except ak.AlkahestError` missed it entirely. The existing `E-RESIDUE-001..4` + refusals are now `AlkahestError`s carrying `.code` and `.remediation` too, + rather than bare `ValueError`s with the code glued into the message; + `AlkahestError` subclasses `ValueError`, so `except ValueError` still works. +- **`series` refuses instead of running forever, with `SeriesError` / + `E-SERIES-003`.** `series(sqrt(t**-2 + t**-1), t, 0, 32)` never returned: + coefficients are formed by repeated differentiation without re-simplifying, so + a nested radical's derivatives grow by a constant factor per coefficient and + the cost doubles per order. It now honours an active `Budget` (raising + `BudgetExceededError`) and, with none, an internal work ceiling. It never + returns a *shorter* series: `O(h^order)` on fewer coefficients than were asked + for is a false statement about the remainder, which is worse than the refusal. + Ordinary expansions are unaffected — the heaviest in the suites intern a few + thousand nodes against a ceiling of 50 000. +- **`simplify_expanded` records a derivation step when its expansion bound stops + it** (`expand_pow_limit_reached`, a no-op step naming the power it declined), + and the bound itself is now a budget on the number of distributed products + rather than a flat exponent cap. `(x+y)**6` and `(x+y+1)**7` now expand where + the exponent-only cap refused them while permitting a twenty-term sum to the + fourth power; anything above the budget comes back unexpanded *and says so* + instead of looking like an expression that was already expanded. ### Known limits — documented, not fixed @@ -104,9 +186,80 @@ loop fails. `#[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). +- **There is no `cuda_device_count()`.** `CudaCompiledFn.call_batch_on(ordinal, + …)` selects a device, but the valid range can only be discovered by trying an + ordinal and catching `CudaError` (`E-CUDA-003`); the loop that does it is in + [`gpu.md`](docs/mdbook/src/gpu.md#discovering-the-valid-device-ordinals). Not + added yet on purpose: `cuda` implies LLVM 15 with NVPTX, so such a binding + cannot be compiled on an ordinary dev box, no CI job builds the Python + extension with either CUDA feature, and exercising it needs a device — it + would ship with no verification of any kind, which is the provenance of the + capability overclaims fixed above. It belongs in the same change as the + missing `maturin develop --features cuda` + `pytest tests/test_cuda.py` + nightly step. ### Fixed +- **`cargo test --features groebner-cuda` could not pass on a machine with no + NVIDIA driver**, contradicting the header comment of + `alkahest-core/tests/groebner_cuda.rs`. `cudarc` *panics* rather than + returning `Err` when `libcuda.so` cannot be `dlopen`ed, so `gpu_available()` + — whose entire job is to decide whether the GPU tier can run — aborted three + tests instead of skipping them. A missing library and a missing device now + both mean *not available*, while `ALKAHEST_GPU_TESTS=1` asserting a device + that is not usable still fails hard. The GPU tier additionally asserts + `GpuBackendReport::ran_on_gpu()`, so a "GPU test" whose reductions all landed + on the CPU fails rather than passing on identical results. +- **`product_definite` dropped the scale it used to clear denominators.** + `ratuni_poly_to_univ` multiplies a `ℚ[k]` polynomial through by the LCM of its + coefficient denominators and never returned that factor, so every index + contributed one spurious copy of it and the answer was off by + `c^(hi−lo+1)`. It is called separately on numerator and denominator, so the + two cancelled only when they happened to be equal — which is why integer- + coefficient products were always right and `Π ½` was not. The scale is now + returned and re-applied (`product_indefinite` gets `c^k`, the same factor in + antidifference form). A 1936-case sweep over `(a₁k+b₁)/(a₂k+b₂)` against exact + `Fraction` arithmetic finds 0 mismatches, down from 26 of 160. +- **`sum_definite` could not see a pole strictly inside the summation range.** + The only check was `contains_zero_to_negative_power` applied to the telescoped + difference `G(hi+1) − G(lo)`, which never mentions the interior indices, so + only poles landing exactly on an endpoint were caught. The summand itself is + now scanned, the same way the definite integrator's interior-pole guards look + at the integrand rather than at `F(b) − F(a)`: the integer roots of the + summand's own denominators are read off its ℤ-factorisation (so the cost does + not grow with the range), each candidate is substituted, and refusal requires + seeing an actual `0^{negative}` survive simplification — positive evidence, + never a guess. +- **`euler_maclaurin` fitted its additive constant at the point its own gate + scored.** The residual there was then zero by construction, so the `o()`-gate's + decay test was satisfied whatever the number was, and any term the expansion + was missing came back as a "constant" — `Σ k⁹` acquired `512⁴/2`. The constant + is now fitted outside the gate's check points and only emitted if a second fit + reproduces it; across the clean battery a genuine constant drifts by ≤ 3.2e-3 + of itself, a fabricated one by ≥ 0.93. +- **`rsolve` solved a shifted equation for forward-shift spellings.** + `extract_recurrence` re-indexes the sequence terms into lag form (`f(n+o) ↦ + f(n−(max_o−o))`), which is the original equation with `n ↦ n − max_o`, but left + the right-hand side at `n`. The right-hand side is now shifted with them, so + `f(n+1) − f(n) = n²` and `f(n) − f(n−1) = (n−1)²` mean the same thing again. + The answer is checked by substituting it back into the equation as supplied. +- **Order-2 recurrences with a repeated characteristic root lost a branch.** + `rsolve` returned `C₀·rⁿ + C₁·rⁿ` and `solve_linear_recurrence_homogeneous` + divided by `r₁ − r₂ = 0`, producing a closed form containing `0^{-1}` that + evaluated nowhere. Both now use the basis `{rⁿ, n·rⁿ}`; order ≥ 3 already + handled multiplicity correctly. +- **A Zeilberger certificate claimed a recurrence for the sum without its + boundary hypothesis.** The verified statement is the telescoping identity in + `k`; summing it over `k = k_lo..k_hi` leaves the boundary difference + `G(n, k_hi+1) − G(n, k_lo)`, and `Σ_i a_i(n)·S(n+i) = 0` holds only when that + vanishes. Both the core docs and the Python docstring asserted it + unconditionally. It is false for `F = C(n,k)/(k+1)`, where `G(n,0) = −1` and + `(n+2)·S(n+1) − (2n+2)·S(n) = 1`. The certificates themselves were and are + correct; what was missing was the hypothesis. `ZeilbergerCertificate` now + carries `side_conditions` (the hypothesis, in the same spirit as + `DerivedResult.verification["side_conditions"]`) and `boundary_term` + (`G(n,k) = R(n,k)·F(n,k)`), so a caller can discharge or refute it for their + own range. - **Claim graphs: a merge could close a dependency cycle, making the graph unreadable.** Claim IDs are content-addressed over the *normalised* statement, so two textually different statements (`"a"` and `" a"`) share an @@ -140,6 +293,18 @@ loop fails. 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. +- **`solve` states the hypotheses a parametric answer rests on.** + `solve([a*x - b], [x])` returns `b/a`, which is the solution *for `a ≠ 0`*: at + `a = 0` the equation reads `-b = 0`, so there is no solution when `b ≠ 0` and + every `x` when `b = 0`, and `b/a` is not even a number there. The + generic-parameter reading is deliberate, but a parametric tuple is not a number + and is therefore returned **unverified** — nothing substitutes it back — so the + hypothesis was the only auditable signal and it was not being given. New + `alkahest.solve_side_conditions() -> list[str]` reports the non-vanishing + hypotheses the most recent `solve` assumed, in the shape + `DerivedResult.verification["side_conditions"]` and + `ZeilbergerCertificate.side_conditions` already use. An empty list means the + solver *proved* every divisor non-zero: `solve([2*x - b], [x])` reports none. ### Added - **`alkahest.ansatz` — parametric families and coefficient fitting** (P2 diff --git a/Cargo.lock b/Cargo.lock index 756bc8f3..5a5fc29e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "alkahest-cas" -version = "3.7.0" +version = "3.8.0" dependencies = [ "bitflags 2.12.1", "boxcar", @@ -38,7 +38,7 @@ dependencies = [ [[package]] name = "alkahest-mlir" -version = "3.7.0" +version = "3.8.0" dependencies = [ "alkahest-cas", "proptest", @@ -46,10 +46,9 @@ dependencies = [ [[package]] name = "alkahest-py" -version = "3.7.0" +version = "3.8.0" dependencies = [ "alkahest-cas", - "numpy", "pyo3", "rug", ] @@ -1162,16 +1161,6 @@ dependencies = [ "libc", ] -[[package]] -name = "matrixmultiply" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "rawpointer", -] - [[package]] name = "memchr" version = "2.8.1" @@ -1196,19 +1185,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "ndarray" -version = "0.15.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "rawpointer", -] - [[package]] name = "num" version = "0.4.3" @@ -1282,21 +1258,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "numpy" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec170733ca37175f5d75a5bea5911d6ff45d2cd52849ce98b685394e4f2f37f4" -dependencies = [ - "libc", - "ndarray", - "num-complex", - "num-integer", - "num-traits", - "pyo3", - "rustc-hash 1.1.0", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1648,12 +1609,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - [[package]] name = "rayon" version = "1.12.0" diff --git a/Cargo.toml b/Cargo.toml index afeb2f74..010f5967 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["fuzz"] resolver = "2" [workspace.package] -version = "3.7.0" +version = "3.8.0" edition = "2021" authors = ["Alkahest Contributors"] license = "Apache-2.0" diff --git a/README.md b/README.md index 783b2a1a..5ae131fd 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The main stack is: Rust kernel → FLINT/Arb (polynomials, ball arithmetic) → - **Verifiable by construction.** Every computation produces a derivation log; a meaningful subset can export Lean 4 proofs for independent verification. - **520× faster than SymPy** on trig-identity simplification — and 77× faster than Mathematica on the same task ([cross-CAS report](benchmarks/results/report.md)). - **~40× faster than SymPy** on 2-variable quadratic systems, via FLINT-backed polynomial arithmetic and a compiled F4 core ([solving guide](docs/mdbook/src/solving.md)). -- **GPU codegen is a routine operation, not a research project** — 16.2× over the CPU JIT on a 1M-point polynomial evaluation (NVPTX `sm_86`, RTX 3090). +- **GPU codegen is a routine operation, not a research project** — 16.2× over the CPU JIT on a 1M-point polynomial evaluation (NVPTX `sm_86`, RTX 3090). Requires a source build with `--features cuda`; the PyPI wheel has no GPU support ([GPU guide](docs/mdbook/src/gpu.md)). - **A trained RL environment.** GRPO against the CAS verifier moved `Qwen2.5-1.5B-Instruct` from 11.7% → 15.1% on elementary integrals (+29% relative) with no stored reference answers — the CAS grades every rollout, including honest refusal on non-elementary integrands. - **Built for agent loops.** String entry point (`ak.parse`), per-candidate `Budget`s, batched `*_many` fan-out, and compact JSON envelopes for cheap logging. - **No silent performance cliffs.** `Expr`, `UniPoly`, `MultiPoly`, `ArbBall` and friends are explicit representations; conversion between them is always an opt-in call. @@ -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 `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. +**Why a separate index or direct wheel URL:** feature-heavy wheels use a PEP 440 **local version** (for example `3.8.0+jit` or `3.8.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.8.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/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" +pip install "https://github.com/alkahest-cas/alkahest/releases/download/v3.8.0/alkahest-3.8.0+full-cp311-cp311-linux_x86_64.whl" +pip install "https://github.com/alkahest-cas/alkahest/releases/download/v3.8.0/alkahest-3.8.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 (`3.7.0%2Bfull` in the filename segment). +If your client chokes on `+` in the URL, use percent-encoding (`3.8.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==3.7.0+full' --extra-index-url https://EXAMPLE/alkahest-extras/simple +pip install 'alkahest==3.8.0+full' --extra-index-url https://EXAMPLE/alkahest-extras/simple ``` ### From source @@ -114,7 +114,7 @@ pip install maturin maturin develop --manifest-path alkahest-py/Cargo.toml --release --features "parallel egraph jit groebner" ``` -Optional Cargo features: `parallel` (sharded pool + parallel F4 + `numpy_eval_par`), `egraph` (vendored egglog backend; **default** in PyPI wheels), `groebner` (Gröbner solver + Diophantine + homotopy; **default** in both the Rust crate and PyPI wheels), `cranelift` (pure-Rust Tier-1 JIT), `jit` (LLVM JIT), `cuda` (NVPTX codegen). +Optional Cargo features: `parallel` (sharded pool + parallel F4 + `numpy_eval_par`), `egraph` (vendored egglog backend; **default** in PyPI wheels), `groebner` (Gröbner solver + Diophantine + homotopy; **default** in both the Rust crate and PyPI wheels), `cranelift` (pure-Rust Tier-1 JIT), `jit` (LLVM JIT), `cuda` (NVPTX codegen — needs LLVM 15 with the NVPTX target; adds `compile_cuda`), `groebner-cuda` (CUDA Macaulay-matrix kernel — needs only `cudarc`, and is a Rust-crate entry point that no Python call reaches). Neither GPU feature is in any published wheel: see the [GPU guide](docs/mdbook/src/gpu.md). ### Rust crate @@ -210,7 +210,7 @@ More runnable examples live in [`examples/`](examples/) — polynomials, Risch i | [ODEs and DAEs](docs/mdbook/src/ode-dae.md) | Symbolic ODE/DAE systems, Pantelides index reduction, sensitivity and adjoint systems, acausal component modeling | `ODE` · `DAE` · `pantelides` · `dae_index_reduce` · `sensitivity_system` | | Number theory | FLINT-backed integer theory, Diophantine equations, LLL lattice reduction, PSLQ integer-relation detection | `number_theory` · `diophantine` · `lattice` · `guess_relation` | | [Rigorous numerics](docs/mdbook/src/ball-arithmetic.md) | Arb ball arithmetic — every float carries a proven error bound | `ArbBall` · `interval_eval` · `refine_root` | -| [Code generation](docs/mdbook/src/codegen.md) | JIT to native CPU code (Cranelift or LLVM), NVPTX GPU kernels, C source, StableHLO, vectorized NumPy | `compile_expr` · `jit` · `numpy_eval` · `emit_c` · `to_stablehlo` | +| [Code generation](docs/mdbook/src/codegen.md) | JIT to native CPU code (Cranelift or LLVM), [NVPTX GPU kernels](docs/mdbook/src/gpu.md), C source, StableHLO, vectorized NumPy | `compile_expr` · `jit` · `numpy_eval` · `emit_c` · `to_stablehlo` · `compile_cuda` | | Program transforms | JAX-style `trace` / `grad` / `jit` over Python functions, plus symbolic gradients and forward-mode dual-number AD | `trace_fn` · `grad` · `symbolic_grad` · `diff_forward` | | [Verification](docs/mdbook/src/lean-certs.md) | [Derivation logs](docs/mdbook/src/derivations.md) on every result, Lean 4 certificate export, [coverage reporting](docs/mdbook/src/certificate-coverage.md) | `DerivedResult.steps` · `to_lean` · `certifiable` · `certificate_coverage` | | [Agent loops](docs/mdbook/src/search-plumbing.md) | String parsing, [budgets and cancellation](docs/mdbook/src/budgets.md), [batched fan-out](docs/mdbook/src/batch.md), [claim graphs](docs/mdbook/src/claim-graphs.md) for session provenance | `parse` · `Budget` · `batch_map` · `research` | diff --git a/alkahest-core/src/calculus/euler_maclaurin.rs b/alkahest-core/src/calculus/euler_maclaurin.rs index c6dfa1a0..949ea6de 100644 --- a/alkahest-core/src/calculus/euler_maclaurin.rs +++ b/alkahest-core/src/calculus/euler_maclaurin.rs @@ -37,6 +37,17 @@ //! against the exactly-computed sum at increasing `n`, and terms that do not //! genuinely refine their predecessor are dropped. If nothing survives, the //! call refuses rather than emitting an unverified expansion. +//! +//! The constant is fitted at a point *outside* the gate's check points, and is +//! emitted only if refitting it at a second point reproduces it. Both halves +//! matter, and for the same reason: a +//! constant fitted at a point the gate then scores makes the residual there +//! zero by construction, so the gate cannot reject it — whatever the expansion +//! was actually missing gets emitted as a "constant". `Σ_{k=1}^{n} k⁹` at the +//! default `corrections = 2` used to acquire a term `34359738368`, which is +//! `512⁴/2`: the missing `n⁴/2` of Faulhaber's formula, frozen at the fitting +//! point, presented as a constant of a polynomial identity whose constant term +//! is zero. use super::asymptotic::AsymptoticError; use super::asymptotic_common::{ @@ -54,9 +65,31 @@ use std::collections::HashMap; /// Largest number of Bernoulli correction terms accepted. pub const MAX_CORRECTIONS: usize = 8; -/// Check points used by the numeric gate and the constant fit. +/// Check points used by the numeric gate. const CHECK_POINTS: [f64; 4] = [64.0, 128.0, 256.0, 512.0]; +/// Where the additive constant is fitted — deliberately **outside** +/// [`CHECK_POINTS`]. +/// +/// Fitting at a point the gate then scores makes the gate vacuous: the residual +/// there is identically zero by construction, so `gate_accept`'s decay test is +/// satisfied no matter what the fitted number is, and any leftover power of `n` +/// is emitted as a "constant". `Σ k⁹` came back with a spurious `512⁴/2` that +/// way — the dropped `n⁴/2` term frozen at the fitting point. +const CONSTANT_FIT_POINT: f64 = 1024.0; + +/// How much the constant fitted at [`CONSTANT_FIT_POINT`] may differ from the +/// one fitted at the previous point, relative to their size. +/// +/// A genuine additive constant is the *same* number wherever it is fitted, up to +/// the truncation error and `f64` noise: across the whole clean battery +/// (`γ`, `ζ(2)`, `ζ(3)`, `ζ(½)`, `½log 2π`, `γ₁`, the Faulhaber `−1/12` and +/// `1/120`, …) the observed drift never exceeded `3.2e-3` of the constant. +/// A dropped power of `n` masquerading as a constant grows with the fitting +/// point, and every one of those observed drifted by `≥ 0.93`. The gap is three +/// orders of magnitude wide; `1e-2` sits in it. +const CONSTANT_DRIFT_TOL: f64 = 1e-2; + /// Asymptotic expansion of `Σ_{k=a}^{n} f(k)` as `n → ∞`. /// /// `corrections` is the number of Bernoulli terms to attempt (`m` above); @@ -128,23 +161,39 @@ pub fn euler_maclaurin( derivation.push(format!("Bernoulli correction j = {j} (B_{} term)", 2 * j)); } - // Oracle: the exact sum at each check point. + // Oracle: the exact sum at each gate point, plus one further point reserved + // for fitting the additive constant. let points: Vec = CHECK_POINTS.to_vec(); - let oracle = exact_sums(f, k, a, &points, pool).ok_or(AsymptoticError::GateFailed)?; + let mut fit_points = points.clone(); + fit_points.push(CONSTANT_FIT_POINT); + let oracle_all = exact_sums(f, k, a, &fit_points, pool).ok_or(AsymptoticError::GateFailed)?; - // The additive constant: fit it from the largest check point, where the - // dropped tail is smallest. - let mut term_vals: Vec> = Vec::with_capacity(terms.len()); + let mut term_vals_all: Vec> = Vec::with_capacity(terms.len()); for &t in &terms { - term_vals.push(eval_over(t, n, &points, pool).ok_or(AsymptoticError::GateFailed)?); + term_vals_all.push(eval_over(t, n, &fit_points, pool).ok_or(AsymptoticError::GateFailed)?); } + + // `C(m) = Σ_{k≤m} f(k) − Σ_j term_j(m)` — the constant the expansion would + // need at each point. Its *convergence* is the evidence that it is a + // constant at all: an additive constant is the same number at every fitting + // point, whereas a term of the expansion that was dropped (because + // `corrections` was too small for the summand) grows with the point and only + // looks constant because it was frozen at one. + let m = fit_points.len() - 1; + let fit_at = + |j: usize| -> f64 { oracle_all[j] - term_vals_all.iter().map(|row| row[j]).sum::() }; + let constant = fit_at(m); + let previous = fit_at(m - 1); + let drift = (constant - previous).abs(); + let scale = constant.abs().max(previous.abs()); + let constant_converged = drift <= CONSTANT_DRIFT_TOL * scale; + + let oracle: Vec = oracle_all[..points.len()].to_vec(); + let mut term_vals: Vec> = term_vals_all + .into_iter() + .map(|row| row[..points.len()].to_vec()) + .collect(); let last = points.len() - 1; - let symbolic_at_last: f64 = term_vals.iter().map(|row| row[last]).sum(); - let constant = oracle[last] - symbolic_at_last; - derivation.push(format!( - "additive constant fitted numerically at n = {}: {constant}", - points[last] - )); // Add the constant, then order the whole sequence by magnitude at the // largest check point. Position matters: an asymptotic sequence has to be @@ -152,9 +201,27 @@ pub fn euler_maclaurin( // every decaying one. Inserting it at a fixed index instead would break // the ordering for a growing summand — `Σ k` would put the constant ahead // of `n/2` and the gate would (correctly) reject the tail. - let constant_expr = float_to_expr(constant, pool); - terms.push(constant_expr); - term_vals.push(vec![constant; points.len()]); + let mut constant_slot: Option = None; + if constant_converged { + derivation.push(format!( + "additive constant fitted numerically at n = {}: {constant} \ + (it moved by {drift:.3e} from the fit at n = {}, so it is a constant)", + fit_points[m], + fit_points[m - 1] + )); + let constant_expr = float_to_expr(constant, pool); + constant_slot = Some(terms.len()); + terms.push(constant_expr); + term_vals.push(vec![constant; points.len()]); + } else { + derivation.push(format!( + "no additive constant is claimed: the fit moved from {previous} at n = {} \ + to {constant} at n = {}, so it is not a constant — most likely a term of \ + the expansion that `corrections` was too small to produce", + fit_points[m - 1], + fit_points[m], + )); + } let mut order: Vec = (0..terms.len()).collect(); order.sort_by(|&i, &j| { @@ -165,6 +232,8 @@ pub fn euler_maclaurin( }); terms = order.iter().map(|&i| terms[i]).collect(); term_vals = order.iter().map(|&i| term_vals[i].clone()).collect(); + // Where the constant ended up after the reordering, if it survives the gate. + let constant_position = constant_slot.and_then(|slot| order.iter().position(|&i| i == slot)); let accepted = gate_accept(&oracle, &term_vals, DEFAULT_SLACK); if accepted == 0 { @@ -174,23 +243,34 @@ pub fn euler_maclaurin( term_vals.truncate(accepted); let verification = verification_points(&points, &oracle, &term_vals, accepted); + let mut hypotheses = vec![ + Hypothesis::checked( + "the summand has a symbolic antiderivative and is finite at every check point", + ), + Hypothesis::assumed( + "the summand is smooth on [a, ∞) and its high derivatives decay, so the \ + Euler–Maclaurin remainder is asymptotically negligible", + ), + ]; + // Only claim the fitted-constant hypothesis when a fitted constant is + // actually part of the answer. + if constant_position.is_some_and(|p| p < accepted) { + hypotheses.push(Hypothesis::assumed( + "the additive constant was fitted numerically from the exact sum, not derived; \ + it was refit at a second, larger point and agreed", + )); + } else { + hypotheses.push(Hypothesis::checked( + "no numerically fitted additive constant is part of this expansion", + )); + } + Ok(AsymptoticReport { method: "euler-maclaurin", var: n, terms, rigor: Rigor::NumericallyConsistent, - hypotheses: vec![ - Hypothesis::checked( - "the summand has a symbolic antiderivative and is finite at every check point", - ), - Hypothesis::assumed( - "the summand is smooth on [a, ∞) and its high derivatives decay, so the \ - Euler–Maclaurin remainder is asymptotically negligible", - ), - Hypothesis::assumed( - "the additive constant was fitted numerically from the exact sum, not derived", - ), - ], + hypotheses, verification, derivation, }) @@ -330,6 +410,83 @@ mod tests { } } + /// `Σ_{k=1}^{n} k⁹` is a Faulhaber polynomial, and Faulhaber polynomials + /// have **zero constant term**. At the default `corrections = 2` the + /// expansion is genuinely incomplete (`n⁴/2 − 3n²/20` is missing), and the + /// honest report of that is a shorter expansion — not the missing tail + /// frozen at the fitting point and relabelled a constant. + #[test] + fn faulhaber_gets_no_spurious_constant() { + let (pool, k, n) = setup(); + let f = pool.pow(k, pool.integer(9_i32)); + let r = euler_maclaurin(f, k, 1, n, 2, &pool).expect("expansion"); + + // Every emitted term must actually depend on n: a constant term here + // would be a claim the polynomial identity contradicts. + for &t in &r.terms { + let mut env = std::collections::HashMap::new(); + env.insert(n, 10.0); + let at_10 = crate::jit::eval_interp(t, &env, &pool).expect("evaluates"); + env.insert(n, 20.0); + let at_20 = crate::jit::eval_interp(t, &env, &pool).expect("evaluates"); + assert!( + (at_10 - at_20).abs() > 1e-9 * at_10.abs().max(1.0), + "constant term {} in Σ k⁹ (value {at_10} at both n = 10 and n = 20)", + pool.display(t) + ); + } + + // What is emitted must be a genuine prefix of Faulhaber's formula. + let partial = r.partial_sum(&pool); + for ni in [1000.0_f64, 10_000.0] { + let mut env = std::collections::HashMap::new(); + env.insert(n, ni); + let approx = crate::jit::eval_interp(partial, &env, &pool).expect("evaluates"); + // Σ k⁹ = n¹⁰/10 + n⁹/2 + 3n⁸/4 − 7n⁶/10 + n⁴/2 − 3n²/20. + let truth = ni.powi(10) / 10.0 + ni.powi(9) / 2.0 + 0.75 * ni.powi(8) + - 0.7 * ni.powi(6) + + 0.5 * ni.powi(4) + - 0.15 * ni * ni; + assert!( + (approx - truth).abs() / truth < 1e-9, + "n = {ni}: expansion {approx} vs Faulhaber {truth}" + ); + } + } + + /// The additive constant is refit at a second point and must agree; the + /// report says which way that went. + #[test] + fn the_constant_is_refit_and_the_report_says_so() { + let (pool, k, n) = setup(); + + let harmonic = pool.pow(k, pool.integer(-1_i32)); + let r = euler_maclaurin(harmonic, k, 1, n, 2, &pool).expect("expansion"); + assert!( + r.derivation + .iter() + .any(|d| d.contains("so it is a constant")), + "γ must be accepted as a constant: {:?}", + r.derivation + ); + + let ninth = pool.pow(k, pool.integer(9_i32)); + let r9 = euler_maclaurin(ninth, k, 1, n, 2, &pool).expect("expansion"); + assert!( + r9.derivation + .iter() + .any(|d| d.contains("no additive constant is claimed")), + "the Σ k⁹ fit is not a constant and must be reported as such: {:?}", + r9.derivation + ); + assert!( + r9.hypotheses + .iter() + .all(|h| !h.statement.contains("fitted numerically")), + "no fitted constant was emitted, so none may be claimed" + ); + } + /// A summand with no symbolic antiderivative is refused, not guessed at. #[test] fn refuses_when_the_summand_cannot_be_integrated() { diff --git a/alkahest-core/src/calculus/limits.rs b/alkahest-core/src/calculus/limits.rs index ad4058fb..8f1e7590 100644 --- a/alkahest-core/src/calculus/limits.rs +++ b/alkahest-core/src/calculus/limits.rs @@ -300,12 +300,81 @@ fn limit_body( if contains_zero_to_negative_power(result, pool) { return Err(LimitError::Unsupported); } + if approach_side_is_outside_the_domain(expr, var, point, direction, pool) { + return Err(LimitError::Unsupported); + } if numeric_evidence_contradicts(expr, var, point, direction, result, pool) { return Err(LimitError::Unsupported); } Ok(result) } +/// True when `expr` takes no real value anywhere on the side the caller asked +/// about, so the one-sided limit does not exist over ℝ. +/// +/// `lim_{x→0⁻} √x` came back as `0`. It is not that the value is hard to pin +/// down: `√x` is undefined at *every* point of every left neighbourhood of `0`, +/// so there is no sequence to take a limit along and the question has no answer +/// over the reals. Same for `lim_{x→1⁺} arccos x`. A `0` there is the kind of +/// answer a loop reasoning about domains of definition inherits and cannot +/// audit — it looks exactly like the (correct) `lim_{x→0⁺} √x = 0`. +/// +/// The evidence required is positive and cheap: +/// +/// * every sampled offset on the approach side evaluates to `NaN` — the +/// interpreter *ran* and the result was not a real number, as opposed to +/// returning `None` because it did not recognise the expression; and +/// * the mirror point on the opposite side evaluates to a finite real, which +/// witnesses that this expression is within the interpreter's vocabulary and +/// that the `NaN`s are therefore facts about the function's domain. +/// +/// `±inf` deliberately does **not** count: `lim_{x→0⁻} 1/x = −∞` is a pole, not +/// a domain boundary, and the answer `−∞` is correct. +/// +/// Two-sided limits are left alone. There the usual convention takes the limit +/// relative to the domain, under which `lim_{x→0} √x = 0` is defensible; a +/// caller who writes `dir="-"` has asked a question that convention does not +/// cover. +fn approach_side_is_outside_the_domain( + expr: ExprId, + var: ExprId, + point: ExprId, + direction: LimitDirection, + pool: &ExprPool, +) -> bool { + let sign = match direction { + LimitDirection::Plus => 1.0, + LimitDirection::Minus => -1.0, + LimitDirection::Bidirectional => return false, + }; + // A polynomial is defined on the whole line; so is anything whose samples + // would be meaningless because a second symbol is unbound. + if is_polynomial_in(expr, var, pool) || has_free_symbol_besides(expr, var, pool) { + return false; + } + let Some(at) = constant_f64(point, pool) else { + return false; + }; + + let mut env: HashMap = HashMap::with_capacity(1); + let mut sample = |offset: f64| -> Option { + env.insert(var, at + offset); + crate::jit::eval_interp(expr, &env, pool) + }; + + for offset in APPROACH_OFFSETS { + match sample(sign * offset) { + Some(v) if v.is_nan() => {} + // Evaluable and real, or not evaluable at all: no verdict. + _ => return false, + } + } + // The witness that the expression itself is evaluable. + APPROACH_OFFSETS + .iter() + .any(|&offset| sample(-sign * offset).is_some_and(|v| v.is_finite())) +} + /// Offsets used to sample a function as it approaches a finite point. /// /// Deliberately stops at `1e-4`: closer in, catastrophic cancellation in @@ -1580,6 +1649,43 @@ mod tests { assert_eq!(r, p.integer(1_i32)); } + #[test] + fn one_sided_limit_off_the_domain_is_refused() { + // √x is undefined at every point of every left neighbourhood of 0, so + // there is no sequence along which to take `lim_{x→0⁻} √x` and the + // question has no answer over ℝ. It used to return `√0 = 0` — + // indistinguishable from the correct `lim_{x→0⁺} √x = 0`. + let p = ExprPool::new(); + let x = p.symbol("x", Domain::Real); + let ex = simplify(p.func("sqrt", vec![x]), &p).value; + assert!( + limit(ex, x, p.integer(0_i32), LimitDirection::Minus, &p).is_err(), + "√x has no left-hand limit at 0 over ℝ" + ); + // The control: from the right the limit exists and is 0. + let r = limit(ex, x, p.integer(0_i32), LimitDirection::Plus, &p).unwrap(); + assert_eq!(constant_f64(r, &p), Some(0.0), "got {}", p.display(r)); + + // arccos is undefined to the right of 1, for the same reason. + let ac = simplify(p.func("acos", vec![x]), &p).value; + assert!( + limit(ac, x, p.integer(1_i32), LimitDirection::Plus, &p).is_err(), + "arccos has no right-hand limit at 1 over ℝ" + ); + + // …and a pole is *not* a domain boundary: 1/x is perfectly well + // defined to the left of 0 and the one-sided limit is −∞. + let inv = simplify(p.pow(x, p.integer(-1_i32)), &p).value; + assert!( + limit(inv, x, p.integer(0_i32), LimitDirection::Minus, &p).is_ok(), + "lim_{{x→0⁻}} 1/x = −∞ must survive" + ); + // √(x²) is defined on both sides; nothing to refuse. + let sq = simplify(p.func("sqrt", vec![p.pow(x, p.integer(2_i32))]), &p).value; + let r = limit(sq, x, p.integer(0_i32), LimitDirection::Minus, &p).unwrap(); + assert_eq!(constant_f64(r, &p), Some(0.0), "got {}", p.display(r)); + } + #[test] fn limit_x_log_x_zero_plus() { let p = ExprPool::new(); diff --git a/alkahest-core/src/calculus/mod.rs b/alkahest-core/src/calculus/mod.rs index c44af592..fa2e91ce 100644 --- a/alkahest-core/src/calculus/mod.rs +++ b/alkahest-core/src/calculus/mod.rs @@ -14,4 +14,4 @@ pub use asymptotic::{asymptotic_expand, AsymptoticError, AsymptoticExpansion, As pub use fps::{Fps, FpsError}; pub use limits::{limit, LimitDirection, LimitError}; pub use multilimit::{multilimit, MultiLimit, PathWitness}; -pub use series::{series, Series, SeriesError}; +pub use series::{series, take_series_refusal, Series, SeriesError, SeriesRefusal}; diff --git a/alkahest-core/src/calculus/series.rs b/alkahest-core/src/calculus/series.rs index 1e56dfc5..d23c69b7 100644 --- a/alkahest-core/src/calculus/series.rs +++ b/alkahest-core/src/calculus/series.rs @@ -1,5 +1,6 @@ //! Truncated Taylor / Laurent series with symbolic [`crate::kernel::ExprData::BigO`] remainder (V2-15). +use crate::budget::BudgetError; use crate::diff::{diff, DiffError}; use crate::flint::FlintPoly; use crate::kernel::{subs, Domain, ExprData, ExprId, ExprPool}; @@ -27,7 +28,13 @@ impl Series { pub enum SeriesError { /// Differentiation failed while forming Taylor coefficients. Diff(DiffError), - /// `order` must be positive. + /// The requested `order` is not one this call can expand to: it was `0`, + /// or the expansion ran past the work ceiling / an active + /// [`crate::budget`] before reaching it. + /// + /// The second reading is the carrier for a *refusal* — see + /// [`take_series_refusal`] for which of the two happened, and + /// [`SeriesRefusal`] for why the refusal cannot be its own variant. InvalidOrder, } @@ -35,7 +42,11 @@ impl fmt::Display for SeriesError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { SeriesError::Diff(e) => write!(f, "{e}"), - SeriesError::InvalidOrder => write!(f, "series order must be >= 1"), + SeriesError::InvalidOrder => write!( + f, + "series order must be >= 1 and reachable: the expansion is not \ + available at the order requested" + ), } } } @@ -62,7 +73,11 @@ impl crate::errors::AlkahestError for SeriesError { SeriesError::Diff(_) => { Some("ensure all functions are registered primitives with differentiation rules") } - SeriesError::InvalidOrder => Some("pass order >= 1 (exclusive truncation degree in x)"), + SeriesError::InvalidOrder => Some( + "pass order >= 1 (exclusive truncation degree in x); if the order was \ + already positive the expansion exceeded the work ceiling — ask for a \ + lower order, or simplify the expression so its derivatives close", + ), } } } @@ -88,6 +103,20 @@ impl From for SeriesError { /// include powers `h^e` with `valuation ≤ e < order` when `valuation ≥ 0`, and /// when `valuation < 0` include the polar tail using `order` Taylor coefficients /// of the analytic factor `h^{-valuation} · f`. +/// +/// # Termination +/// +/// The coefficient loop is bounded: it honours [`crate::budget`] (wall clock, +/// steps, [`crate::budget::request_cancel`]) and, with no budget active, an +/// internal work ceiling ([`MAX_SERIES_POOL_GROWTH`]). Coefficients are formed +/// by repeated differentiation *without* re-simplifying, so an expression whose +/// derivatives do not close — `√(t⁻² + t⁻¹)` is the standard example — grows by +/// a constant factor per coefficient and order 32 is not slow but unreachable. +/// +/// Running out of room is reported as **`Err(SeriesError::InvalidOrder)` with a +/// [`take_series_refusal`] pending**, never as a shorter series: a truncated +/// expansion still labelled `O(hᵒʳᵈᵉʳ)` would be a false statement about the +/// remainder, and that is a lie where a refusal is merely a limitation. pub fn series( expr: ExprId, var: ExprId, @@ -95,12 +124,23 @@ pub fn series( order: u32, pool: &ExprPool, ) -> Result { + let frame = enter_series_frame(); + // The ceiling is what makes the loop stoppable at all: `local_expansion` is + // one uninterruptible call from here, so there is nowhere else to put a + // checkpoint. Unlike `limit`'s, this one refuses instead of settling for + // the prefix it managed to compute. + let _ceiling = enter_coeff_ceiling(pool.len().saturating_add(MAX_SERIES_POOL_GROWTH)); + let LocalExpansion { valuation, coeffs, h_expr, } = local_expansion(expr, var, point, order, pool)?; + if frame.refusal_pending() { + return Err(SeriesError::InvalidOrder); + } + Ok(assemble_series(&coeffs, valuation, h_expr, order, pool)) } @@ -244,10 +284,158 @@ fn unipoly_strip_low(p: &UniPoly, k: u32) -> UniPoly { // Coefficient-loop ceiling // --------------------------------------------------------------------------- +/// How many *new* expression nodes one top-level [`series`] call may intern +/// before it refuses. +/// +/// Measured rather than guessed, with an order of magnitude of headroom: the +/// heaviest expansions in the Rust and Python suites intern a few thousand nodes +/// (`sin` at order 24: 125; `√(1+x)` at order 24: 677; `tan` at order 16: 1 564; +/// `log(1+x)/(1−x)` at order 20: 4 579), while `√(t⁻² + t⁻¹)` at order 32 doubles +/// per coefficient and reaches this ceiling in a fraction of a second. +/// +/// Counting interned nodes rather than iterations catches the pathology directly +/// (it is *size* that explodes, not the iteration count), costs `O(1)` per check +/// — [`ExprPool::len`] is a lock-free counter — and is monotone, so no path can +/// evade it. +pub const MAX_SERIES_POOL_GROWTH: usize = 50_000; + thread_local! { /// Absolute `pool.len()` ceiling for [`taylor_coefficients`], or `None` for /// "compute every coefficient that was asked for". static COEFF_POOL_CEILING: Cell> = const { Cell::new(None) }; + /// `true` while a [`series`] call is on the stack, which is the only + /// context in which a truncated coefficient loop is a refusal rather than + /// the requested behaviour. + static IN_SERIES: Cell = const { Cell::new(false) }; + /// The refusal behind the [`SeriesError::InvalidOrder`] the current thread + /// is about to return, if that error is a work-ceiling trip rather than a + /// zero `order`. + static LAST_REFUSAL: Cell> = const { Cell::new(None) }; +} + +/// A [`series`] call that could not reach the order it was asked for. +/// +/// # Why this is not an error variant +/// +/// [`SeriesError`] is a public *exhaustive* enum, so growing it a `Truncated` +/// variant is a major semver break — and so is marking it `#[non_exhaustive]` +/// to allow it later. A correctness fix inside a patch release cannot spend a +/// major version, so the refusal travels out of band: [`series`] returns +/// [`SeriesError::InvalidOrder`], whose reworded text states exactly the +/// disjunction that is known ("the order is not one this call can expand to"), +/// and the real cause is recorded here for [`take_series_refusal`] to hand to +/// the bindings, which raise its own `E-SERIES-003` (or the `E-BUDGET-*` of the +/// budget that tripped). +/// +/// This is the pattern [`crate::calculus::limits::last_budget_trip`] uses for +/// budget trips inside `LimitError::DepthExceeded`, and +/// [`crate::matrix::take_zero_test_refusal`] for undecided zero tests inside +/// `MatrixError::SingularMatrix`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SeriesRefusal { + requested: u32, + computed: u32, + budget: Option, +} + +impl SeriesRefusal { + /// Number of Taylor coefficients that were asked for. + pub fn requested_coefficients(&self) -> u32 { + self.requested + } + + /// Number of Taylor coefficients that were formed before the loop stopped. + /// + /// Deliberately *not* returned as a series: `assemble_series` would label it + /// `O(h^requested)`, which is a claim about a remainder nobody bounded. + pub fn computed_coefficients(&self) -> u32 { + self.computed + } + + /// The [`BudgetError`] that stopped this expansion, or `None` when it was + /// the internal work ceiling. + pub fn budget(&self) -> Option { + self.budget + } +} + +impl fmt::Display for SeriesRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "series expansion stopped after {} of {} Taylor coefficients ({}); \ + refusing to return a shorter series labelled with the requested \ + order, which would understate the O(.) remainder", + self.computed, + self.requested, + match self.budget { + Some(b) => format!("budget: {b}"), + None => "internal work ceiling".to_string(), + } + ) + } +} + +impl std::error::Error for SeriesRefusal {} + +impl crate::errors::AlkahestError for SeriesRefusal { + fn code(&self) -> &'static str { + "E-SERIES-003" + } + + fn remediation(&self) -> Option<&'static str> { + Some( + "ask for a lower order, raise the budget, or rewrite the expression so its \ + repeated derivatives close (nested radicals grow by a constant factor per \ + coefficient)", + ) + } +} + +/// RAII marker for the outermost [`series`] frame on this thread. +pub(crate) struct SeriesFrame { + outermost: bool, +} + +impl SeriesFrame { + /// Did the coefficient loop stop early during this call? + fn refusal_pending(&self) -> bool { + LAST_REFUSAL.with(|c| c.get().is_some()) + } +} + +impl Drop for SeriesFrame { + fn drop(&mut self) { + if self.outermost { + IN_SERIES.with(|c| c.set(false)); + } + } +} + +/// Enter a [`series`] frame, clearing any refusal left by an earlier call so a +/// pending one always describes the call that just returned. +fn enter_series_frame() -> SeriesFrame { + LAST_REFUSAL.with(|c| c.set(None)); + IN_SERIES.with(|c| { + let already = c.get(); + c.set(true); + SeriesFrame { + outermost: !already, + } + }) +} + +/// Take the refusal behind the [`SeriesError::InvalidOrder`] that just came +/// back, if there was one. +/// +/// `Some` means the requested order was positive and simply out of reach — the +/// work ceiling or an active [`crate::budget`] stopped the coefficient loop. +/// `None` means the variant means what it has always meant: `order == 0`. +/// +/// Consuming, so one refusal is reported once and cannot leak into a later +/// unrelated error. Thread-local, like the ceiling itself. +pub fn take_series_refusal() -> Option { + LAST_REFUSAL.with(|c| c.take()) } /// RAII installer for the [`taylor_coefficients`] ceiling; restores the @@ -263,12 +451,12 @@ impl Drop for CoeffCeiling { /// Stop [`taylor_coefficients`] early once the pool has grown past `ceiling`, /// returning the coefficients computed so far. /// -/// Only for callers that need a *leading* term rather than a series to a -/// promised order — [`crate::calculus::limits`] scans for the first nonzero -/// coefficient, so a short prefix is either enough to answer or an honest "no -/// answer at this order", never a wrong answer. [`series`] itself installs no -/// ceiling: truncating there would understate the `O(·)` term, which would be -/// a lie rather than a refusal. +/// [`crate::calculus::limits`] scans for the first nonzero coefficient, so a +/// short prefix is either enough to answer or an honest "no answer at this +/// order", never a wrong answer, and it simply uses what it got. [`series`] +/// installs a ceiling too — it has to, or the loop is unbounded — but it treats +/// a short prefix as a **refusal** ([`take_series_refusal`]): returning it would +/// understate the `O(·)` term, which would be a lie rather than a limitation. /// /// Successive Taylor coefficients are formed by differentiating *without* /// re-simplifying, so for expressions whose derivatives do not close (nested @@ -303,6 +491,18 @@ fn taylor_coefficients( let mut out = Vec::with_capacity(num as usize); for k in 0..num { if k > 0 && coeff_loop_should_stop(pool) { + // Inside a `series` call this prefix is not an answer — record why, + // for `series` to turn into a refusal. Every other caller wants the + // prefix, so nothing is recorded for them and no stale refusal is + // left behind for the next `take_series_refusal`. + if IN_SERIES.with(|c| c.get()) { + let refusal = SeriesRefusal { + requested: num, + computed: k, + budget: crate::budget::check().err(), + }; + LAST_REFUSAL.with(|c| c.set(Some(refusal))); + } break; } let ev = subs(cur, &mapping, pool); @@ -508,4 +708,87 @@ mod tests { let s = series(ix, x, z, 4, &p).unwrap(); assert!(contains_big_o(s.expr(), &p)); } + + /// `√(t⁻² + t⁻¹)` at order 32 is the runaway shape: each coefficient is + /// formed by differentiating the previous one without re-simplifying, and a + /// nested radical's derivatives grow by a constant factor, so the loop is + /// unfinishable rather than slow (order 13 already takes 0.15 s and the cost + /// doubles per order). + /// + /// The refusal is the assertion. A *short* series would be worse than the + /// hang it replaces: `O(t^32)` on nine computed coefficients is a false + /// statement about the remainder, and unlike a timeout the caller has no way + /// to notice. This test also passes trivially if the expansion is ever made + /// to terminate honestly at the full order — see the `is_ok` arm. + #[test] + fn series_refuses_rather_than_truncating_a_runaway_radical() { + use crate::errors::AlkahestError; + let p = ExprPool::new(); + let t = p.symbol("t", Domain::Real); + let inner = p.add(vec![p.pow(t, p.integer(-2)), p.pow(t, p.integer(-1))]); + let ex = p.func("sqrt", vec![inner]); + + match series(ex, t, p.integer(0), 32, &p) { + Ok(_) => { + // A future fast path that really reaches order 32 is welcome; + // it must not leave a refusal behind. + assert_eq!(take_series_refusal(), None); + } + Err(e) => { + assert!(matches!(e, SeriesError::InvalidOrder), "{e:?}"); + let refusal = take_series_refusal().expect("work-ceiling refusal recorded"); + assert_eq!(refusal.code(), "E-SERIES-003"); + assert_eq!(refusal.budget(), None, "no budget was active"); + assert!( + refusal.computed_coefficients() < refusal.requested_coefficients(), + "{refusal}" + ); + } + } + } + + /// The carrier variant keeps its original meaning: `order == 0` is a user + /// error, not a refusal, and must not leave a refusal pending for the + /// bindings to mis-report as `E-SERIES-003`. + #[test] + fn order_zero_is_a_user_error_not_a_refusal() { + let p = ExprPool::new(); + let x = p.symbol("x", Domain::Real); + let cx = p.func("cos", vec![x]); + let err = series(cx, x, p.integer(0), 0, &p).unwrap_err(); + assert!(matches!(err, SeriesError::InvalidOrder), "{err:?}"); + assert_eq!(take_series_refusal(), None); + } + + /// A budget trip is attributed to the budget, so a binding raises + /// `E-BUDGET-*` rather than "this order is unreachable". + #[test] + fn budget_stops_a_series_and_is_attributed() { + use crate::budget::{self, Budget, BudgetError}; + let p = ExprPool::new(); + let t = p.symbol("t", Domain::Real); + let inner = p.add(vec![p.pow(t, p.integer(-2)), p.pow(t, p.integer(-1))]); + let ex = p.func("sqrt", vec![inner]); + + let _guard = budget::enter(Budget::new().with_max_steps(3)); + let err = series(ex, t, p.integer(0), 32, &p).unwrap_err(); + assert!(matches!(err, SeriesError::InvalidOrder), "{err:?}"); + let refusal = take_series_refusal().expect("budget refusal recorded"); + assert!( + matches!(refusal.budget(), Some(BudgetError::Steps { .. })), + "{refusal}" + ); + } + + /// The ceiling must not cost coverage: an ordinary high-order expansion of + /// a function whose derivatives close still returns, and leaves no refusal. + #[test] + fn ordinary_high_order_expansion_is_unaffected() { + let p = ExprPool::new(); + let x = p.symbol("x", Domain::Real); + let sx = p.func("sin", vec![x]); + let s = series(sx, x, p.integer(0), 24, &p).unwrap(); + assert!(contains_big_o(s.expr(), &p)); + assert_eq!(take_series_refusal(), None); + } } diff --git a/alkahest-core/src/errors/codes.rs b/alkahest-core/src/errors/codes.rs index df4cec8e..001ffe96 100644 --- a/alkahest-core/src/errors/codes.rs +++ b/alkahest-core/src/errors/codes.rs @@ -43,6 +43,14 @@ pub const REGISTRY: &[ErrorSpec] = &[ // E-SERIES — SeriesError (V2-15 truncated expansions) ErrorSpec { code: "E-SERIES-001", class: "SeriesError", cause: Cause::Unsupported, remediation: Some("ensure all functions are registered primitives with differentiation rules") }, ErrorSpec { code: "E-SERIES-002", class: "SeriesError", cause: Cause::UserInput, remediation: Some("pass order >= 1 (exclusive truncation degree in x)") }, + // A `series` call that ran past its work ceiling (or an active budget) before + // reaching the requested order. Refusing is the point: coefficients are formed by + // repeated differentiation without re-simplifying, so a nested radical grows by a + // constant factor per coefficient, and returning the prefix under the requested + // `O(h^order)` label would understate the remainder rather than admit the miss. + // Carried out of band on `SeriesError::InvalidOrder` (exhaustive public enum) — + // see `calculus::series::take_series_refusal`. + ErrorSpec { code: "E-SERIES-003", class: "SeriesRefusal", cause: Cause::Resource, remediation: Some("ask for a lower order, raise the budget, or rewrite the expression so its repeated derivatives close") }, // E-INT — IntegrationError ErrorSpec { code: "E-INT-001", class: "IntegrationError", cause: Cause::Unsupported, remediation: Some("use a numeric integrator for arbitrary functions") }, ErrorSpec { code: "E-INT-002", class: "IntegrationError", cause: Cause::Domain, remediation: None }, @@ -103,8 +111,26 @@ pub const REGISTRY: &[ErrorSpec] = &[ ErrorSpec { code: "E-SOLVE-001", class: "SolverError", cause: Cause::UserInput, remediation: Some("ensure all equations are polynomial in the declared variables") }, ErrorSpec { code: "E-SOLVE-002", class: "SolverError", cause: Cause::Unsupported, remediation: Some("only degree ≤ 2 univariate solving is implemented; Gröbner basis is still returned") }, ErrorSpec { code: "E-SOLVE-003", class: "SolverError", cause: Cause::UserInput, remediation: Some("provide one equation per variable") }, + // `triangularize` extracts one polynomial per main variable, so two basis + // generators sharing a main variable lose one of them and the chain describes + // a larger variety than the input system. Splitting on the initials + // (Lazard–Kalkbrener) is what would decompose those ideals; refusing is the + // point until it exists. Travels inside `SolverError::NotPolynomial` — see + // `solver::regular_chains::TriangularizeRefusal`. + ErrorSpec { code: "E-SOLVE-004", class: "TriangularizeRefusal", cause: Cause::Unsupported, remediation: Some("this ideal needs a splitting triangular decomposition (Lazard–Kalkbrener on the initials), which is not implemented; use GroebnerBasis::compute or primary_decomposition instead") }, ErrorSpec { code: "E-SOLVE-010", class: "SolverError", cause: Cause::Resource, remediation: Some("check GPU availability; pass device_id=None to fall back to CPU") }, ErrorSpec { code: "E-SOLVE-011", class: "SolverError", cause: Cause::Resource, remediation: Some("CRT reconstruction failed; try adding more equations or use CPU path") }, + // E-IDEAL — PrimaryDecompositionError (ideal/primary.rs) and IdealRefusal + ErrorSpec { code: "E-IDEAL-001", class: "PrimaryDecompositionError", cause: Cause::UserInput, remediation: Some("pass at least one generator") }, + ErrorSpec { code: "E-IDEAL-002", class: "PrimaryDecompositionError", cause: Cause::UserInput, remediation: Some("all generators must be polynomials in the same variable list") }, + ErrorSpec { code: "E-IDEAL-003", class: "PrimaryDecompositionError", cause: Cause::Resource, remediation: Some("the saturation split recursed past its depth limit; simplify the generating set") }, + ErrorSpec { code: "E-IDEAL-004", class: "PrimaryDecompositionError", cause: Cause::Internal, remediation: Some("report the generating set as a minimal failing example") }, + // √I over an arbitrary ideal needs Gianni–Trager–Zacharias (or a + // characteristic-set method). Only monomial, principal and zero-dimensional + // ideals are certified; outside those, returning the input unchanged would be + // asserting √I = I with no justification, so the routine refuses instead. + ErrorSpec { code: "E-IDEAL-005", class: "IdealRefusal", cause: Cause::Unsupported, remediation: Some("radical is certified for monomial, principal and zero-dimensional ideals; intersect the associated primes of a primary decomposition if one is available") }, + ErrorSpec { code: "E-IDEAL-006", class: "IdealRefusal", cause: Cause::Unsupported, remediation: Some("primary decomposition is certified for monomial and principal ideals, for saturation/CRT splits of them, and for shape-position zero-dimensional ideals; no general algorithm is implemented") }, // E-HOMOTOPY — HomotopyError (V2-14 numerical algebraic geometry) ErrorSpec { code: "E-HOMOTOPY-002", class: "HomotopyError", cause: Cause::Unsupported, remediation: Some("raise HomotopyOpts.max_bezout_paths or use mixed-volume continuation for deficient systems") }, ErrorSpec { code: "E-HOMOTOPY-003", class: "HomotopyError", cause: Cause::Resource, remediation: Some("try HomotopyOpts.gamma_angle_seed or rescale equations") }, diff --git a/alkahest-core/src/holonomic/mod.rs b/alkahest-core/src/holonomic/mod.rs index 92d27920..c5f86bd0 100644 --- a/alkahest-core/src/holonomic/mod.rs +++ b/alkahest-core/src/holonomic/mod.rs @@ -12,7 +12,9 @@ //! - [`mod@zeilberger`] — Zeilberger's creative-telescoping algorithm: given a //! proper hypergeometric `F(n, k)`, find a P-recursive relation //! `Σ_i a_i(n)·F(n+i,k) = G(n,k+1) − G(n,k)` with `G = R·F` and `R` an -//! exact rational-function certificate. +//! exact rational-function certificate. Deriving a recurrence for the *sum* +//! `Σ_k F(n,k)` from it carries a boundary hypothesis that this module states +//! rather than assumes — see [`zeilberger::boundary_side_condition`]. //! //! Every certificate this module returns is checked as an *exact* identity //! in `Q(n)(k)` before it is handed back to the caller — see @@ -31,7 +33,9 @@ pub mod zeilberger; pub use hyperterm::{GammaFactor, ProperTerm}; pub use qfield::{PolyK, RatK, Rn}; -pub use zeilberger::{zeilberger, ZeilbergerOpts, ZeilbergerResult}; +pub use zeilberger::{ + boundary_side_condition, boundary_term, zeilberger, ZeilbergerOpts, ZeilbergerResult, +}; use std::fmt; diff --git a/alkahest-core/src/holonomic/zeilberger.rs b/alkahest-core/src/holonomic/zeilberger.rs index ba0014b1..d451e8d2 100644 --- a/alkahest-core/src/holonomic/zeilberger.rs +++ b/alkahest-core/src/holonomic/zeilberger.rs @@ -8,11 +8,30 @@ //! ``` //! //! with polynomial coefficients `a_i(n)` (not all zero, `a_J ≢ 0`) and an -//! exact rational-function certificate `R(n,k)`. Summing both sides over `k` -//! telescopes the right-hand side, so if `S(n) = Σ_k F(n,k)` then -//! `Σ_i a_i(n)·S(n+i) = 0`: this is exactly the classical route from -//! `Σ_k C(n,k) = 2^n` (order 1) to `Σ_k C(n,k)^2 = C(2n,n)` (order 1, higher -//! degree certificate) and beyond. +//! exact rational-function certificate `R(n,k)`. That identity — and only that +//! identity — is what [`zeilberger()`] verifies exactly before returning. +//! +//! # The sum recurrence carries a hypothesis +//! +//! Summing both sides over `k = κ₀ .. κ₁` telescopes the right-hand side to a +//! **boundary difference**, not to zero: +//! +//! ```text +//! Σ_i a_i(n)·S(n+i) = G(n, κ₁+1) − G(n, κ₀), S(n) = Σ_{k=κ₀}^{κ₁} F(n,k) +//! ``` +//! +//! `Σ_i a_i(n)·S(n+i) = 0` therefore holds **only when that boundary difference +//! vanishes** — the *natural boundary* hypothesis, which is what makes the +//! classical route from `Σ_k C(n,k) = 2ⁿ` to `Σ_k C(n,k)² = C(2n,n)` work: `G` +//! is a rational multiple of `F`, and `F` vanishes outside `0 ≤ k ≤ n`. +//! +//! It is not automatic. For `F(n,k) = C(n,k)/(k+1)` summed over `k = 0..n` the +//! certificate is correct and `G(n,0) = −1`, so +//! `(n+2)·S(n+1) − (2n+2)·S(n) = 1`, not `0`; `S(n) = (2ⁿ⁺¹−1)/(n+1)` confirms +//! it in exact arithmetic. A caller who reads the homogeneous recurrence off a +//! certificate without checking the boundary gets a false lemma. Use +//! [`boundary_term`] to obtain `G(n,k)` and evaluate it at the summation +//! endpoints; [`boundary_side_condition`] states the hypothesis in words. //! //! # Method //! @@ -82,6 +101,11 @@ impl Default for ZeilbergerOpts { /// A verified Zeilberger certificate: `Σ_i coeffs[i](n)·F(n+i,k) = ΔG`, /// `G(n,k) = certificate(n,k)·F(n,k)`. +/// +/// The verified content is the **telescoping identity in `k`**. Turning it into +/// a recurrence for `S(n) = Σ_k F(n,k)` needs the boundary difference +/// `G(n, κ₁+1) − G(n, κ₀)` to vanish over the summation range; see the module +/// documentation, [`boundary_term`] and [`boundary_side_condition`]. #[derive(Debug, Clone)] pub struct ZeilbergerResult { /// Recurrence order `J`; `coeffs.len() == order + 1`. @@ -93,6 +117,32 @@ pub struct ZeilbergerResult { pub certificate: ExprId, } +/// `G(n,k) = R(n,k)·F(n,k)`, the telescoped quantity whose boundary values +/// decide whether the certificate's recurrence holds for the *sum*. +/// +/// `term` must be the same `F(n,k)` that was passed to [`zeilberger()`]. The +/// recurrence for `S(n) = Σ_{k=κ₀}^{κ₁} F(n,k)` is +/// `Σ_i a_i(n)·S(n+i) = G(n, κ₁+1) − G(n, κ₀)`, so this is exactly what a +/// caller needs in order to discharge (or refute) the natural-boundary +/// hypothesis for their own summation range. +pub fn boundary_term(result: &ZeilbergerResult, term: ExprId, pool: &ExprPool) -> ExprId { + crate::simplify::simplify(pool.mul(vec![result.certificate, term]), pool).value +} + +/// The hypothesis that [`ZeilbergerResult`]'s recurrence for the *sum* rests on, +/// stated so it can be recorded rather than assumed. +/// +/// Emitted verbatim as a side condition by the Python binding; it is deliberately +/// a fixed string, because the condition is the same for every certificate and +/// only the range it is evaluated over changes. +pub const fn boundary_side_condition() -> &'static str { + "the recurrence Σ_i a_i(n)·S(n+i) = 0 for S(n) = Σ_k F(n,k) additionally requires \ + G(n, k_hi+1) = G(n, k_lo) over the summation range, where G(n,k) = R(n,k)·F(n,k); \ + Zeilberger verifies the telescoping identity in k, not this boundary condition. \ + It holds for the usual natural boundary (F vanishing outside 0 <= k <= n) and fails \ + for e.g. F = C(n,k)/(k+1), where G(n,0) = -1 makes the recurrence inhomogeneous" +} + /// `k^j` as an element of `Q(n)[k]`. fn k_mono(j: usize) -> PolyK { let mut coeffs = vec![rn_zero(); j + 1]; @@ -490,6 +540,39 @@ mod tests { } } + /// `F(n,k) = C(n,k)/(k+1)` is the counterexample the boundary hypothesis + /// exists for: the telescoping certificate is correct, but `G(n,0) = −1`, so + /// `Σ_i a_i(n)·S(n+i)` is `1`, not `0`. The boundary term must therefore be + /// available to the caller, and it must not vanish here. + #[test] + fn boundary_term_is_available_and_nonzero_where_the_hypothesis_fails() { + let pool = ExprPool::new(); + let (n, k) = nk(&pool); + let kp1 = pool.add(vec![k, pool.integer(1_i32)]); + let f = pool.mul(vec![ + binom(&pool, n, k), + pool.pow(kp1, pool.integer(-1_i32)), + ]); + let opts = ZeilbergerOpts::default(); + let result = zeilberger(f, n, k, &pool, &opts).expect("certificate"); + let g = boundary_term(&result.value, f, &pool); + + // G(n, 0) = R(n,0)·F(n,0) = −1 for every n, so the homogeneous sum + // recurrence is false and nothing in the certificate said otherwise. + let mut m = std::collections::HashMap::new(); + m.insert(k, pool.integer(0_i32)); + let g_at_0 = crate::simplify::simplify(crate::kernel::subs(g, &m, &pool), &pool).value; + for ni in [2.0_f64, 5.0, 9.0] { + let env = std::collections::HashMap::from([(n, ni)]); + let v = crate::eval_f64(g_at_0, &pool, &env).expect("G(n,0) evaluates"); + assert!( + (v + 1.0).abs() < 1e-9, + "G({ni}, 0) should be -1, got {v} — the boundary difference does not vanish" + ); + } + assert!(boundary_side_condition().contains("G(n, k_hi+1) = G(n, k_lo)")); + } + /// Refuses non-hypergeometric input rather than guessing. #[test] fn refuses_non_hypergeometric_input() { diff --git a/alkahest-core/src/ideal/mod.rs b/alkahest-core/src/ideal/mod.rs index ec6c550f..3938cdf4 100644 --- a/alkahest-core/src/ideal/mod.rs +++ b/alkahest-core/src/ideal/mod.rs @@ -1,12 +1,17 @@ //! Polynomial ideals — primary decomposition and radicals (V2-12). //! //! Gianni–Trager–Zacharias-style splitting is implemented via saturations and -//! univariate factorization (zero-dimensional factors in the lowest Lex -//! variable). This covers the roadmap examples; pathological ideals may need a -//! broader implementation in future work. +//! univariate factorization. Only the ideal classes whose components can be +//! *certified* primary — and whose radicals can be certified radical — are +//! answered; everything else refuses through [`IdealRefusal`] rather than +//! reporting the input ideal as though it were its own radical. See +//! [`primary`] for the full list of what is certified and why. #[cfg(feature = "groebner")] pub mod primary; #[cfg(feature = "groebner")] -pub use primary::{primary_decomposition, radical, PrimaryComponent, PrimaryDecompositionError}; +pub use primary::{ + primary_decomposition, radical, take_ideal_refusal, IdealRefusal, PrimaryComponent, + PrimaryDecompositionError, +}; diff --git a/alkahest-core/src/ideal/primary.rs b/alkahest-core/src/ideal/primary.rs index a35a9d32..ebc4b379 100644 --- a/alkahest-core/src/ideal/primary.rs +++ b/alkahest-core/src/ideal/primary.rs @@ -1,20 +1,61 @@ -//! Primary decomposition over ℚ\[x₁,…,xₙ\] via saturation splits and Lex -//! factorization (Gianni–Trager–Zacharias fragment). +//! Primary decomposition and radicals over ℚ\[x₁,…,xₙ\] (Gianni–Trager–Zacharias +//! fragment). //! -//! Splits use `I = (I : x_i^∞) ∩ (I + (x_i))` when the intersection checks out, -//! and a zero-dimensional split from factoring the univariate generator in the -//! first Lex variable when it has multiple distinct irreducible factors over ℚ. +//! # What is certified, and what is refused +//! +//! There is no general primary-decomposition algorithm here, and none of the +//! routines pretend otherwise. Every component this module returns is primary +//! for a reason it can state, and every radical it returns is radical for a +//! reason it can state: +//! +//! * **Monomial ideals** decompose completely, by the coprime split +//! `⟨J, u·v⟩ = ⟨J, u⟩ ∩ ⟨J, v⟩` for coprime monomials `u, v`, into irreducible +//! monomial ideals `⟨x_{j₁}^{a₁}, …, x_{j_k}^{a_k}⟩`, each primary with +//! associated prime `⟨x_{j₁}, …, x_{j_k}⟩`. Their radical is generated by the +//! square-free part of each generating monomial. +//! * **Principal ideals** decompose as `⟨∏ pᵢ^{eᵢ}⟩ = ∩ ⟨pᵢ^{eᵢ}⟩`, since +//! ℚ\[x₁,…,xₙ\] is a UFD and `⟨pᵉ⟩` is `⟨p⟩`-primary for a prime element `p`. +//! Their radical is `⟨∏ pᵢ⟩`. +//! * **Zero-dimensional ideals** have `√I = I + ⟨sqfree(pᵢ) : i⟩` where +//! `pᵢ` generates `I ∩ ℚ[xᵢ]` (Seidenberg; ℚ is perfect), and a +//! shape-position basis `⟨x₀ − g₀(t), …, x_{n−2} − g_{n−2}(t), h(t)⟩` with +//! `h = c·pᵉ` and `p` irreducible is primary, because its quotient is the +//! local Artinian ring `ℚ[t]/⟨pᵉ⟩`. +//! * **Splits** — `I = (I : x_i^∞) ∩ (I + ⟨x_i⟩)` when the intersection checks +//! out, and `I = ∩_j (I + ⟨p_j^{e_j}⟩)` by CRT when `I` contains a univariate +//! with several distinct irreducible factors — reduce an ideal to smaller +//! ones, which must themselves land in one of the certified classes. +//! +//! Anything else **refuses**: see [`IdealRefusal`]. Returning `I` itself as +//! though it were its own radical, or as though it were primary, is the failure +//! this module is written to avoid — a caller who reads a field named +//! `associated_prime` is entitled to assume it names a prime. +use crate::errors::AlkahestError; +use crate::flint::mpoly::{FlintMPoly, FlintMPolyCtx, FlintMPolyFactor}; use crate::flint::FlintPoly; use crate::poly::groebner::ideal::GbPoly; use crate::poly::groebner::monomial_order::MonomialOrder; -use crate::poly::groebner::GroebnerBasis; +use crate::poly::groebner::{is_zero_dimensional, GroebnerBasis}; +use std::cell::RefCell; use std::collections::BTreeMap; use std::fmt; +use std::sync::Arc; const MAX_SPLIT_DEPTH: usize = 48; +/// Ceiling on the number of irreducible components a monomial split may produce +/// before the routine gives up and refuses. The split is exponential in the +/// worst case (`⟨x₁y₁, …, x_ky_k⟩` has `2^k` components) and this gate runs on +/// every pull request. +const MAX_MONOMIAL_COMPONENTS: usize = 256; + /// One primary component together with its associated prime (√Q). +/// +/// Both fields carry a guarantee: `primary` is primary and `associated_prime` +/// is prime, for one of the reasons listed in the module documentation. A +/// component that cannot be certified is never returned — the whole call +/// refuses instead. #[derive(Clone, Debug)] pub struct PrimaryComponent { pub primary: GroebnerBasis, @@ -22,6 +63,9 @@ pub struct PrimaryComponent { } /// Primary-decomposition failures (inconsistent input, depth limit, FLINT). +/// +/// `Factorization` doubles as the carrier for [`IdealRefusal`] — see that type +/// for why the refusal cannot be a variant of its own. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PrimaryDecompositionError { EmptyGenerators, @@ -42,15 +86,167 @@ impl fmt::Display for PrimaryDecompositionError { PrimaryDecompositionError::RecursionDepth => { write!(f, "primary decomposition exceeded recursion depth") } - PrimaryDecompositionError::Factorization(msg) => { - write!(f, "univariate factorization failed: {msg}") - } + // Reworded from "univariate factorization failed: {msg}": this variant + // is also the carrier for `IdealRefusal`, whose message is a full + // sentence of its own. Every in-module construction site now spells + // out what failed. + PrimaryDecompositionError::Factorization(msg) => write!(f, "{msg}"), } } } impl std::error::Error for PrimaryDecompositionError {} +impl AlkahestError for PrimaryDecompositionError { + fn code(&self) -> &'static str { + match self { + PrimaryDecompositionError::EmptyGenerators => "E-IDEAL-001", + PrimaryDecompositionError::InconsistentNvars => "E-IDEAL-002", + PrimaryDecompositionError::RecursionDepth => "E-IDEAL-003", + PrimaryDecompositionError::Factorization(_) => "E-IDEAL-004", + } + } + + fn remediation(&self) -> Option<&'static str> { + match self { + PrimaryDecompositionError::EmptyGenerators => Some("pass at least one generator"), + PrimaryDecompositionError::InconsistentNvars => { + Some("all generators must be polynomials in the same variable list") + } + PrimaryDecompositionError::RecursionDepth => Some( + "the saturation split recursed past its depth limit; simplify the \ + generating set", + ), + PrimaryDecompositionError::Factorization(_) => { + Some("report the generating set as a minimal failing example") + } + } + } +} + +// --------------------------------------------------------------------------- +// Refusals, reported out of band +// --------------------------------------------------------------------------- + +/// Which routine declined, which fixes the stable code the refusal carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RefusalSite { + /// `√I` for an ideal outside the certified classes — `E-IDEAL-005`. + Radical, + /// A primary decomposition with a component that cannot be certified + /// primary — `E-IDEAL-006`. + Decomposition, +} + +/// An ideal-theoretic question this module cannot answer, with the code it +/// carries. +/// +/// # Why this is not an error variant +/// +/// [`PrimaryDecompositionError`] is a public *exhaustive* enum, so growing it a +/// `NotCertifiable` variant is a major semver break — and so is marking it +/// `#[non_exhaustive]` to allow one later. A correctness fix inside a patch +/// release cannot spend a major version, so the refusal travels out of band: +/// the refusing routine returns `PrimaryDecompositionError::Factorization` with +/// this type's message, and the real code is recorded here for +/// [`take_ideal_refusal`] to hand to the bindings. +/// +/// This is the pattern [`crate::matrix::take_zero_test_refusal`] already uses +/// for undecided zero tests inside `LinearAlgebraError::UnsupportedField`, and +/// [`crate::calculus::limits::last_budget_trip`] for budget trips inside +/// `LimitError::DepthExceeded`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IdealRefusal { + site: RefusalSite, + message: &'static str, +} + +impl fmt::Display for IdealRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.message) + } +} + +impl std::error::Error for IdealRefusal {} + +impl AlkahestError for IdealRefusal { + fn code(&self) -> &'static str { + match self.site { + RefusalSite::Radical => "E-IDEAL-005", + RefusalSite::Decomposition => "E-IDEAL-006", + } + } + + fn remediation(&self) -> Option<&'static str> { + match self.site { + RefusalSite::Radical => Some( + "radical is certified for monomial, principal and zero-dimensional \ + ideals; intersect the associated primes of a primary decomposition \ + if one is available", + ), + RefusalSite::Decomposition => Some( + "primary decomposition is certified for monomial and principal ideals, \ + for saturation/CRT splits of them, and for shape-position \ + zero-dimensional ideals; no general algorithm is implemented", + ), + } + } +} + +thread_local! { + /// The refusal behind the `Factorization` error the current thread is about + /// to return, when that variant is a carrier rather than what it usually + /// means. + static LAST_IDEAL_REFUSAL: RefCell> = const { RefCell::new(None) }; +} + +/// Drop any recorded refusal, so a genuine FLINT failure can never be +/// re-attributed to a refusal left behind by an earlier call on this thread. +fn forget_ideal_refusal() { + LAST_IDEAL_REFUSAL.with(|c| *c.borrow_mut() = None); +} + +fn refuse(site: RefusalSite, message: &'static str) -> PrimaryDecompositionError { + LAST_IDEAL_REFUSAL.with(|c| *c.borrow_mut() = Some(IdealRefusal { site, message })); + PrimaryDecompositionError::Factorization(message) +} + +/// Take the refusal behind the error that just came back, if there was one. +/// +/// Bindings call this when [`radical`] or [`primary_decomposition`] returns +/// `PrimaryDecompositionError::Factorization` and raise the refusal's own +/// `E-IDEAL-005` / `E-IDEAL-006` when it is present, so the caller still gets +/// the specific code. `Some` means *this* error is a refusal; `None` means the +/// variant means what it usually means — a FLINT failure. +/// +/// Consuming, so one refusal is reported once and cannot leak into a later +/// unrelated error. Thread-local. +pub fn take_ideal_refusal() -> Option { + LAST_IDEAL_REFUSAL.with(|c| c.borrow_mut().take()) +} + +const RADICAL_NOT_CERTIFIABLE: &str = "cannot certify √I for this ideal: it is neither \ + monomial nor principal nor zero-dimensional, and no primary decomposition of it \ + could be certified either. The general radical needs Gianni–Trager–Zacharias or a \ + characteristic-set method, which is not implemented — refusing rather than \ + returning I unchanged as though it were already radical"; + +const DECOMPOSITION_NOT_CERTIFIABLE: &str = "cannot certify a primary decomposition of \ + this ideal: it splits no further and is not one of the classes whose primarity can \ + be established (monomial, principal, or a shape-position zero-dimensional basis). \ + Refusing rather than reporting the ideal itself as a primary component with an \ + unjustified associated prime"; + +const MONOMIAL_TOO_LARGE: &str = "the irreducible decomposition of this monomial ideal \ + exceeded the component ceiling; refusing rather than returning a truncated \ + intersection that is not equal to the input ideal"; + +const FACTORIZATION_FAILED: &str = "univariate factorization failed: FLINT could not \ + factor a generator over ℤ"; + +const MULTIVARIATE_FACTORIZATION_FAILED: &str = "multivariate factorization failed: \ + FLINT could not factor the generator of a principal ideal over ℤ"; + fn lcm_rational_denoms(coeffs: &[rug::Rational]) -> rug::Integer { let mut m = rug::Integer::from(1); for c in coeffs { @@ -94,36 +290,47 @@ fn primitive_flint_from_rational_asc(coeffs: &[rug::Rational]) -> Option bool { - a.n_vars == b.n_vars && a.terms == b.terms -} - -/// Radical √I via repeated augmentation with squarefree parts of univariate -/// polynomials in each variable (characteristic 0), then a Gröbner basis pass. +/// Radical √I, or a refusal. +/// +/// Certified for monomial, principal and zero-dimensional ideals, and for any +/// ideal whose [`primary_decomposition`] can be certified (√I is then the +/// intersection of the associated primes). Everything else raises +/// `E-IDEAL-005`; see [`IdealRefusal`]. +/// +/// Before 3.8 this returned the input basis unchanged whenever no univariate +/// generator was available, which asserted `√I = I` with nothing behind it: +/// `radical([(x−y)²])` did not contain `x−y`, although +/// `√⟨(x−y)²⟩ = ⟨x−y⟩` exactly. pub fn radical( gens: Vec, order: MonomialOrder, ) -> Result { validate_gens(&gens)?; + forget_ideal_refusal(); let gb = GroebnerBasis::compute(gens, order); - Ok(radical_from_basis(&gb, order)) + radical_from_basis(&gb, order) + .ok_or_else(|| refuse(RefusalSite::Radical, RADICAL_NOT_CERTIFIABLE)) } -/// Irredundant primary decomposition (partial — see module docs). +/// Irredundant primary decomposition, or a refusal. +/// +/// Every returned component is primary and carries a prime `associated_prime`, +/// for one of the reasons in the module documentation; the intersection of the +/// components is the input ideal. When some component cannot be certified the +/// whole call raises `E-IDEAL-006` rather than reporting an unjustified one. pub fn primary_decomposition( gens: Vec, order: MonomialOrder, ) -> Result, PrimaryDecompositionError> { validate_gens(&gens)?; + forget_ideal_refusal(); let gb = GroebnerBasis::compute(gens, order); if is_unit_ideal(&gb) { return Ok(vec![]); } let mut raw = decompose_recursive(gb, order, 0)?; dedup_components(&mut raw); - for c in &mut raw { - c.associated_prime = radical_from_basis(&c.primary, order); - } + drop_redundant_components(&mut raw); Ok(raw) } @@ -280,9 +487,37 @@ fn decompose_recursive( if is_unit_ideal(&gb) { return Ok(vec![]); } + if gb.generators().iter().all(|g| g.is_zero()) { + // ⟨0⟩ is prime in the domain ℚ[x₁,…,xₙ], hence primary, and is its own + // radical. + return Ok(vec![PrimaryComponent { + primary: gb.clone(), + associated_prime: gb, + }]); + } let n_vars = gb.generators()[0].n_vars; + // Monomial ideals decompose completely and every piece is certified + // irreducible, so there is nothing left for the general machinery to do. + if let Some(mons) = monomial_ideal_generators(gb.generators()) { + return match decompose_monomial_ideal(&mons, n_vars, order) { + Some(comps) => Ok(comps), + None => Err(refuse(RefusalSite::Decomposition, MONOMIAL_TOO_LARGE)), + }; + } + + // Principal ideals decompose completely in the UFD ℚ[x₁,…,xₙ]. + if gb.generators().len() == 1 { + return match principal_components(&gb.generators()[0], order) { + Some(comps) => Ok(comps), + None => Err(refuse( + RefusalSite::Decomposition, + MULTIVARIATE_FACTORIZATION_FAILED, + )), + }; + } + for i in 0..n_vars { let f = var_monomial(n_vars, i); let sat_gb = saturate_ideal(gb.generators(), &f, order); @@ -308,69 +543,78 @@ fn decompose_recursive( return Ok(out); } - if order == MonomialOrder::Lex { - if let Some(pieces) = try_lex_factor_split(gb.generators(), order, n_vars)? { - let mut acc = Vec::new(); - for piece_gens in pieces { - let piece = GroebnerBasis::compute(piece_gens, order); - acc.extend(decompose_recursive(piece, order, depth + 1)?); + if let Some(pieces) = try_univariate_factor_split(gb.generators(), n_vars)? { + let mut acc = Vec::new(); + for piece_gens in pieces { + let piece = GroebnerBasis::compute(piece_gens, order); + acc.extend(decompose_recursive(piece, order, depth + 1)?); + } + return Ok(acc); + } + + if let Some(component) = certify_shape_position(gb.generators(), n_vars, order)? { + return Ok(vec![component]); + } + + // An ideal whose radical is *maximal* is primary: `R/I` is then local with + // its maximal ideal equal to the nilradical `√I/I`, so everything outside + // that ideal is a unit and everything inside is nilpotent — no zero divisor + // is left over. This is what certifies `⟨x² + y², xy⟩`, whose radical is + // `⟨x, y⟩`. + if let Some(rad) = radical_direct(&gb, order) { + if let Some(certified) = certify_shape_position(rad.generators(), n_vars, order)? { + if ideals_equal(&certified.primary, &certified.associated_prime) { + // A shape-position basis whose eliminant is irreducible *and* + // square-free has the field ℚ[t]/⟨p⟩ as its quotient, i.e. it is + // maximal. + return Ok(vec![PrimaryComponent { + primary: gb, + associated_prime: rad, + }]); } - return Ok(acc); } } - let ass = radical_from_basis(&gb, order); - Ok(vec![PrimaryComponent { - primary: gb, - associated_prime: ass, - }]) + Err(refuse( + RefusalSite::Decomposition, + DECOMPOSITION_NOT_CERTIFIABLE, + )) } -/// If the basis contains a univariate polynomial only in variable 0, split along -/// coprime factors `p_j^{e_j}` as `⟨I, p_j^{e_j}⟩`. -fn try_lex_factor_split( +/// If the basis contains a univariate polynomial in *some* variable whose +/// factorization over ℚ has several distinct irreducible factors, split along +/// them as `⟨I, p_j^{e_j}⟩`. +/// +/// The pieces are pairwise comaximal, because `gcd(p_i^{e_i}, p_j^{e_j}) = 1` in +/// the univariate ring ℚ\[x\] gives a Bézout identity, and their product lies in +/// `I`, so CRT makes `I = ∩_j (I + ⟨p_j^{e_j}⟩)` an equality rather than a +/// containment. +/// +/// Until 3.8 this looked only in variable 0, so a zero-dimensional ideal in +/// shape position — whose eliminant lives in the *last* variable — was never +/// split at all. +fn try_univariate_factor_split( gens: &[GbPoly], - _order: MonomialOrder, n_vars: usize, ) -> Result>>, PrimaryDecompositionError> { - let u = match find_univariate_in_var0(gens) { - Some(u) => u, - None => return Ok(None), - }; - let facs = factor_univariate_q_monic(&u, n_vars)?; - if facs.len() <= 1 { - return Ok(None); - } - let mut out = Vec::with_capacity(facs.len()); - for (p, e) in facs { - let mut g = gens.to_vec(); - g.push(gbpoly_pow(&p, e)); - out.push(g); - } - Ok(Some(out)) -} - -fn find_univariate_in_var0(gens: &[GbPoly]) -> Option { - for g in gens { - if g.is_zero() { + for var in 0..n_vars { + let u = match find_any_univariate(gens, var) { + Some(u) => u, + None => continue, + }; + let facs = factor_univariate_q_monic(&u, var, n_vars)?; + if facs.len() <= 1 { continue; } - let mut ok = true; - for e in g.terms.keys() { - if e.is_empty() { - ok = false; - break; - } - if e[0..].iter().skip(1).any(|&x| x != 0) { - ok = false; - break; - } - } - if ok { - return Some(g.clone()); + let mut out = Vec::with_capacity(facs.len()); + for (p, e) in facs { + let mut g = gens.to_vec(); + g.push(gbpoly_pow(&p, e)); + out.push(g); } + return Ok(Some(out)); } - None + Ok(None) } fn gbpoly_pow(p: &GbPoly, e: u32) -> GbPoly { @@ -381,7 +625,7 @@ fn gbpoly_pow(p: &GbPoly, e: u32) -> GbPoly { acc } -fn flint_monic_to_gbpoly_var0(fz: &FlintPoly, n_vars: usize) -> GbPoly { +fn flint_monic_to_gbpoly_in_var(fz: &FlintPoly, var: usize, n_vars: usize) -> GbPoly { let deg = fz.degree(); if deg < 0 { return GbPoly::zero(n_vars); @@ -395,25 +639,26 @@ fn flint_monic_to_gbpoly_var0(fz: &FlintPoly, n_vars: usize) -> GbPoly { } let rq = rug::Rational::from((cz.clone(), lc.clone())); let mut expv = vec![0u32; n_vars]; - expv[0] = d as u32; + expv[var] = d as u32; terms.insert(expv, rq); } GbPoly { terms, n_vars } } -/// Factor a univariate `p(x₀)`; returns **monic** irreducible factors over ℚ. +/// Factor a univariate `p(x_var)`; returns **monic** irreducible factors over ℚ. fn factor_univariate_q_monic( p: &GbPoly, + var: usize, n_vars: usize, ) -> Result, PrimaryDecompositionError> { - if n_vars < 1 || !is_univariate_in_var(p, 0) { + if var >= n_vars || !is_univariate_in_var(p, var) { return Err(PrimaryDecompositionError::Factorization( - "internal: expected univariate in var 0", + "internal: expected a univariate polynomial in the requested variable", )); } let mut coeff_map: BTreeMap = BTreeMap::new(); for (e, c) in &p.terms { - coeff_map.insert(e[0], c.clone()); + coeff_map.insert(e[var], c.clone()); } let deg = *coeff_map.keys().max().unwrap_or(&0); let coeffs_r: Vec = (0..=deg) @@ -425,14 +670,20 @@ fn factor_univariate_q_monic( }) .collect(); let fp = primitive_flint_from_rational_asc(&coeffs_r).ok_or( - PrimaryDecompositionError::Factorization("could not build integer model"), + PrimaryDecompositionError::Factorization( + "univariate factorization failed: could not build an integer model of the \ + eliminant", + ), )?; let (_unit, facs) = fp .factor_over_z() - .map_err(|_| PrimaryDecompositionError::Factorization("FLINT factor_over_z"))?; + .map_err(|_| PrimaryDecompositionError::Factorization(FACTORIZATION_FAILED))?; let mut pairs = Vec::new(); for (fz, exp) in facs { - let g = flint_monic_to_gbpoly_var0(&fz, n_vars); + if fz.degree() < 1 { + continue; + } + let g = flint_monic_to_gbpoly_in_var(&fz, var, n_vars); pairs.push((g, exp)); } Ok(pairs) @@ -444,26 +695,451 @@ fn is_univariate_in_var(p: &GbPoly, var: usize) -> bool { .all(|e| e.len() == p.n_vars && e.iter().enumerate().all(|(i, &v)| i == var || v == 0)) } -fn radical_from_basis(gb: &GroebnerBasis, order: MonomialOrder) -> GroebnerBasis { - let n = gb.generators().first().map(|p| p.n_vars).unwrap_or(0); +// --------------------------------------------------------------------------- +// Radical +// --------------------------------------------------------------------------- + +/// `√I` when it can be certified, `None` when it cannot. +/// +/// The order of the attempts is cheapest-first; every one of them is exact. +/// Before 3.8 this function's fallback was `I` itself, which is what made +/// `radical` and `PrimaryComponent::associated_prime` able to be wrong. +fn radical_from_basis(gb: &GroebnerBasis, order: MonomialOrder) -> Option { + if let Some(r) = radical_direct(gb, order) { + return Some(r); + } + + // Last resort: √I is the intersection of the associated primes of any + // primary decomposition (the embedded ones contain a minimal one, so they + // do not change the intersection). This only succeeds where the + // decomposition itself is certified. + let comps = decompose_recursive(gb.clone(), order, 0).ok()?; + if comps.is_empty() { + return Some(gb.clone()); + } + let mut acc = comps[0].associated_prime.clone(); + for c in &comps[1..] { + acc = ideal_intersection(acc.generators(), c.associated_prime.generators(), order); + } + Some(acc) +} + +/// The part of [`radical_from_basis`] that does not go through +/// [`decompose_recursive`] — the decomposition consults it, so it must not +/// consult the decomposition. +fn radical_direct(gb: &GroebnerBasis, order: MonomialOrder) -> Option { + let gens = gb.generators(); + if gens.is_empty() || gens.iter().all(|g| g.is_zero()) { + // ⟨0⟩ is prime in a domain, so it is its own radical. + return Some(gb.clone()); + } + if is_unit_ideal(gb) { + return Some(gb.clone()); + } + let n = gens[0].n_vars; + + // √⟨monomials⟩ is generated by the square-free part of each monomial: that + // ideal J is radical (a monomial ideal generated by square-free monomials + // is), and each of its generators has a power in I, so I ⊆ J ⊆ √I ⊆ √J = J. + if let Some(mons) = monomial_ideal_generators(gens) { + let sq: Vec = mons + .iter() + .map(|m| monomial_poly(&squarefree_exp(m))) + .collect(); + return Some(GroebnerBasis::compute(sq, order)); + } + + // √⟨f⟩ = ⟨∏ pᵢ⟩ over the distinct irreducible factors of f: ℚ[x₁,…,xₙ] is a + // UFD, so gⁿ ∈ ⟨f⟩ for some n iff every pᵢ divides g. + if gens.len() == 1 { + let facs = factor_gbpoly_q(&gens[0], order)?; + let mut prod = GbPoly::constant(rug::Rational::from(1), n); + for (p, _) in &facs { + prod = prod.mul(p); + } + return Some(GroebnerBasis::compute(vec![prod], order)); + } + + radical_zero_dimensional(gb, n, order) +} + +/// Seidenberg: over a perfect field, an ideal containing a square-free +/// univariate polynomial in *every* variable is radical. +/// +/// So when `I` is zero-dimensional — equivalently, `I ∩ ℚ[xᵢ] ≠ 0` for every +/// `i` — the ideal `J = I + ⟨sqfree(pᵢ) : i⟩` is radical, and `I ⊆ J ⊆ √I` +/// because `pᵢ | sqfree(pᵢ)^{deg pᵢ}`; hence `√I = √J = J`. +/// +/// The old code only used the eliminants that happened to appear *verbatim* in +/// the basis. Under Lex that is normally just the last variable, so the result +/// was an ideal between `I` and `√I` reported as `√I`. +fn radical_zero_dimensional( + gb: &GroebnerBasis, + n: usize, + order: MonomialOrder, +) -> Option { + if n == 0 { + return None; + } + // `is_zero_dimensional` reads GRevLex leading monomials, so it needs a + // GRevLex basis — cheaper than discovering the answer through n failed + // eliminations. + let grevlex = GroebnerBasis::compute(gb.generators().to_vec(), MonomialOrder::GRevLex); + if !is_zero_dimensional(grevlex.generators(), n) { + return None; + } let mut gens = gb.generators().to_vec(); - for _ in 0..(n + 4) { - let mut appended = false; - for i in 0..n { - if let Some(u) = find_any_univariate(&gens, i) { - let sf = univariate_squarefree_part(&u, i, order); - if !gbpoly_eq(&sf, &u) { - gens.push(sf); - appended = true; + for i in 0..n { + let u = eliminant_in_var(gb.generators(), n, i)?; + gens.push(univariate_squarefree_part(&u, i, order)); + } + Some(GroebnerBasis::compute(gens, order)) +} + +/// A non-zero element of `I ∩ ℚ[x_i]`, or `None` when there is none. +fn eliminant_in_var(gens: &[GbPoly], n: usize, i: usize) -> Option { + if let Some(u) = find_any_univariate(gens, i) { + return Some(u); + } + // Re-rank the variables so that x_i is lex-least, then the elimination + // theorem says the basis elements free of every other variable generate + // `I ∩ ℚ[x_i]`. + let mut perm: Vec = (0..n).filter(|&j| j != i).collect(); + perm.push(i); + let permuted: Vec = gens.iter().map(|g| permute_vars(g, &perm)).collect(); + let pgb = GroebnerBasis::compute(permuted, MonomialOrder::Lex); + let u = find_any_univariate(pgb.generators(), n - 1)?; + let mut terms = BTreeMap::new(); + for (e, c) in &u.terms { + let mut ne = vec![0u32; n]; + ne[i] = e[n - 1]; + terms.insert(ne, c.clone()); + } + Some(GbPoly { terms, n_vars: n }) +} + +/// Re-index the exponent vectors: `new[k] = old[perm[k]]`. +fn permute_vars(p: &GbPoly, perm: &[usize]) -> GbPoly { + let n = perm.len(); + let mut terms: BTreeMap, rug::Rational> = BTreeMap::new(); + for (e, c) in &p.terms { + let ne: Vec = perm + .iter() + .map(|&j| e.get(j).copied().unwrap_or(0)) + .collect(); + terms.insert(ne, c.clone()); + } + GbPoly { terms, n_vars: n } +} + +// --------------------------------------------------------------------------- +// Monomial ideals +// --------------------------------------------------------------------------- + +/// The exponent vectors of `gens` when every generator is a single monomial. +fn monomial_ideal_generators(gens: &[GbPoly]) -> Option>> { + let mut out = Vec::with_capacity(gens.len()); + for g in gens { + if g.is_zero() { + continue; + } + if g.terms.len() != 1 { + return None; + } + let (e, c) = g.terms.iter().next()?; + if *c == 0 { + return None; + } + out.push(e.clone()); + } + if out.is_empty() { + None + } else { + Some(out) + } +} + +fn monomial_poly(exp: &[u32]) -> GbPoly { + GbPoly::monomial(exp.to_vec(), rug::Rational::from(1)) +} + +fn squarefree_exp(m: &[u32]) -> Vec { + m.iter().map(|&e| e.min(1)).collect() +} + +fn monomial_divides(a: &[u32], b: &[u32]) -> bool { + a.iter().zip(b.iter()).all(|(x, y)| x <= y) +} + +/// The minimal generating set of a monomial ideal: drop every generator that a +/// different one divides. +fn minimal_monomial_generators(mut mons: Vec>) -> Vec> { + mons.sort(); + mons.dedup(); + mons.iter() + .filter(|m| !mons.iter().any(|o| o != *m && monomial_divides(o, m))) + .cloned() + .collect() +} + +/// Irreducible decomposition of a monomial ideal, by the coprime split. +/// +/// For monomial ideals `⟨J, u·v⟩ = ⟨J, u⟩ ∩ ⟨J, v⟩` whenever `u` and `v` are +/// coprime monomials: a monomial in both right-hand ideals that is not in `J` +/// is divisible by `u` and by `v`, hence by `uv`. Splitting the first variable +/// off each generator that mentions more than one strictly decreases +/// `Σ (|supp m| − 1)`, so the recursion terminates, and what it terminates on is +/// a set of pure prime powers `⟨x_{j₁}^{a₁}, …⟩` — an irreducible monomial +/// ideal, primary with associated prime `⟨x_{j₁}, …⟩`. +fn collect_irreducible_monomial_components( + mons: Vec>, + n: usize, + out: &mut Vec>>, +) -> bool { + if out.len() >= MAX_MONOMIAL_COMPONENTS { + return false; + } + let mons = minimal_monomial_generators(mons); + let split_at = mons + .iter() + .position(|m| m.iter().filter(|&&e| e > 0).count() >= 2); + match split_at { + None => { + out.push(mons); + true + } + Some(i) => { + let m = mons[i].clone(); + let j = m + .iter() + .position(|&e| e > 0) + .expect("a generator with ≥2 variables has support"); + let mut u = vec![0u32; n]; + u[j] = m[j]; + let mut v = m; + v[j] = 0; + let mut left = mons.clone(); + left[i] = u; + let mut right = mons; + right[i] = v; + collect_irreducible_monomial_components(left, n, out) + && collect_irreducible_monomial_components(right, n, out) + } + } +} + +fn decompose_monomial_ideal( + mons: &[Vec], + n: usize, + order: MonomialOrder, +) -> Option> { + let mut pieces: Vec>> = Vec::new(); + if !collect_irreducible_monomial_components(mons.to_vec(), n, &mut pieces) { + return None; + } + Some( + pieces + .into_iter() + .map(|pure| { + let primary = + GroebnerBasis::compute(pure.iter().map(|m| monomial_poly(m)).collect(), order); + let associated_prime = GroebnerBasis::compute( + pure.iter() + .map(|m| monomial_poly(&squarefree_exp(m))) + .collect(), + order, + ); + PrimaryComponent { + primary, + associated_prime, } + }) + .collect(), + ) +} + +// --------------------------------------------------------------------------- +// Principal ideals +// --------------------------------------------------------------------------- + +/// `⟨∏ pᵢ^{eᵢ}⟩ = ∩ ⟨pᵢ^{eᵢ}⟩`, each factor primary. +/// +/// ℚ\[x₁,…,xₙ\] is a UFD, so the `pᵢ^{eᵢ}` are pairwise coprime and their +/// intersection is generated by their product; and `⟨pᵉ⟩` is `⟨p⟩`-primary for +/// a prime element `p`, because `ab ∈ ⟨pᵉ⟩` with `p ∤ a` forces `pᵉ | b`. +fn principal_components(f: &GbPoly, order: MonomialOrder) -> Option> { + let facs = factor_gbpoly_q(f, order)?; + if facs.is_empty() { + return None; + } + Some( + facs.into_iter() + .map(|(p, e)| PrimaryComponent { + primary: GroebnerBasis::compute(vec![gbpoly_pow(&p, e)], order), + associated_prime: GroebnerBasis::compute(vec![p], order), + }) + .collect(), + ) +} + +/// Non-constant irreducible factors of `p` over ℚ, made monic, with +/// multiplicity. `None` when FLINT declines. +fn factor_gbpoly_q(p: &GbPoly, order: MonomialOrder) -> Option> { + let n = p.n_vars; + if n == 0 || p.is_zero() { + return None; + } + let ctx = FlintMPolyCtx::new(n); + let fp = gbpoly_to_flint(p, &ctx)?; + let mut fac = FlintMPolyFactor::new(Arc::clone(&ctx)); + if !fac.factor(&fp) || !fac.constant_den_is_one() { + return None; + } + let mut out = Vec::with_capacity(fac.len()); + for i in 0..fac.len() { + let base = flint_to_gbpoly(&fac.base_at(i), n); + let exp = fac.exp_at(i); + if base.is_zero() || is_constant(&base) { + continue; + } + out.push((base.make_monic(order), exp)); + } + Some(out) +} + +fn is_constant(p: &GbPoly) -> bool { + p.terms.keys().all(|e| e.iter().all(|&v| v == 0)) +} + +/// Clear denominators and hand the integer model to FLINT. +fn gbpoly_to_flint(p: &GbPoly, ctx: &Arc) -> Option { + if p.is_zero() { + return None; + } + let coeffs: Vec = p.terms.values().cloned().collect(); + let lcm = lcm_rational_denoms(&coeffs); + let nv = ctx.nvars(); + let mut fp = FlintMPoly::new(Arc::clone(ctx)); + for (e, c) in &p.terms { + let scaled = c.clone() * rug::Rational::from((lcm.clone(), 1)); + let (num, den) = scaled.into_numer_denom(); + debug_assert_eq!(den, rug::Integer::from(1)); + let mut exp = vec![0u64; nv]; + for (i, &v) in e.iter().enumerate() { + if i < nv { + exp[i] = u64::from(v); + } + } + fp.push_term(&num, &exp); + } + fp.finish(); + Some(fp) +} + +fn flint_to_gbpoly(f: &FlintMPoly, n_vars: usize) -> GbPoly { + let mut terms: BTreeMap, rug::Rational> = BTreeMap::new(); + for (e, c) in f.terms() { + if c == 0 { + continue; + } + let mut exp = vec![0u32; n_vars]; + for (i, &v) in e.iter().enumerate() { + if i < n_vars { + exp[i] = v; + } + } + terms.insert(exp, rug::Rational::from((c, 1))); + } + GbPoly { terms, n_vars } +} + +// --------------------------------------------------------------------------- +// Shape-position certificate +// --------------------------------------------------------------------------- + +/// `⟨x₀ − g₀(t), …, x_{n−2} − g_{n−2}(t), h(t)⟩` with `t = x_{n−1}`, `h = c·pᵉ` +/// and `p` irreducible over ℚ. +/// +/// The quotient is `ℚ[t]/⟨pᵉ⟩` — a local Artinian ring, whose only zero +/// divisors are nilpotent — so the ideal is primary, and replacing `h` by `p` +/// gives the associated prime `ℚ[t]/⟨p⟩`, a field. +/// +/// This is the certificate the zero-dimensional leaves of the saturation and +/// CRT splits land on: `⟨x − 1, y⟩` and `⟨x + 1, y⟩` from `⟨x² − 1, y⟩`, for +/// instance. +fn certify_shape_position( + gens: &[GbPoly], + n: usize, + order: MonomialOrder, +) -> Result, PrimaryDecompositionError> { + if n == 0 || gens.len() != n { + return Ok(None); + } + let t = n - 1; + let mut eliminant: Option = None; + let mut solved: Vec<(usize, GbPoly)> = Vec::new(); + for g in gens { + if g.is_zero() { + return Ok(None); + } + if is_univariate_in_var(g, t) { + if eliminant.is_some() { + return Ok(None); } + eliminant = Some(g.clone()); + } else if let Some(j) = solved_variable_over_last(g, t) { + solved.push((j, g.clone())); + } else { + return Ok(None); } - if !appended { - break; + } + let Some(h) = eliminant else { + return Ok(None); + }; + let mut seen: Vec = solved.iter().map(|(j, _)| *j).collect(); + seen.sort_unstable(); + if seen != (0..t).collect::>() { + return Ok(None); + } + + let facs = factor_univariate_q_monic(&h, t, n)?; + if facs.len() != 1 { + // Several distinct factors: the CRT split should have taken this ideal + // apart already, so this is not a leaf. + return Ok(None); + } + let (p, _e) = facs.into_iter().next().expect("exactly one factor"); + let mut prime_gens: Vec = solved.into_iter().map(|(_, g)| g).collect(); + prime_gens.push(p); + Ok(Some(PrimaryComponent { + primary: GroebnerBasis::compute(gens.to_vec(), order), + associated_prime: GroebnerBasis::compute(prime_gens, order), + })) +} + +/// `Some(j)` when `g = c·x_j + (a polynomial in x_t alone)` with `j ≠ t`. +fn solved_variable_over_last(g: &GbPoly, t: usize) -> Option { + let mut lead: Option = None; + for e in g.terms.keys() { + let support: Vec = e + .iter() + .enumerate() + .filter(|(_, &v)| v > 0) + .map(|(i, _)| i) + .collect(); + if support.iter().all(|&i| i == t) { + continue; + } + if support.len() == 1 && support[0] != t && e[support[0]] == 1 { + if lead.is_some() { + return None; + } + lead = Some(support[0]); + } else { + return None; } - gens = GroebnerBasis::compute(gens, order).generators().to_vec(); } - GroebnerBasis::compute(gens, order) + lead } fn find_any_univariate(gens: &[GbPoly], var: usize) -> Option { @@ -511,6 +1187,33 @@ fn univariate_squarefree_part(u: &GbPoly, var: usize, order: MonomialOrder) -> G GbPoly { terms, n_vars: n }.make_monic(order) } +/// Drop every component that contains another one. +/// +/// `Q_j ⊆ Q_i` makes `Q_i ∩ Q_j = Q_j`, so `Q_i` contributes nothing to the +/// intersection and the decomposition stays exact without it. This is what +/// removes the `⟨x, z⟩` that `⟨xz, yz⟩` used to report alongside `⟨z⟩` — the +/// ideal is square-free monomial, hence radical, so exactly its two minimal +/// primes are associated. +fn drop_redundant_components(v: &mut Vec) { + let mut i = 0; + while i < v.len() { + let redundant = v + .iter() + .enumerate() + .any(|(j, other)| j != i && ideal_contains_ideal(&v[i].primary, &other.primary)); + if redundant { + v.remove(i); + } else { + i += 1; + } + } +} + +/// `inner ⊆ outer`. +fn ideal_contains_ideal(outer: &GroebnerBasis, inner: &GroebnerBasis) -> bool { + inner.generators().iter().all(|g| outer.contains(g)) +} + fn dedup_components(v: &mut Vec) { let mut i = 0; while i < v.len() { @@ -611,4 +1314,189 @@ mod tests { let dec = primary_decomposition(vec![xm1, y], MonomialOrder::Lex).unwrap(); assert_eq!(dec.len(), 2); } + + // ----------------------------------------------------------------------- + // 3.8 — silent errors in the radical and the associated primes + // ----------------------------------------------------------------------- + + /// `c₀·x^a·y^b + c₁·x^c·y^d + …` from `[(a, b, c₀), …]`. + fn poly2(terms: &[(u32, u32, i64)]) -> GbPoly { + GbPoly { + terms: terms + .iter() + .map(|&(a, b, c)| (vec![a, b], rat(c, 1))) + .collect(), + n_vars: 2, + } + } + + fn poly3(terms: &[(u32, u32, u32, i64)]) -> GbPoly { + GbPoly { + terms: terms + .iter() + .map(|&(a, b, c, k)| (vec![a, b, c], rat(k, 1))) + .collect(), + n_vars: 3, + } + } + + #[test] + fn radical_of_a_square_contains_its_base() { + // √⟨(x−y)²⟩ = ⟨x−y⟩ exactly: ℚ[x,y]/(x−y) ≅ ℚ[y] is a domain, so ⟨x−y⟩ + // is prime, and it contains (x−y)². This returned ⟨(x−y)²⟩ before 3.8, + // so `contains(x−y)` was False while `contains((x−y)²)` was True. + let sq = poly2(&[(2, 0, 1), (1, 1, -2), (0, 2, 1)]); + let r = radical(vec![sq.clone()], MonomialOrder::Lex).unwrap(); + let base = poly2(&[(1, 0, 1), (0, 1, -1)]); + assert!(r.contains(&base), "√⟨(x−y)²⟩ must contain x−y"); + assert!(r.contains(&sq)); + // …and no more than that: y ∉ ⟨x−y⟩. + assert!(!r.contains(&poly2(&[(0, 1, 1)]))); + } + + #[test] + fn decomposition_of_a_difference_of_squares_is_two_primes() { + // x² − y² = (x−y)(x+y), both irreducible and non-associate, so + // ⟨x²−y²⟩ = ⟨x−y⟩ ∩ ⟨x+y⟩ and neither factor ideal is the whole thing. + // ⟨x²−y²⟩ itself is *not* primary: (x−y)(x+y) ∈ I, x−y ∉ I by degree, + // and no power of x+y is divisible by x²−y². + let f = poly2(&[(2, 0, 1), (0, 2, -1)]); + let dec = primary_decomposition(vec![f], MonomialOrder::Lex).unwrap(); + assert_eq!(dec.len(), 2); + let minus = poly2(&[(1, 0, 1), (0, 1, -1)]); + let plus = poly2(&[(1, 0, 1), (0, 1, 1)]); + assert!(dec.iter().any(|c| ideals_equal( + &c.primary, + &GroebnerBasis::compute(vec![minus.clone()], MonomialOrder::Lex) + ))); + assert!(dec.iter().any(|c| ideals_equal( + &c.primary, + &GroebnerBasis::compute(vec![plus.clone()], MonomialOrder::Lex) + ))); + for c in &dec { + assert!( + ideals_equal(&c.primary, &c.associated_prime), + "both are prime" + ); + } + } + + #[test] + fn squarefree_monomial_ideal_has_exactly_its_minimal_primes() { + // ⟨xz, yz⟩ is square-free monomial, hence radical, so its associated + // primes are exactly its minimal primes: ⟨z⟩ and ⟨x, y⟩. The third + // component this used to report, ⟨x, z⟩, contains ⟨z⟩ and is therefore + // redundant. + let xz = poly3(&[(1, 0, 1, 1)]); + let yz = poly3(&[(0, 1, 1, 1)]); + let dec = primary_decomposition(vec![xz, yz], MonomialOrder::Lex).unwrap(); + assert_eq!(dec.len(), 2); + let z_only = GroebnerBasis::compute(vec![poly3(&[(0, 0, 1, 1)])], MonomialOrder::Lex); + let x_and_y = GroebnerBasis::compute( + vec![poly3(&[(1, 0, 0, 1)]), poly3(&[(0, 1, 0, 1)])], + MonomialOrder::Lex, + ); + assert!(dec.iter().any(|c| ideals_equal(&c.primary, &z_only))); + assert!(dec.iter().any(|c| ideals_equal(&c.primary, &x_and_y))); + for c in &dec { + assert!( + ideals_equal(&c.primary, &c.associated_prime), + "radical ideal" + ); + } + } + + #[test] + fn embedded_monomial_component_keeps_its_multiplicity() { + // ⟨x², xy⟩ = ⟨x⟩ ∩ ⟨x², y⟩. The second is ⟨x,y⟩-primary and *not* + // prime, so a decomposition that reported prime = primary here would be + // wrong in the other direction. + let dec = primary_decomposition( + vec![poly2(&[(2, 0, 1)]), poly2(&[(1, 1, 1)])], + MonomialOrder::Lex, + ) + .unwrap(); + assert_eq!(dec.len(), 2); + let embedded = dec + .iter() + .find(|c| !ideals_equal(&c.primary, &c.associated_prime)) + .expect("⟨x², y⟩ is primary but not prime"); + assert!(embedded.associated_prime.contains(&poly2(&[(1, 0, 1)]))); + assert!(embedded.associated_prime.contains(&poly2(&[(0, 1, 1)]))); + assert!( + !embedded.primary.contains(&poly2(&[(1, 0, 1)])), + "x ∉ ⟨x², y⟩" + ); + } + + #[test] + fn primary_when_the_radical_is_maximal() { + // √⟨x² + y², xy⟩ = ⟨x, y⟩: y(x²+y²) − x(xy) = y³ and x(x²+y²) − y(xy) = x³ + // are both in the ideal. A maximal radical makes the ideal primary. + let gens = vec![poly2(&[(2, 0, 1), (0, 2, 1)]), poly2(&[(1, 1, 1)])]; + let r = radical(gens.clone(), MonomialOrder::Lex).unwrap(); + assert!(r.contains(&poly2(&[(1, 0, 1)]))); + assert!(r.contains(&poly2(&[(0, 1, 1)]))); + let dec = primary_decomposition(gens, MonomialOrder::Lex).unwrap(); + assert_eq!(dec.len(), 1); + assert!(dec[0].associated_prime.contains(&poly2(&[(1, 0, 1)]))); + assert!(!dec[0].primary.contains(&poly2(&[(1, 0, 1)]))); + } + + #[test] + fn radical_refuses_rather_than_return_its_input() { + // The twisted cubic ⟨y − x², z − x³⟩ is prime and one-dimensional; it is + // neither monomial nor principal nor zero-dimensional, and none of the + // splits reduce it. √I = I here, but nothing in this module can *show* + // that, so it must refuse instead of guessing right by accident. + let gens = vec![ + poly3(&[(0, 1, 0, 1), (2, 0, 0, -1)]), + poly3(&[(0, 0, 1, 1), (3, 0, 0, -1)]), + ]; + let err = radical(gens.clone(), MonomialOrder::Lex) + .expect_err("must refuse rather than assert √I = I"); + assert!(matches!(err, PrimaryDecompositionError::Factorization(_))); + let refusal = take_ideal_refusal().expect("refusal recorded out of band"); + assert_eq!(refusal.code(), "E-IDEAL-005"); + assert_eq!(take_ideal_refusal(), None, "consuming"); + + let err = primary_decomposition(gens, MonomialOrder::Lex).expect_err("must refuse"); + assert!(matches!(err, PrimaryDecompositionError::Factorization(_))); + assert_eq!( + take_ideal_refusal().expect("refusal recorded").code(), + "E-IDEAL-006" + ); + } + + #[test] + fn every_reported_associated_prime_is_radical() { + // The weakest check that would have caught the old behaviour on every + // ideal at once: √P = P for a prime P, so re-radicalising a reported + // associated prime must be a no-op. + let cases: Vec> = vec![ + vec![poly2(&[(2, 0, 1), (0, 2, -1)])], + vec![poly2(&[(2, 0, 1)]), poly2(&[(1, 1, 1)])], + vec![poly3(&[(1, 0, 1, 1)]), poly3(&[(0, 1, 1, 1)])], + vec![poly2(&[(2, 0, 1), (0, 0, -1)]), poly2(&[(0, 1, 1)])], + vec![poly2(&[(2, 0, 1), (0, 2, 1)]), poly2(&[(1, 1, 1)])], + ]; + for gens in cases { + let dec = primary_decomposition(gens.clone(), MonomialOrder::Lex).unwrap(); + assert!(!dec.is_empty()); + for c in &dec { + let again = radical(c.associated_prime.generators().to_vec(), MonomialOrder::Lex) + .expect("the radical of a certified prime is computable"); + assert!( + ideals_equal(&again, &c.associated_prime), + "√P ≠ P — the reported associated prime is not prime" + ); + for g in c.primary.generators() { + assert!( + c.associated_prime.contains(g), + "Q ⊄ √Q — the component does not lie in its own prime" + ); + } + } + } + } } diff --git a/alkahest-core/src/integrate/risch/tower_integrate.rs b/alkahest-core/src/integrate/risch/tower_integrate.rs index 66b74393..3625367b 100644 --- a/alkahest-core/src/integrate/risch/tower_integrate.rs +++ b/alkahest-core/src/integrate/risch/tower_integrate.rs @@ -51,7 +51,7 @@ use super::exp_case::build_rational; use super::number_field::{ gdegree, gext_gcd, gpoly_divrem, gpoly_mul, gtrim, CoeffField, Quotient, }; -use super::poly_rde::{contains_subexpr, expr_to_qpoly, is_free_of_var, poly_deriv}; +use super::poly_rde::{contains_subexpr, expr_to_qpoly, is_free_of_var, poly_deriv, QPoly}; use super::radical_ext::RadicalExt; use super::tower::find_generators; use super::tower_field::{solve_tower_rde_generic, ExpTowerField, LogTowerField, TExpr}; @@ -74,6 +74,23 @@ pub fn try_integrate_radical_over_exp( try_integrate_radical_over_transcendental(expr, var, pool) } +/// `Dt = h′/h` for a log tower generator `t = log(h)`, or `None` when `h` is +/// identically zero. +/// +/// An integrand can spell `log(0)`: `√(log(x − x))` reaches here with an empty +/// `h_poly`, and [`RatFn::new`] *panics* on a zero denominator. Crossing the +/// FFI boundary that panic arrives as `pyo3_runtime.PanicException`, which +/// inherits `BaseException` and therefore slips past a caller's +/// `except Exception` and kills an unattended run. A generator that is +/// identically zero is not a log tower at all, so decline the shape and let +/// ordinary dispatch refuse the integral through the usual coded error. +fn log_generator_derivative(h_poly: QPoly) -> Option { + if h_poly.iter().all(|c| *c == 0) { + return None; + } + Some(RatFn::new(poly_deriv(&h_poly), h_poly)) +} + /// Public entry: try to integrate a radical whose radicand involves a single /// **exp or log** transcendental generator. Returns `None` when the integrand /// is not of this shape, so the caller falls through to the ordinary dispatch. @@ -102,7 +119,7 @@ pub fn try_integrate_radical_over_transcendental( let h = g.argument(); let t_gen = pool.func("log", vec![h]); let h_poly = expr_to_qpoly(h, var, pool)?; - let dh_over_h = RatFn::new(poly_deriv(&h_poly), h_poly); + let dh_over_h = log_generator_derivative(h_poly)?; let field = LogTowerField::new(dh_over_h); integrate_radical_over_tower(&field, t_gen, expr, n, a_expr, var, pool) } else { @@ -183,7 +200,7 @@ pub fn try_integrate_exp_times_radical_over_tower( let h = inner.argument(); let t_gen = pool.func("log", vec![h]); let h_poly = expr_to_qpoly(h, var, pool)?; - let dh_over_h = RatFn::new(poly_deriv(&h_poly), h_poly); + let dh_over_h = log_generator_derivative(h_poly)?; let field = LogTowerField::new(dh_over_h); integrate_exp_times_radical( &field, t_gen, exp_eta, eta_prime, expr, n, a_expr, var, pool, diff --git a/alkahest-core/src/lattice/lll.rs b/alkahest-core/src/lattice/lll.rs index fe6112c3..28c9a01f 100644 --- a/alkahest-core/src/lattice/lll.rs +++ b/alkahest-core/src/lattice/lll.rs @@ -126,6 +126,18 @@ fn gram_schmidt_rows( for i in 0..n { let mut vip = int_row_as_rat(&basis[i]); for j in 0..i { + // A rank-deficient basis makes some `b*_j` the zero vector, and the + // projection coefficient onto it is `0/0`. There is nothing to + // subtract — the zero vector spans nothing — so the projection is + // `0`, which is the convention `size_reduce_single` below already + // applies. Without this the exact-rational divide panicked + // ("division by zero" inside `rug`), and across the FFI boundary a + // panic becomes `pyo3_runtime.PanicException`: a `BaseException` + // that a caller's `except Exception` does not catch. Any two + // dependent rows — `lll_reduce_rows([[1, 2], [2, 4]])` — reached it. + if b_norm_sq[j].is_zero() { + continue; + } mu[i][j].assign(&dot_int_rat(&basis[i], &star[j]) / &b_norm_sq[j]); for t in 0..ambient { let m = mu[i][j].clone() * star[j][t].clone(); @@ -232,7 +244,13 @@ fn lovasz_ok(b_norm_sq: &[Rational], mu: &[Vec], delta: &Rational, k: let bk = &b_norm_sq[k]; let bkm1 = &b_norm_sq[k - 1]; if bkm1.is_zero() { - return false; + // `‖b*_k‖² ≥ (δ − μ²)·0` holds for every `b*_k`, so the condition is + // satisfied and `k` must advance. Reporting `false` here forced a swap + // that put the zero row back where it came from: `[[1,2],[2,4]]` + // oscillated between `[(0,0),(1,2)]` and `[(1,2),(0,0)]` until the + // two-million-swap guard fired. Unreachable for a full-rank basis, + // where no `b*_j` vanishes. + return true; } let mux = mu[k][k - 1].clone(); let mux_sq = Rational::from(&mux * &mux); @@ -369,6 +387,38 @@ mod tests { validate_lll_rows(&reduced, &delta).unwrap(); } + #[test] + fn rank_deficient_basis_returns_instead_of_panicking() { + // Any two dependent rows make some `b*_j` the zero vector, and the + // Gram–Schmidt coefficient onto it was computed as `0/0`. The exact + // rational divide panicked, and across the FFI boundary a panic becomes + // `pyo3_runtime.PanicException` — a `BaseException` an unattended + // loop's `except Exception` does not catch. + for rows in [ + vec![ + vec![Integer::from(1), Integer::from(2)], + vec![Integer::from(2), Integer::from(4)], + ], + vec![ + vec![Integer::from(0), Integer::from(0)], + vec![Integer::from(3), Integer::from(5)], + ], + vec![ + vec![Integer::from(1), Integer::from(1), Integer::from(0)], + vec![Integer::from(2), Integer::from(2), Integer::from(0)], + vec![Integer::from(0), Integer::from(0), Integer::from(7)], + ], + ] { + let reduced = lattice_reduce_rows(&rows); + assert!( + reduced.is_ok(), + "rank-deficient basis must return a value or a coded error, not panic" + ); + let reduced = reduced.unwrap(); + assert_eq!(reduced.len(), rows.len(), "row count must be preserved"); + } + } + #[test] fn knapsack_row_weighted_near_origin() { let rows: Vec> = vec![ diff --git a/alkahest-core/src/lib.rs b/alkahest-core/src/lib.rs index 1be96c56..e768c3b9 100644 --- a/alkahest-core/src/lib.rs +++ b/alkahest-core/src/lib.rs @@ -209,7 +209,10 @@ pub use diffalg::{ DifferentialRanking, DifferentialRing, RegularDifferentialChain, RosenfeldGroebnerResult, }; #[cfg(feature = "groebner")] -pub use ideal::{primary_decomposition, radical, PrimaryComponent, PrimaryDecompositionError}; +pub use ideal::{ + primary_decomposition, radical, take_ideal_refusal, IdealRefusal, PrimaryComponent, + PrimaryDecompositionError, +}; pub use modular::{ is_prime, lift_crt, mignotte_bound, rational_reconstruction, reduce_mod, select_lucky_prime, ModularError, ModularValue, MultiPolyFp, @@ -260,7 +263,8 @@ pub mod stable { }; #[cfg(feature = "groebner")] pub use crate::ideal::{ - primary_decomposition, radical, PrimaryComponent, PrimaryDecompositionError, + primary_decomposition, radical, take_ideal_refusal, IdealRefusal, PrimaryComponent, + PrimaryDecompositionError, }; pub use crate::integrate::{integrate, integrate_definite, IntegrationError}; pub use crate::jit::{compile, CompileCache, CompiledFn, JitError}; @@ -398,13 +402,13 @@ pub mod experimental { #[cfg(feature = "parallel")] pub use crate::simplify::redex::{simplify_redex, simplify_redex_with_config}; - #[cfg(feature = "groebner-cuda")] - pub use crate::poly::groebner::GpuGroebnerError; #[cfg(feature = "groebner")] pub use crate::poly::groebner::{ compute_groebner_basis_f5, fglm, grevlex_staircase, is_zero_dimensional, GbPoly, GroebnerBasis, MonomialOrder, }; + #[cfg(feature = "groebner-cuda")] + pub use crate::poly::groebner::{GpuBackendReport, GpuGroebnerError}; /// Bounded content-addressed expression pool (RFC 0001). Not used by default /// [`crate::ExprPool`]; unit-tested prototype only. diff --git a/alkahest-core/src/poly/groebner/cuda.rs b/alkahest-core/src/poly/groebner/cuda.rs index 0b4df87f..cd49ebcc 100644 --- a/alkahest-core/src/poly/groebner/cuda.rs +++ b/alkahest-core/src/poly/groebner/cuda.rs @@ -18,6 +18,17 @@ //! Enabled by `--features groebner-cuda` (implies `groebner` + `cuda`). //! The `compute_groebner_basis_gpu` entry point is always available; it //! falls back to pure-Rust row reduction when no CUDA device is found. +//! +//! # The fallback is reported, not hidden +//! +//! Because the fallback exists, "this function is named `..._gpu`" is not +//! evidence that a GPU ran. Both entry points therefore return a +//! [`GpuBackendReport`] alongside the polynomials, counting how many mod-p row +//! reductions executed on each side and carrying the first driver error that +//! forced a fallback. [`GpuBackendReport::ran_on_gpu`] is the question a +//! caller actually wants answered; before 3.8 it was unanswerable, since +//! `device_id: None` and "the driver failed on every prime" produced results +//! indistinguishable from a real GPU run. use crate::poly::groebner::ideal::GbPoly; use crate::poly::groebner::monomial_order::MonomialOrder; @@ -158,6 +169,72 @@ impl crate::errors::AlkahestError for GpuGroebnerError { } } +// --------------------------------------------------------------------------- +// Where the work actually ran +// --------------------------------------------------------------------------- + +/// Where the mod-p Macaulay row reductions of a run actually executed. +/// +/// Returned by [`reduce_batch`] and [`compute_groebner_basis_gpu`] so that a +/// CPU fallback is *observable*. The counts are of individual mod-p row +/// reductions (one per prime per Macaulay matrix), not of polynomials. +/// +/// ```rust,ignore +/// let (basis, backend) = compute_groebner_basis_gpu(gens, order, Some(0))?; +/// assert!(backend.ran_on_gpu(), "silently reduced on the CPU: {backend:?}"); +/// ``` +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GpuBackendReport { + /// The device ordinal the caller asked for, or `None` if the caller asked + /// for the CPU path outright. + pub requested_device: Option, + /// Row reductions that executed on a CUDA device. + pub reductions_on_gpu: usize, + /// Row reductions that executed on the CPU — either because + /// `requested_device` was `None`, or because the GPU attempt failed and + /// this one fell back. + pub reductions_on_cpu: usize, + /// The first GPU failure that forced a fallback, if any. `None` on a run + /// with no GPU failures (including a run that never asked for a GPU). + pub first_gpu_error: Option, +} + +impl GpuBackendReport { + fn new(requested_device: Option) -> Self { + GpuBackendReport { + requested_device, + ..Default::default() + } + } + + /// Fold another report (from a nested [`reduce_batch`] call) into this one. + fn absorb(&mut self, other: &GpuBackendReport) { + self.reductions_on_gpu += other.reductions_on_gpu; + self.reductions_on_cpu += other.reductions_on_cpu; + if self.first_gpu_error.is_none() { + self.first_gpu_error.clone_from(&other.first_gpu_error); + } + } + + /// True only when at least one row reduction ran on a CUDA device *and* + /// none fell back to the CPU. + /// + /// Deliberately conservative: a run in which the driver died on the first + /// prime and every subsequent reduction ran on the CPU is not a GPU run, + /// and a run that reduced nothing at all (an empty or trivial ideal) did + /// not exercise the GPU either. + pub fn ran_on_gpu(&self) -> bool { + self.reductions_on_gpu > 0 && self.reductions_on_cpu == 0 + } + + /// True when any row reduction ran on the CPU. This is `true` for a + /// `device_id: None` run, which is the case that used to be + /// indistinguishable from a GPU run. + pub fn fell_back_to_cpu(&self) -> bool { + self.reductions_on_cpu > 0 + } +} + // --------------------------------------------------------------------------- // Mod-p arithmetic helpers // --------------------------------------------------------------------------- @@ -250,7 +327,7 @@ fn rational_reconstruction(a: &Integer, modulus: &Integer) -> Option { pub struct MacaulayMatrix { pub n_rows: usize, pub n_cols: usize, - /// monomials[col] = exponent vector for that column, sorted descending by `order`. + /// `monomials[col]` = exponent vector for that column, sorted descending by `order`. pub monomials: Vec>, /// Row-major data: `data[row * n_cols + col]` is the coefficient mod p. pub data: Vec, @@ -702,17 +779,21 @@ const PRIMES: &[u64] = &[ /// Macaulay-matrix row reduction with CRT rational reconstruction. /// /// Returns the non-zero reduced forms (remainders), equivalent to calling -/// `reduce(sp, basis, order)` for each sp individually. +/// `reduce(sp, basis, order)` for each sp individually, paired with a +/// [`GpuBackendReport`] saying where the row reductions actually ran. /// -/// `device_id` controls which CUDA device to use. Pass `None` to force CPU. +/// `device_id` controls which CUDA device to use. Pass `None` to force CPU — +/// in which case the report says so, rather than leaving the caller to assume +/// from the function's name that a GPU was involved. pub fn reduce_batch( targets: &[GbPoly], basis: &[GbPoly], order: MonomialOrder, device_id: Option, -) -> Result, GpuGroebnerError> { +) -> Result<(Vec, GpuBackendReport), GpuGroebnerError> { + let mut report = GpuBackendReport::new(device_id); if targets.is_empty() { - return Ok(vec![]); + return Ok((vec![], report)); } // Build upper (basis multiples) + lower (targets) parts of the Macaulay matrix. @@ -728,7 +809,10 @@ pub fn reduce_batch( let n_rows = all_rows.len(); if n_rows == 0 || all_rows[0].n_vars == 0 { - return Ok(targets.iter().filter(|p| !p.is_zero()).cloned().collect()); + return Ok(( + targets.iter().filter(|p| !p.is_zero()).cloned().collect(), + report, + )); } let n_vars = all_rows[0].n_vars; @@ -757,14 +841,22 @@ pub fn reduce_batch( if let Some(dev) = device_id { match mat.reduce_gpu(dev) { - Ok(()) => {} + Ok(()) => report.reductions_on_gpu += 1, Err(e) => { + // Still a warning on stderr for interactive use, but the + // machine-readable channel is the report: a caller that + // never reads stderr must still be able to find out. eprintln!("alkahest: GPU row reduction failed ({e}), using CPU fallback"); + if report.first_gpu_error.is_none() { + report.first_gpu_error = Some(e.to_string()); + } mat.reduce_cpu(); + report.reductions_on_cpu += 1; } } } else { mat.reduce_cpu(); + report.reductions_on_cpu += 1; } lifter.add_image(&mat); @@ -799,7 +891,7 @@ pub fn reduce_batch( if stable || prime_count >= PRIMES.len() { let mut result = new_elements; result.dedup_by(|a, b| a.terms == b.terms); - return Ok(result); + return Ok((result, report)); } prev = Some(new_elements); } @@ -824,11 +916,22 @@ pub fn reduce_batch( /// /// `device_id` is the CUDA device ordinal (0-indexed). Pass `None` to run /// entirely on CPU (useful for testing correctness without a GPU). +/// +/// # The second return value is not optional reading +/// +/// This function computes the same basis whether or not a GPU was involved, so +/// the basis alone cannot tell you which happened: `device_id: None` runs +/// wholly on the CPU, and a `Some(dev)` run whose driver calls all fail also +/// runs wholly on the CPU after logging to stderr. The returned +/// [`GpuBackendReport`] is the only in-band answer — check +/// [`GpuBackendReport::ran_on_gpu`] before recording a measurement as a GPU +/// measurement. pub fn compute_groebner_basis_gpu( generators: Vec, order: MonomialOrder, device_id: Option, -) -> Result, GpuGroebnerError> { +) -> Result<(Vec, GpuBackendReport), GpuGroebnerError> { + let mut report = GpuBackendReport::new(device_id); let mut basis: Vec = generators .into_iter() .filter(|g| !g.is_zero()) @@ -836,7 +939,7 @@ pub fn compute_groebner_basis_gpu( .collect(); if basis.is_empty() { - return Ok(basis); + return Ok((basis, report)); } let mut pairs: Vec<(usize, usize)> = vec![]; @@ -856,7 +959,8 @@ pub fn compute_groebner_basis_gpu( .collect(); if !s_polys.is_empty() { - let reduced = reduce_batch(&s_polys, &basis, order, device_id)?; + let (reduced, batch_report) = reduce_batch(&s_polys, &basis, order, device_id)?; + report.absorb(&batch_report); let new_start = basis.len(); for r in reduced { if !r.is_zero() { @@ -879,7 +983,9 @@ pub fn compute_groebner_basis_gpu( } } - interreduce_gpu(basis, order, device_id) + let (basis, interreduce_report) = interreduce_gpu(basis, order, device_id)?; + report.absorb(&interreduce_report); + Ok((basis, report)) } fn product_criterion(f: &GbPoly, g: &GbPoly, order: MonomialOrder) -> bool { @@ -898,7 +1004,8 @@ fn interreduce_gpu( mut basis: Vec, order: MonomialOrder, device_id: Option, -) -> Result, GpuGroebnerError> { +) -> Result<(Vec, GpuBackendReport), GpuGroebnerError> { + let mut report = GpuBackendReport::new(device_id); let mut i = 0; while i < basis.len() { let others: Vec = basis @@ -907,7 +1014,8 @@ fn interreduce_gpu( .filter(|&(j, _)| j != i) .map(|(_, g)| g.clone()) .collect(); - let reduced = reduce_batch(&[basis[i].clone()], &others, order, device_id)?; + let (reduced, batch_report) = reduce_batch(&[basis[i].clone()], &others, order, device_id)?; + report.absorb(&batch_report); let r = reduced .into_iter() .next() @@ -919,7 +1027,7 @@ fn interreduce_gpu( i += 1; } } - Ok(basis) + Ok((basis, report)) } // --------------------------------------------------------------------------- @@ -1027,9 +1135,16 @@ mod tests { // (x + y - 1, x - y) → basis contains x - 1/2 and y - 1/2 let f = poly1(&[(&[1, 0], 1), (&[0, 1], 1), (&[0, 0], -1)]); let g = poly1(&[(&[1, 0], 1), (&[0, 1], -1)]); - let basis = + let (basis, backend) = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) .expect("gpu groebner failed"); + // `device_id: None` ran no GPU work and must say so — the fallback is + // reported, not inferred from the function's name. + assert!(!backend.ran_on_gpu()); + assert!(backend.fell_back_to_cpu()); + assert_eq!(backend.reductions_on_gpu, 0); + assert_eq!(backend.requested_device, None); + assert_eq!(backend.first_gpu_error, None); assert!(!basis.is_empty()); // Verify: original generators reduce to zero mod the computed basis let rf = cpu_reduce(&f, &basis, MonomialOrder::Lex); @@ -1043,9 +1158,10 @@ mod tests { // (x^2 - 1, x - 1) → {x - 1} let f = poly1(&[(&[2], 1), (&[0], -1)]); let g = poly1(&[(&[1], 1), (&[0], -1)]); - let basis = + let (basis, backend) = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) .expect("gpu groebner failed"); + assert!(!backend.ran_on_gpu()); assert_eq!(basis.len(), 1); let rf = cpu_reduce(&f, &basis, MonomialOrder::Lex); let rg = cpu_reduce(&g, &basis, MonomialOrder::Lex); @@ -1058,9 +1174,10 @@ mod tests { // x^2 + y^2 - 1 = 0, y - x = 0 → solutions (±√2/2, ±√2/2) let f = poly1(&[(&[2, 0], 1), (&[0, 2], 1), (&[0, 0], -1)]); let g = poly1(&[(&[0, 1], 1), (&[1, 0], -1)]); - let basis = + let (basis, backend) = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) .expect("gpu groebner failed"); + assert!(!backend.ran_on_gpu()); assert!(!basis.is_empty()); let rf = cpu_reduce(&f, &basis, MonomialOrder::Lex); let rg = cpu_reduce(&g, &basis, MonomialOrder::Lex); @@ -1076,8 +1193,9 @@ mod tests { let g = poly1(&[(&[1, 0], 1), (&[0, 1], -1)]); let order = MonomialOrder::Lex; - let basis_gpu = + let (basis_gpu, backend) = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], order, None).unwrap(); + assert!(!backend.ran_on_gpu(), "no device requested, so no GPU run"); let basis_cpu = compute_buchberger_basis(vec![f.clone(), g.clone()], order); // Both bases are Gröbner — each element of one should reduce to 0 mod the other diff --git a/alkahest-core/src/poly/groebner/mod.rs b/alkahest-core/src/poly/groebner/mod.rs index ab0b2c9a..d4347a4b 100644 --- a/alkahest-core/src/poly/groebner/mod.rs +++ b/alkahest-core/src/poly/groebner/mod.rs @@ -39,7 +39,7 @@ pub mod f4 { pub use buchberger::compute_buchberger_basis; #[cfg(feature = "groebner-cuda")] -pub use cuda::{compute_groebner_basis_gpu, GpuGroebnerError, MacaulayMatrix}; +pub use cuda::{compute_groebner_basis_gpu, GpuBackendReport, GpuGroebnerError, MacaulayMatrix}; pub use f5::compute_groebner_basis_f5; pub use fglm::{fglm, grevlex_staircase, is_zero_dimensional}; pub use ideal::GbPoly; diff --git a/alkahest-core/src/poly/resultant.rs b/alkahest-core/src/poly/resultant.rs index d487db5d..ff91c610 100644 --- a/alkahest-core/src/poly/resultant.rs +++ b/alkahest-core/src/poly/resultant.rs @@ -15,8 +15,8 @@ //! `Polynomial.resultant_eq_zero_iff_common_root`. use crate::deriv::{DerivationLog, DerivedExpr, RewriteStep}; -use crate::flint::integer::FlintInteger; use crate::flint::mpoly::FlintMPolyCtx; +use crate::flint::FlintPoly; use crate::kernel::{ExprData, ExprId, ExprPool}; use crate::poly::error::ConversionError; use crate::poly::multipoly::multi_to_flint_pub; @@ -239,15 +239,36 @@ pub fn resultant( /// `Vec`: /// `[p, q, S₂, S₃, …, Sₖ]` /// +/// Each element after the first two is a genuine **subresultant**: the entry of +/// degree `j` is `S_j(p, q)`, the polynomial whose coefficients are the +/// determinants of the corresponding submatrices of the Sylvester matrix. +/// /// The 0th subresultant — the resultant — can be extracted as the last /// element that is a constant (degree-0) polynomial, or from -/// [`resultant`] directly. +/// [`resultant`] directly. The two agree; when `gcd(p, q)` is non-constant the +/// chain terminates early and no degree-0 element is produced, which is the +/// honest report that the resultant is `0`. +/// +/// The one corner where no `S_j` exists at all is `deg q = 0`: the chain +/// `S_j`, `0 ≤ j < deg q`, is empty, so the sequence is just `[p, q]` and the +/// resultant `lc(q)^{deg p}` must be taken from [`resultant`]. /// /// # Algorithm /// -/// Classical Brown–Collins subresultant algorithm (1971/1967). Computations -/// stay in ℤ\[x\]; all coefficient scalings are exact integer divisions -/// guaranteed by the subresultant theory. +/// Ducos' formulation of the subresultant chain (Ducos, *Optimizations of the +/// subresultant algorithm*, JPAA 145 (2000)), which is the Brown–Collins +/// recurrence written so that the emitted elements are the *regular* +/// subresultants rather than the raw remainders. The distinction is not +/// cosmetic: for a defective sequence (a degree drop of more than one) the raw +/// Brown–Collins remainder differs from `S_{deg}` by a power of a leading +/// coefficient, so a sequence built from the remainders alone contradicts +/// [`resultant`] on its last element. +/// +/// Computations stay in ℤ\[x\]; every coefficient scaling is an exact integer +/// division guaranteed by the subresultant theory. Those divisions are +/// *checked* rather than assumed: an inexact one would be an internal +/// contradiction, and it is reported as [`ResultantError::FlintError`] instead +/// of being handed to a routine that aborts the process. /// /// # Derivation log /// @@ -267,7 +288,7 @@ pub fn subresultant_prs( std::mem::swap(&mut up, &mut uq); } - let prs_polys = sprs_inner(up, uq); + let prs_polys = sprs_inner(up, uq).ok_or(ResultantError::FlintError)?; // Convert each polynomial in the sequence back to a symbolic expression. let exprs: Vec = prs_polys @@ -283,103 +304,162 @@ pub fn subresultant_prs( } // --------------------------------------------------------------------------- -// Internal: Brown–Collins subresultant PRS +// Internal: the subresultant chain (Ducos' form of Brown–Collins) // --------------------------------------------------------------------------- -/// Classical subresultant PRS (Brown 1971, Collins 1967). -/// -/// Requires `deg(p) >= deg(q)`. Returns the sequence `[P, Q, S₂, …, Sₖ]`. -fn sprs_inner(p: UniPoly, q: UniPoly) -> Vec { - let var = p.var; - let mut sequence = vec![p.clone(), q.clone()]; +/// Dense coefficient vector, little-endian (`c[i]` multiplies `xⁱ`), with no +/// trailing zeros. The empty vector is the zero polynomial. +type Coeffs = Vec; - if q.is_zero() { - return sequence; +/// Drop trailing zero coefficients so that `len() - 1` is the degree. +fn trim(c: &mut Coeffs) { + while c.last().is_some_and(|t| *t == 0) { + c.pop(); } +} - let m = p.degree(); - let n = q.degree(); - if n < 0 { - return sequence; +/// Integer exponentiation for [`rug::Integer`] (non-negative exponent). +fn rug_pow(base: &rug::Integer, exp: u32) -> rug::Integer { + if exp == 0 { + return rug::Integer::from(1); } + let mut r = base.clone(); + for _ in 1..exp { + r *= base; + } + r +} - // β₁ = (-1)^(m - n + 1) - let delta0 = (m - n) as u32; - let beta: rug::Integer = if (delta0 + 1) % 2 == 0 { - rug::Integer::from(1) - } else { - rug::Integer::from(-1) - }; - - let mut beta_cur = beta; - let mut psi_cur: rug::Integer = rug::Integer::from(-1); - - let mut a = p; - let mut b = q; +/// `c · a`. +fn scalar_mul(a: &Coeffs, c: &rug::Integer) -> Coeffs { + if *c == 0 { + return Coeffs::new(); + } + a.iter().map(|t| rug::Integer::from(t * c)).collect() +} - loop { - if b.is_zero() { - break; +/// `a / c`, or `None` when the division is not exact (or `c = 0`). +/// +/// Checked rather than assumed: the subresultant theory says every division +/// this module performs is exact, and FLINT's `scalar_divexact` *aborts the +/// process* when it is not. A bug upstream must surface as an error, not as a +/// `SIGABRT` in the caller's Python process. +fn scalar_div_exact(a: &Coeffs, c: &rug::Integer) -> Option { + if *c == 0 { + return None; + } + let mut out = Coeffs::with_capacity(a.len()); + for t in a { + if !t.is_divisible(c) { + return None; } + out.push(rug::Integer::from(t / c)); + } + trim(&mut out); + Some(out) +} - let deg_a = a.degree(); - let deg_b = b.degree(); - if deg_b < 0 { - break; +/// Canonical pseudo-remainder: the `R` in `lc(b)^(deg a − deg b + 1) · a = q·b + R`. +/// +/// The *canonical* exponent `δ+1` matters. FLINT's `fmpz_poly_pseudo_divrem` +/// returns the **minimal** exponent `d ≤ δ+1` instead, and the subresultant +/// recurrence is stated for `δ+1`, so using FLINT's remainder unscaled leaves +/// every element short by `lc(b)^(δ+1−d)`. +/// +/// Returns `None` if `b` is zero. +fn pseudo_remainder(a: &Coeffs, b: &Coeffs) -> Option { + let db = b.len().checked_sub(1)?; + let lc_b = &b[db]; + if a.len() <= db { + // deg a < deg b: the remainder is `a` itself. + return Some(a.clone()); + } + let delta = (a.len() - 1) - db; + let mut r = scalar_mul(a, &rug_pow(lc_b, delta as u32 + 1)); + while r.len() > db { + let dr = r.len() - 1; + // Exact by construction: pre-scaling by `lc(b)^(δ+1)` leaves every + // coefficient after `k` reduction steps divisible by `lc(b)^(δ+1−k)`, + // and the loop runs at most `δ+1` steps. Checked anyway — a truncating + // division here would be a wrong polynomial with no symptom, which is + // the exact failure mode this module was fixed for. + if !r[dr].is_divisible(lc_b) { + return None; } - let delta = (deg_a - deg_b) as u32; - - // Pseudo-remainder: lc(b)^d * a = Q*b + R - let (_, r_flint, _d) = a.coeffs.pseudo_divrem(&b.coeffs); - if r_flint.is_zero() { + let quot = rug::Integer::from(&r[dr] / lc_b); + let shift = dr - db; + for (i, bi) in b.iter().enumerate() { + r[shift + i] -= rug::Integer::from(" * bi); + } + trim(&mut r); + if r.is_empty() { break; } + } + Some(r) +} - // S_{i+1} = prem(S_{i-1}, S_i) / β_i [exact scalar division] - let beta_fi = FlintInteger::from_rug(&beta_cur); - let c_coeffs = r_flint.scalar_divexact_fmpz(&beta_fi); - let c = UniPoly { - var, - coeffs: c_coeffs, - }; - sequence.push(c.clone()); +/// The subresultant chain of `p` and `q`, as Ducos states it. +/// +/// Requires `deg(p) >= deg(q)`. Returns the sequence `[P, Q, S₂, …, Sₖ]`, +/// where every element after the first two is a *regular* subresultant: the +/// element of degree `j` is exactly `S_j(p, q)`, so the last degree-0 element +/// is `S₀ = Res(p, q)` and agrees with [`resultant`]. +/// +/// Returns `None` if one of the exact divisions the theory guarantees turns out +/// not to be exact — an internal contradiction, reported rather than aborted. +fn sprs_inner(p: UniPoly, q: UniPoly) -> Option> { + let var = p.var; + let mut sequence = vec![p.clone(), q.clone()]; - // Update ψ: ψ_{i+1} = (-lc(b))^δ / ψ_i^(δ-1) - let lc_b_fmpz = b.coeffs.leading_coeff_fmpz(); - let lc_b = lc_b_fmpz.to_rug(); - let neg_lc_b: rug::Integer = -lc_b; + let mut pc = p.coefficients(); + let mut qc = q.coefficients(); + trim(&mut pc); + trim(&mut qc); + // `deg q < 0` (q = 0) or `deg q = 0`: the chain `S_j`, `0 ≤ j < deg q`, is + // empty, so there is nothing to append. + if qc.len() <= 1 || pc.is_empty() { + return Some(sequence); + } - let psi_new = if delta <= 1 { - // ψ^0 = 1, so result is just (-lc(b))^δ - rug_pow(&neg_lc_b, delta) + // s = lc(q)^(deg p − deg q); A = q; B = prem(p, −q). + let mut s = rug_pow(&qc[qc.len() - 1], (pc.len() - qc.len()) as u32); + let mut a = qc.clone(); + let neg_q: Coeffs = qc.iter().map(|t| rug::Integer::from(-t)).collect(); + let mut b = pseudo_remainder(&pc, &neg_q)?; + + while !b.is_empty() { + let d = a.len() - 1; + let e = b.len() - 1; + let delta = d - e; + + // `B` is the (possibly defective) subresultant `S_{d−1}`. The regular + // one of the same degree is `C = lc(B)^(δ−1) · B / s^(δ−1)`; when the + // sequence is normal (δ = 1) the two coincide. + let c = if delta > 1 { + let scaled = scalar_mul(&b, &rug_pow(&b[e], delta as u32 - 1)); + scalar_div_exact(&scaled, &rug_pow(&s, delta as u32 - 1))? } else { - let num = rug_pow(&neg_lc_b, delta); - let den = rug_pow(&psi_cur, delta - 1); - rug::Integer::from(num.div_exact_ref(&den)) + b.clone() }; + sequence.push(UniPoly { + var, + coeffs: FlintPoly::from_rug_coefficients(&c), + }); + if e == 0 { + break; + } - // β_{i+1} = -lc(b) · ψ_{i+1} - let beta_new = neg_lc_b * &psi_new; - - a = b; - b = c; - psi_cur = psi_new; - beta_cur = beta_new; + // B ← prem(A, −B) / (s^δ · lc(A)); A ← C; s ← lc(A). + let neg_b: Coeffs = b.iter().map(|t| rug::Integer::from(-t)).collect(); + let rem = pseudo_remainder(&a, &neg_b)?; + let divisor = rug_pow(&s, delta as u32) * &a[d]; + b = scalar_div_exact(&rem, &divisor)?; + a = c; + s = a[a.len() - 1].clone(); } - sequence -} - -/// Integer exponentiation for [`rug::Integer`] (non-negative exponent). -fn rug_pow(base: &rug::Integer, exp: u32) -> rug::Integer { - if exp == 0 { - return rug::Integer::from(1); - } - let mut r = base.clone(); - for _ in 1..exp { - r *= base; - } - r + Some(sequence) } // --------------------------------------------------------------------------- @@ -698,6 +778,182 @@ mod tests { ); } + // --- subresultant chain: determinantal ground truth --- + + /// Build the symbolic polynomial `Σ c[i]·xⁱ` from little-endian coefficients. + fn from_coeffs(p: &ExprPool, x: ExprId, c: &[i64]) -> ExprId { + let terms: Vec = c + .iter() + .enumerate() + .filter(|(_, &k)| k != 0) + .map(|(i, &k)| { + let xi = p.pow(x, p.integer(i as i64)); + p.mul(vec![p.integer(k), xi]) + }) + .collect(); + if terms.is_empty() { + p.integer(0_i32) + } else { + p.add(terms) + } + } + + /// Read a PRS element back as little-endian integer coefficients. + fn to_coeffs(p: &ExprPool, x: ExprId, e: ExprId) -> Vec { + let mut c = UniPoly::from_symbolic(e, x, p).unwrap().coefficients(); + while c.last().is_some_and(|t| *t == 0) { + c.pop(); + } + c + } + + /// Determinant by Gaussian elimination over ℚ (test-only; the matrices here + /// are tiny and this is deliberately a different algorithm from anything in + /// the module under test). + fn det_rational(mut m: Vec>) -> rug::Rational { + let n = m.len(); + let mut d = rug::Rational::from(1); + for i in 0..n { + let Some(piv) = (i..n).find(|&r| m[r][i] != 0) else { + return rug::Rational::from(0); + }; + if piv != i { + m.swap(i, piv); + d = -d; + } + let (head, tail) = m.split_at_mut(i + 1); + let pivot_row = &head[i]; + d *= pivot_row[i].clone(); + let inv = rug::Rational::from(1) / pivot_row[i].clone(); + for row in tail.iter_mut() { + let f = row[i].clone() * inv.clone(); + if f == 0 { + continue; + } + for (cell, pivot) in row[i..n].iter_mut().zip(pivot_row[i..n].iter()) { + *cell -= f.clone() * pivot.clone(); + } + } + } + d + } + + /// `S_j(f, g)` straight from the definition: the coefficient of `x^k` in + /// `S_j` is the determinant of the `(m+n−2j)`-square matrix whose rows are + /// `x^{n−j−1}f, …, f, x^{m−j−1}g, …, g` taken in the degree columns + /// `m+n−j−1, …, j+1` together with the degree-`k` column. + /// + /// This is the ground truth the chain is checked against — no part of it + /// shares code with `sprs_inner`. + fn subresultant_by_determinant(f: &[i64], g: &[i64], j: usize) -> Vec { + let m = f.len() - 1; + let n = g.len() - 1; + let width = m + n - j; // degrees m+n−j−1 … 0 + let row_of = |poly: &[i64], sh: usize| -> Vec { + // Column c holds the coefficient of degree `width−1−c`. + (0..width) + .map(|c| { + let deg = width - 1 - c; + let k = deg.wrapping_sub(sh); + if deg >= sh && k < poly.len() { + rug::Rational::from(poly[k]) + } else { + rug::Rational::from(0) + } + }) + .collect() + }; + let mut rows: Vec> = Vec::new(); + for sh in (0..n - j).rev() { + rows.push(row_of(f, sh)); + } + for sh in (0..m - j).rev() { + rows.push(row_of(g, sh)); + } + let size = m + n - 2 * j; + assert_eq!(rows.len(), size); + let mut out: Vec = Vec::new(); + for k in 0..=j { + let mut cols: Vec = (0..size - 1).collect(); + cols.push(width - 1 - k); + let sub: Vec> = rows + .iter() + .map(|r| cols.iter().map(|&c| r[c].clone()).collect()) + .collect(); + let d = det_rational(sub); + assert_eq!(*d.denom(), 1); + out.push(d.numer().clone()); + } + while out.last().is_some_and(|t| *t == 0) { + out.pop(); + } + out + } + + #[test] + fn sprs_matches_the_sylvester_determinants() { + // Every element of degree `j` in the returned sequence must be exactly + // `S_j`, and the last degree-0 element must be `Res(f, g)`. + // + // The two families below are the ones from the 3.8 silent-error hunt: + // `subresultant_prs(x²−3x+2, 2x)` used to end in `4` while `resultant` + // said `8`, and `subresultant_prs(3x³−x, −3x²+2x−3)` returned + // `8x+6, −44` where the determinants give `−24x−18, −396`. + let p = ExprPool::new(); + let x = p.symbol("x", Domain::Real); + let cases: &[(&[i64], &[i64])] = &[ + (&[2, -3, 1], &[0, 2]), + (&[0, -1, 0, 3], &[-3, 2, -3]), + (&[1, 2, 2], &[1, 1, 2]), + (&[1, 0, 1], &[0, 2]), + (&[-2, 0, 0, 3, 2, -1], &[-3, 2, 0, -1, -1]), + (&[-2, -3, -1, 3, 3, -1], &[-3, -2, -2, 0, 2]), + (&[1, 1, 1, 1], &[2, 0, 3]), + (&[-5, 0, 0, 0, 7], &[1, -1, 1]), + ]; + for (f, g) in cases { + let pf = from_coeffs(&p, x, f); + let pg = from_coeffs(&p, x, g); + let seq = subresultant_prs(pf, pg, x, &p).unwrap().value; + for &elem in &seq[2..] { + let c = to_coeffs(&p, x, elem); + let j = c.len() - 1; + assert_eq!( + c, + subresultant_by_determinant(f, g, j), + "element of degree {j} is not S_{j} for f={f:?}, g={g:?}" + ); + } + // …and the resultant agrees with `resultant`, sign included. + let last = to_coeffs(&p, x, *seq.last().unwrap()); + if last.len() == 1 && seq.len() > 2 { + let r = resultant(pf, pg, x, &p).unwrap().value; + let expected = match p.get(r) { + ExprData::Integer(n) => n.0.clone(), + other => panic!("resultant was not an integer: {other:?}"), + }; + assert_eq!(last[0], expected, "last PRS element ≠ resultant"); + } + } + } + + #[test] + fn sprs_survives_an_inexact_scaling_input() { + // `subresultant_prs(2x²+2x+1, 2x²+x+1)` used to hand a non-exact + // division to FLINT's `scalar_divexact`, which does not raise — it + // calls `flint_abort`, taking the whole process down with SIGABRT, so + // no `except` of any kind could survive it. + let p = ExprPool::new(); + let x = p.symbol("x", Domain::Real); + let f = from_coeffs(&p, x, &[1, 2, 2]); + let g = from_coeffs(&p, x, &[1, 1, 2]); + let seq = subresultant_prs(f, g, x, &p).unwrap().value; + assert_eq!( + to_coeffs(&p, x, *seq.last().unwrap()), + vec![rug::Integer::from(2)] + ); + } + #[test] fn subresultant_prs_non_polynomial_error() { let p = ExprPool::new(); diff --git a/alkahest-core/src/primitive/mod.rs b/alkahest-core/src/primitive/mod.rs index 5d9e4c91..a64d82bd 100644 --- a/alkahest-core/src/primitive/mod.rs +++ b/alkahest-core/src/primitive/mod.rs @@ -2409,6 +2409,26 @@ pub mod builtins { 9.984_369_578_019_572e-6, 1.505_632_735_149_311_6e-7, ]; + // Γ has a simple pole at 0 and at every negative integer, so there is + // no value to return there. The reflection formula below cannot see + // that on its own: `sin(π·x)` is computed from `x` *after* rounding π, + // so `sin(π · -2.0)` is `2.45e-16` rather than `0` and the quotient + // came back as a clean, finite, confidently wrong `6.4e15`. Only + // `Γ(0)` happened to land on an exact zero and error out. + // + // `∞` is the same answer the reflection formula already produced at + // `x = 0` (`π/(0·Γ(1))`), and `eval_f64` rejects any non-finite result + // with `E-EVAL-009` — the code `eval_expr(0^-1)` raises — so the pole + // is reported through the channel callers already handle. `∞` rather + // than `NaN` because `1/Γ` is *entire* with a zero at each pole, so + // `Γ(0)^-1 = 0` is the right value and is what `product_definite`'s + // Γ-quotient form relies on for `Π_{k=0}^{5} k = 0`. The pole is not + // signed — the residues alternate — but no sign is ever consumed: any + // arithmetic on the value other than taking its reciprocal yields a + // non-finite result and refuses. + if x <= 0.0 && x.floor() == x { + return f64::INFINITY; + } if x < 0.5 { // Reflection: Γ(x)Γ(1-x) = π / sin(πx) std::f64::consts::PI / ((std::f64::consts::PI * x).sin() * libm_gamma(1.0 - x)) @@ -2482,6 +2502,41 @@ mod tests { } } + #[test] + fn gamma_has_no_value_at_the_non_positive_integers() { + // Γ has a simple pole at 0, −1, −2, … — `1/Γ` is entire with zeros + // exactly there — so no finite value is correct. The reflection formula + // `π / (sin(πx)·Γ(1−x))` used to return one anyway: `sin(π · −2.0)` + // evaluates to 2.45e-16 rather than 0 in f64, giving Γ(−2) ≈ 6.4e15. + // A non-finite result is what `eval_f64` turns into `E-EVAL-009`. + let reg = PrimitiveRegistry::default_registry(); + for x in [0.0, -1.0, -2.0, -3.0, -10.0, -21.0] { + let got = reg.numeric_f64("gamma", &[x]).unwrap(); + assert!( + !got.is_finite(), + "Γ({x}) returned {got}; the pole has no finite value" + ); + // `1/Γ` is entire and vanishes at every pole, so the reciprocal + // stays usable — `Π_{k=0}^{5} k = 0` is read off exactly this. + assert_eq!(1.0 / got, 0.0, "1/Γ({x}) should be 0"); + } + // Non-integer negatives are ordinary points and must keep their values: + // Γ(−½) = −2√π, Γ(−3/2) = 4√π/3. + let half = reg.numeric_f64("gamma", &[-0.5]).unwrap(); + assert!( + (half + 2.0 * std::f64::consts::PI.sqrt()).abs() < 1e-9, + "Γ(-1/2) = {half}, expected -2√π" + ); + let three_half = reg.numeric_f64("gamma", &[-1.5]).unwrap(); + assert!( + (three_half - 4.0 * std::f64::consts::PI.sqrt() / 3.0).abs() < 1e-9, + "Γ(-3/2) = {three_half}, expected 4√π/3" + ); + // …and the positive side is untouched: Γ(5) = 4! = 24. + let five = reg.numeric_f64("gamma", &[5.0]).unwrap(); + assert!((five - 24.0).abs() < 1e-9, "Γ(5) = {five}, expected 24"); + } + #[test] fn diff_forward_sin() { let reg = PrimitiveRegistry::default_registry(); diff --git a/alkahest-core/src/simplify/engine.rs b/alkahest-core/src/simplify/engine.rs index 22705c37..710a208d 100644 --- a/alkahest-core/src/simplify/engine.rs +++ b/alkahest-core/src/simplify/engine.rs @@ -3,7 +3,7 @@ use super::rules::{ MulOne, MulZero, PowOne, PowZero, PrimitiveFold, RewriteRule, SqrtInteger, SubSelf, }; use super::rulesets::PatternRuleSet; -use crate::deriv::log::{DerivationLog, DerivedExpr}; +use crate::deriv::log::{DerivationLog, DerivedExpr, RewriteStep}; use crate::kernel::{ExprData, ExprId, ExprPool}; use std::collections::HashMap; @@ -359,12 +359,23 @@ fn is_sorted(args: &[ExprId]) -> bool { // --------------------------------------------------------------------------- /// Simplify `expr` with a custom rule set and config. +/// +/// With `config.expand` set, a bounded-expansion rule that *declines* — the +/// power was too large to distribute — contributes a step to the returned log +/// naming the bound it hit ([`crate::simplify::rules`]'s +/// `expand_pow_limit_reached`). A rule that fires records a step; one that +/// silently does nothing leaves `.steps` describing a derivation that is not +/// what happened, and the caller holding an unexpanded expression with no +/// indication why. pub fn simplify_with( expr: ExprId, pool: &ExprPool, rules: &[Box], config: SimplifyConfig, ) -> DerivedExpr { + if config.expand { + crate::simplify::rules::clear_expand_limits(); + } let mut current = DerivedExpr::new(expr); for _ in 0..config.max_iterations { // Cooperative budget checkpoint, once per full bottom-up pass (P1 @@ -390,6 +401,10 @@ pub fn simplify_with( current = DerivedExpr::with_log(result.value, merged_log); } + if config.expand { + current = DerivedExpr::with_log(current.value, current.log.merge(expand_limit_log())); + } + let mut assumptions = config.assumptions; // Static symbol domains (e.g. Domain::Positive) authorize the same // conditional rewrites as explicit AssumptionContext facts. @@ -402,6 +417,25 @@ pub fn simplify_with( current } +/// One step per power a bounded-expansion rule declined to unfold in the pass +/// that just finished. +/// +/// The step is a no-op rewrite (`before == after`) on purpose: nothing changed, +/// and that *is* the record. It cannot be emitted from the rule itself — +/// [`apply_rules`] treats "a rule fired" as "restart the loop", so a step with +/// an unchanged value there would spin forever. +pub(crate) fn expand_limit_log() -> DerivationLog { + let mut log = DerivationLog::new(); + for (node, _exp, _summands) in crate::simplify::rules::take_expand_limits() { + log.push(RewriteStep::simple( + crate::simplify::rules::EXPAND_POW_LIMIT_RULE, + node, + node, + )); + } + log +} + /// Simplify `expr` using a [`PatternRuleSet`] (discrimination-net indexed). pub fn simplify_with_pattern_rules( expr: ExprId, @@ -677,6 +711,50 @@ mod tests { assert_eq!(r.value, x); } + /// A bounded expansion that declines leaves a step saying so, so `.steps` + /// records what happened rather than an empty derivation next to an + /// unexpanded answer. + #[test] + fn declined_expansion_is_recorded_in_the_derivation_log() { + let pool = p(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let z = pool.symbol("z", Domain::Real); + let base = pool.add(vec![x, y, z]); + let expr = pool.pow(base, pool.integer(9_i32)); // 3^9 products, over budget + + let r = simplify_expanded(expr, &pool); + assert_eq!(r.value, expr, "the expansion really was declined"); + let limits: Vec<_> = r + .log + .steps() + .iter() + .filter(|s| s.rule_name == crate::simplify::rules::EXPAND_POW_LIMIT_RULE) + .collect(); + assert_eq!(limits.len(), 1, "{:?}", r.log.steps()); + assert_eq!(limits[0].before, expr); + assert_eq!(limits[0].after, expr); + } + + /// The control: an expansion the budget allows produces no limit step, so + /// the note cannot be passed by emitting it unconditionally. `(x+y)⁶` also + /// pins the raised bound — the old exponent-only cap refused it. + #[test] + fn an_expansion_within_the_budget_records_no_limit_step() { + let pool = p(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let expr = pool.pow(pool.add(vec![x, y]), pool.integer(6_i32)); + + let r = simplify_expanded(expr, &pool); + assert_ne!(r.value, expr, "(x+y)^6 is inside the budget"); + assert!(!r + .log + .steps() + .iter() + .any(|s| s.rule_name == crate::simplify::rules::EXPAND_POW_LIMIT_RULE)); + } + #[test] fn simplify_idempotent_on_already_simple() { let pool = p(); diff --git a/alkahest-core/src/simplify/parallel.rs b/alkahest-core/src/simplify/parallel.rs index b61b59d0..f8db2a47 100644 --- a/alkahest-core/src/simplify/parallel.rs +++ b/alkahest-core/src/simplify/parallel.rs @@ -36,7 +36,7 @@ //! rather than the main thread's 8 MiB, so a deep chain used to abort the whole //! process with a stack overflow. The recursion now measures how much stack it //! has consumed and continues on a freshly spawned thread with a larger stack -//! before running out; see [`with_stack_segment`]. +//! before running out (see the private `with_stack_segment` helper). //! //! # Safety //! @@ -58,7 +58,7 @@ use std::sync::Arc; /// Arity threshold above which children are simplified in parallel. const PAR_THRESHOLD: usize = 4; -/// Stack size handed to each refill thread (see [`with_stack_segment`]). +/// Stack size handed to each refill thread by `with_stack_segment`. const SEGMENT_STACK_BYTES: usize = 16 * 1024 * 1024; /// Stack the traversal may consume on a thread it did not create. Rayon @@ -162,7 +162,13 @@ fn simplify_node_par( let (current, rule_log) = crate::simplify::engine::apply_rules(rebuilt, pool, rules.as_ref()); - DerivedExpr::with_log(current, child_log.merge(rule_log)) + // Drain the bounded-expansion declines *here*, on whichever thread just + // ran the rules. The record is thread-local, so the sequential path's + // trick of draining once per pass in the caller would collect nothing + // from a rayon worker — and a decline that reaches no log is exactly the + // silent no-op the product budget exists to prevent. + let limit_log = crate::simplify::engine::expand_limit_log(); + DerivedExpr::with_log(current, child_log.merge(rule_log).merge(limit_log)) }); memo.insert(expr, result.value); @@ -671,4 +677,40 @@ mod tests { let seq = simplify(expr, &pool); assert_eq!(par.value, seq.value); } + + /// A declined expansion must reach the log on the parallel path too. + /// + /// `apply_rules` runs on a rayon worker, and the decline record is + /// thread-local, so draining once in the caller (as the sequential pass + /// does) collects nothing. Without the per-worker drain this returns the + /// power unchanged with an empty log — a silent no-op, which is the exact + /// failure the product budget was added to prevent. + #[test] + fn parallel_expansion_declines_are_recorded() { + let pool = p(); + let vars: Vec<_> = (0..4) + .map(|i| pool.symbol(format!("v{i}"), Domain::Complex)) + .collect(); + let sum = pool.add(vars.clone()); + let twelve = pool.integer(12); + let big = pool.pow(sum, twelve); + + let config = SimplifyConfig { + expand: true, + ..SimplifyConfig::default() + }; + let out = simplify_par_with_config(big, &pool, &config); + + assert_eq!( + out.value, big, + "the power is over budget, so it must not expand" + ); + assert!( + out.log + .steps() + .iter() + .any(|s| s.rule_name == crate::simplify::rules::EXPAND_POW_LIMIT_RULE), + "the decline must be recorded, not silent" + ); + } } diff --git a/alkahest-core/src/simplify/redex.rs b/alkahest-core/src/simplify/redex.rs index 51f5f4eb..4ae2e914 100644 --- a/alkahest-core/src/simplify/redex.rs +++ b/alkahest-core/src/simplify/redex.rs @@ -107,6 +107,9 @@ pub fn simplify_redex_with_config( pool: &ExprPool, config: &SimplifyConfig, ) -> DerivedExpr { + if config.expand { + crate::simplify::rules::clear_expand_limits(); + } let rules = rules_for_config(config); let mut current = expr; let mut full_log = DerivationLog::new(); @@ -120,6 +123,12 @@ pub fn simplify_redex_with_config( current = value; } + if config.expand { + // Same as `simplify_with`: a bound that stopped an expansion is part of + // what happened, and belongs in the log rather than nowhere. + full_log = full_log.merge(crate::simplify::engine::expand_limit_log()); + } + // Assumption-driven (colored e-graph) pass, mirroring `simplify_with`. let mut assumptions = config.assumptions.clone(); super::assumptions::collect_static_domain_facts(current, pool, &mut assumptions); @@ -424,6 +433,29 @@ mod tests { assert_eq!(simplify_redex(expr, &pool).value, expected); } + /// The redex engine reports a declined expansion the same way + /// `simplify_with` does — the bound is a fact about the pass, not about + /// which engine ran it. + #[test] + fn expand_limit_is_recorded_by_the_redex_engine() { + let pool = p(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let z = pool.symbol("z", Domain::Real); + let expr = pool.pow(pool.add(vec![x, y, z]), pool.integer(9_i32)); + let config = SimplifyConfig { + expand: true, + ..Default::default() + }; + let r = simplify_redex_with_config(expr, &pool, &config); + assert_eq!(r.value, expr); + assert!(r + .log + .steps() + .iter() + .any(|s| s.rule_name == crate::simplify::rules::EXPAND_POW_LIMIT_RULE)); + } + #[test] fn expand_matches_sequential() { let pool = p(); diff --git a/alkahest-core/src/simplify/rules.rs b/alkahest-core/src/simplify/rules.rs index 0331229a..bf10129d 100644 --- a/alkahest-core/src/simplify/rules.rs +++ b/alkahest-core/src/simplify/rules.rs @@ -1357,16 +1357,80 @@ impl RewriteRule for ExpandMul { // product whose factors `collect_mul_factors` may merge (`a·a → a²`) without // ever reconstructing the original `(Add)^n`, so the fixed point is reached. // -// The exponent is capped (`MAX_EXPAND_POW_EXP`) so a stray large literal -// exponent cannot trigger combinatorial blow-up; powers above the cap are left -// untouched. +// The work is capped so a stray large literal exponent cannot trigger +// combinatorial blow-up — but a cap that is silently a no-op is its own defect, +// so declining is *recorded* (`take_expand_limits`) and surfaces as a step in +// the derivation log. // --------------------------------------------------------------------------- -/// Maximum literal exponent that [`ExpandPow`] will unfold. A binomial squared -/// is the dominant DCM case; cap at 4 to bound term growth while still covering -/// realistic hand-entered polynomials. +/// Exponent below which [`ExpandPow`] always unfolds, whatever the width of the +/// base. +/// +/// Historically the *whole* bound: exponent ≤ 4, any number of summands. Kept as +/// a floor so nothing that expanded before stops expanding, but it is a poor +/// bound on its own — it permits `(a₁+…+a₂₀)⁴` (160 000 products) while refusing +/// `(x+y)⁵` (32). [`MAX_EXPAND_POW_PRODUCTS`] is the bound that actually +/// describes the work. const MAX_EXPAND_POW_EXP: u32 = 4; +/// Maximum number of distributed products [`ExpandPow`] will form: a base of +/// `m` summands raised to `n` produces `mⁿ` of them before like terms are +/// collected. +/// +/// Raising the old exponent-only cap was cheap for the shapes that matter — a +/// binomial now expands to the 12th power (4096 products) where it used to stop +/// at the 4th, and `(x+y+1)⁷` (2187) where it used to stop at `(x+y+1)⁴`. +/// Measured on `simplify_expanded` (release build): the whole pass — distribute, +/// collect, constant-fold — costs 1.2 ms at 64 products, 24 ms at 1024 and +/// ~100 ms at this budget, i.e. roughly linear in the product count with the +/// *collection* cost (the `n^5.7` term the 3.8 performance audit measured for +/// this route) taking over at the top end. That is the reason not to go higher. +/// +/// Beyond it the honest answer is `poly_normal`, which is `n^2.1` for the same +/// result; the recorded log step says so rather than leaving the caller with an +/// unexpanded expression and no explanation. +const MAX_EXPAND_POW_PRODUCTS: u64 = 4096; + +/// How many declined powers one pass will report. A pass that hits the bound +/// on hundreds of distinct nodes has said everything useful in the first few, +/// and the cap bounds the recorder on engines that run rules without draining +/// it (`simplify_par`'s rayon workers each have their own copy of this +/// thread-local, and no one collects theirs). +const MAX_RECORDED_EXPAND_LIMITS: usize = 64; + +thread_local! { + /// Powers [`ExpandPow`] declined to unfold on this thread, with the size it + /// would have taken, newest last and de-duplicated by node. + /// + /// The engine's contract is `Option<(ExprId, DerivationLog)>` — a rule that + /// changes nothing returns `None` and contributes no step, and it cannot + /// return a step *with* `before == after` because `apply_rules` would then + /// spin on it forever. So the note travels out of band and + /// [`crate::simplify::engine::simplify_with`] appends it to the log of the + /// pass that declined, which is where `.steps` can show it. + static DECLINED_EXPANSIONS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Forget any recorded declines (start of an expanding simplify pass). +pub(crate) fn clear_expand_limits() { + DECLINED_EXPANSIONS.with(|c| c.borrow_mut().clear()); +} + +/// Take the powers this pass declined to expand, as `(node, exponent, summands)`. +pub(crate) fn take_expand_limits() -> Vec<(ExprId, u32, usize)> { + DECLINED_EXPANSIONS.with(|c| std::mem::take(&mut *c.borrow_mut())) +} + +/// The rule name carried by the derivation step that reports a declined +/// expansion. Named for what happened, not for a rewrite that did not. +pub(crate) const EXPAND_POW_LIMIT_RULE: &str = "expand_pow_limit_reached"; + +/// Number of distributed products `(m summands)^n` would form, saturating. +fn expansion_products(summands: usize, exp: u32) -> u64 { + (summands as u64).checked_pow(exp).unwrap_or(u64::MAX) +} + pub struct ExpandPow; impl ExpandPow { @@ -1406,7 +1470,20 @@ impl RewriteRule for ExpandPow { return None; } let n_u32 = n.to_u32()?; - if n_u32 > MAX_EXPAND_POW_EXP { + if n_u32 > MAX_EXPAND_POW_EXP + && expansion_products(summands.len(), n_u32) > MAX_EXPAND_POW_PRODUCTS + { + // Declining is a decision about *this* expression, and a caller who + // asked for expansion and got their input back deserves to be told + // which bound stopped it — otherwise the rule is a silent no-op and + // `.steps` records a derivation that never mentions the step it + // refused to take. + DECLINED_EXPANSIONS.with(|c| { + let mut v = c.borrow_mut(); + if v.len() < MAX_RECORDED_EXPAND_LIMITS && !v.iter().any(|&(e, _, _)| e == expr) { + v.push((expr, n_u32, summands.len())); + } + }); return None; } @@ -1561,6 +1638,43 @@ mod tests { ExprPool::new() } + // --- ExpandPow bound --- + + /// `(x+y)⁶` is 64 products — well inside the budget, and outside the old + /// exponent-only cap of 4, which refused it while happily expanding a + /// twenty-term sum to the fourth power (160 000 products). + #[test] + fn expand_pow_unfolds_past_the_old_exponent_cap_when_the_work_is_small() { + let pool = p(); + clear_expand_limits(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let base = pool.add(vec![x, y]); + let expr = pool.pow(base, pool.integer(6_i32)); + let (after, log) = ExpandPow.apply(expr, &pool).expect("within the budget"); + assert_ne!(after, expr); + assert_eq!(log.steps()[0].rule_name, "expand_pow"); + assert!(take_expand_limits().is_empty(), "nothing was declined"); + } + + /// Above the budget the rule still declines — but it says so. The silent + /// no-op was the defect: the caller got their input back with no indication + /// that a bound, rather than the mathematics, stopped the expansion. + #[test] + fn expand_pow_records_the_bound_it_declined() { + let pool = p(); + clear_expand_limits(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let z = pool.symbol("z", Domain::Real); + let base = pool.add(vec![x, y, z]); + let expr = pool.pow(base, pool.integer(9_i32)); // 3^9 = 19 683 products + assert!(ExpandPow.apply(expr, &pool).is_none()); + assert_eq!(take_expand_limits(), vec![(expr, 9, 3)]); + // Consuming: the same decline is not reported twice. + assert!(take_expand_limits().is_empty()); + } + // --- AddZero --- #[test] diff --git a/alkahest-core/src/solver/homotopy.rs b/alkahest-core/src/solver/homotopy.rs index f882e252..5c7bc1f3 100644 --- a/alkahest-core/src/solver/homotopy.rs +++ b/alkahest-core/src/solver/homotopy.rs @@ -5,9 +5,12 @@ //! for generic dense systems. //! //! **Polyhedral (BKK):** for 2-variable systems where the mixed volume is below the -//! Bézout bound (e.g. Katsura family), [`polyhedral`] supplies binomial start systems -//! and exact start points. The homotopy `H = (1−t)·G_cell(z) + t·F(z)` is then -//! tracked with the same Euler-Newton predictor-corrector. +//! Bézout bound (e.g. Katsura family), [`polyhedral`] is meant to supply binomial start +//! systems and exact start points, tracked by `H = (1−t)·G_cell(z) + t·F(z)` with the +//! same Euler-Newton predictor-corrector. Its mixed-cell enumeration is currently +//! broken (see the TODO in [`polyhedral`]) and yields no start points, so +//! [`solve_numerical`] checks the supplied path count against the mixed volume and +//! falls back to the Bézout start whenever it comes up short. //! //! Endpoints are Newton-polished in ℝⁿ and checked with a conservative Smale //! heuristic plus `ArbBall` enclosures. @@ -780,12 +783,18 @@ fn dedup(points: &[Vec], tol: f64) -> Vec> { /// Total-degree or polyhedral-BKK continuation + polishing + Smale / ArbBall packaging. /// /// For 2-variable systems where the BKK mixed volume is strictly below the Bézout bound -/// (e.g. Katsura family), polyhedral homotopy is used automatically. The path budget -/// is checked against the mixed volume in that case. For all other systems, the standard -/// total-degree (Bézout) start is used. +/// (e.g. Katsura family), polyhedral homotopy is attempted first; if the mixed-cell +/// decomposition supplies fewer start points than the mixed volume — as the +/// [`polyhedral`] module's cell enumeration does today, where it supplies none — the +/// run falls back to the total-degree (Bézout) start rather than reporting the paths +/// it could not track as an absence of solutions. The path budget is checked against +/// whichever count is used. /// /// Returns **real projections** whose imaginary tails were negligible; complex -/// roots with large imaginary part are discarded. +/// roots with large imaginary part are discarded. An empty result therefore means +/// "no real solution was found among the paths that arrived"; if *no* path arrives at +/// all, `E-HOMOTOPY-004` is raised instead, so an empty list is never a stand-in for a +/// tracker that failed outright. pub fn solve_numerical( equations: &[ExprId], vars: &[ExprId], @@ -815,38 +824,62 @@ pub fn solve_numerical( let prec = opts.certify_prec_bits; const SMALE_THRESH: f64 = 0.125; let mut raw: Vec> = Vec::new(); + // A path that never reached `t = 1` yields nothing, and "nothing" is + // indistinguishable from "no solution on this path". Counting the paths + // that did arrive is what lets an empty result be told apart from a + // tracker that failed everywhere. + let mut paths_completed = 0usize; + let mut paths_started = 0usize; + let mut used_polyhedral = false; if polyhedral::should_use_polyhedral(&sys) { - // BKK bound is strictly below Bézout — use polyhedral mixed-cell starts. + // BKK bound is strictly below Bézout — try polyhedral mixed-cell starts. let mv = polyhedral::mixed_volume(&sys).unwrap_or(bez); if mv > opts.max_bezout_paths { return Err(HomotopyError::BezoutTooLarge(mv)); } - for (start_sys, cell_starts) in polyhedral::polyhedral_cell_iter(&sys[0], &sys[1]) { - for z0 in cell_starts { - let z_end = match track_path_sys(&sys, &start_sys, z0, opts) { - Ok(z) => z, - Err(_) => continue, - }; - if z_end.iter().all(|c| c.im.abs() < 1e-6) { - let xr: Vec = z_end.iter().map(|c| c.re).collect(); - if let Some(xp) = newton_terminal(&sys, xr, opts) { - raw.push(xp); + let cells = polyhedral::polyhedral_cell_iter(&sys[0], &sys[1]); + let n_starts: usize = cells.iter().map(|(_, s)| s.len()).sum(); + // A polyhedral run is only a valid substitute for the Bézout run when + // it supplies at least `mv` paths; fewer start points cannot reach + // every isolated root, and the missing ones would be reported as an + // empty solution set — a mathematical claim, not a diagnostic. + // `polyhedral_cell_iter` currently supplies *none* (its mixed-cell + // criterion selects exactly the edge pairs its binomial solver + // rejects; see the module TODO), so this fallback fires every time. + if n_starts >= mv && mv > 0 { + used_polyhedral = true; + for (start_sys, cell_starts) in cells { + for z0 in cell_starts { + paths_started += 1; + let z_end = match track_path_sys(&sys, &start_sys, z0, opts) { + Ok(z) => z, + Err(_) => continue, + }; + paths_completed += 1; + if z_end.iter().all(|c| c.im.abs() < 1e-6) { + let xr: Vec = z_end.iter().map(|c| c.re).collect(); + if let Some(xp) = newton_terminal(&sys, xr, opts) { + raw.push(xp); + } } } } } - } else { + } + if !used_polyhedral { if bez > opts.max_bezout_paths { return Err(HomotopyError::BezoutTooLarge(bez)); } let starts = start_system_roots(°s); let gamma = random_gamma(opts.gamma_angle_seed); for z0 in starts { + paths_started += 1; let z_end = match track_path(gamma, &sys, °s, z0, opts) { Ok(z) => z, Err(_) => continue, }; + paths_completed += 1; if z_end.iter().all(|c| c.im.abs() < 1e-6) { let xr: Vec = z_end.iter().map(|c| c.re).collect(); if let Some(xp) = newton_terminal(&sys, xr, opts) { @@ -855,6 +888,11 @@ pub fn solve_numerical( } } } + if paths_started > 0 && paths_completed == 0 { + return Err(HomotopyError::TrackerFailed( + "no continuation path reached t = 1", + )); + } let uniq = dedup(&raw, opts.dedup_tol); let mut out = Vec::new(); for x in uniq { @@ -913,6 +951,48 @@ mod tests { assert!(sols.iter().all(|s| s.max_residual_f64 < 1e-8)); } + /// Systems the mixed volume routes away from the Bézout start must still + /// produce their solutions. `x²y − 1, xy² − 2` has MV 3 against a Bézout + /// bound of 9, so it takes the polyhedral branch — which supplies no start + /// points at all and used to hand back an empty list, i.e. the claim that + /// a system with an obvious real solution has none. + #[test] + fn polyhedral_routed_system_still_finds_its_root() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + // x²y − 1 + let eq1 = pool.add(vec![ + pool.mul(vec![pool.pow(x, pool.integer(2)), y]), + pool.integer(-1), + ]); + // xy² − 2 + let eq2 = pool.add(vec![ + pool.mul(vec![x, pool.pow(y, pool.integer(2))]), + pool.integer(-2), + ]); + let sys = [ + expr_to_gbpoly(eq1, &[x, y], &pool).unwrap(), + expr_to_gbpoly(eq2, &[x, y], &pool).unwrap(), + ]; + assert!( + polyhedral::should_use_polyhedral(&sys), + "this system is the polyhedral-routed case the test is about", + ); + let opts = HomotopyOpts::default(); + let sols = solve_numerical(&[eq1, eq2], &[x, y], &pool, &opts).expect("solve"); + // x²y = 1 and xy² = 2 ⇒ (x²y)(xy²) = x³y³ = 2 ⇒ xy = 2^{1/3}; + // dividing xy² by x²y gives y/x = 2, so x = 2^{-1/3}, y = 2^{2/3}. + let x0 = 2.0_f64.powf(-1.0 / 3.0); + let y0 = 2.0_f64.powf(2.0 / 3.0); + assert!( + sols.iter() + .any(|s| (s.coordinates[0] - x0).abs() < 1e-6 + && (s.coordinates[1] - y0).abs() < 1e-6), + "expected ({x0}, {y0}) among {sols:?}", + ); + } + #[test] fn circle_line_two_real_roots() { let pool = ExprPool::new(); diff --git a/alkahest-core/src/solver/mod.rs b/alkahest-core/src/solver/mod.rs index a96be128..f273934d 100644 --- a/alkahest-core/src/solver/mod.rs +++ b/alkahest-core/src/solver/mod.rs @@ -20,6 +20,11 @@ //! `ExprPool`; outputs are symbolic `ExprId` values (may include `sqrt`), //! or `SolutionSet::Parametric` / `SolutionSet::NoSolution`. //! +//! Candidate tuples are checked against the input equations before they are +//! returned — see [`solve_polynomial_system`]'s post-condition and the +//! `verify` module. Verifying is far cheaper than solving, and a returned +//! solution that does not satisfy the system is always a bug. +//! //! Free symbols that appear in the equations but are not listed in `vars` are //! treated as **parameters**: they become extra indeterminates in the Gröbner //! basis (appended after the solve variables under Lex) and are pre-bound to @@ -31,6 +36,7 @@ pub mod homotopy; pub mod polyhedral; pub mod regular_chains; pub mod transcendental; +mod verify; pub use transcendental::{solve_transcendental, TranscendentalOutcome}; @@ -46,6 +52,7 @@ use crate::errors::AlkahestError; use crate::kernel::{ExprData, ExprId, ExprPool}; use crate::poly::collect_free_vars; use crate::poly::groebner::{GbPoly, GroebnerBasis, MonomialOrder}; +use rug::ops::Pow; use rug::Rational; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -62,8 +69,17 @@ pub type Solution = Vec; /// The result of `solve_polynomial_system`. pub enum SolutionSet { /// Finitely many solutions (each is a `Vec` parallel to `vars`). + /// + /// Every tuple has survived substitution back into the input equations, so + /// a returned solution is never one the solver can itself refute. Finite(Vec), - /// Infinitely many solutions; the Gröbner basis is returned for downstream use. + /// **No finite solution list was produced**; the Gröbner basis is returned + /// for downstream use. + /// + /// The usual reason is a positive-dimensional ideal. It is also what the + /// solver reports when the basis admits no complete triangular + /// elimination in the declared unknowns, so this is "here is the ideal, + /// enumerate it yourself" rather than a claim that solutions are infinite. Parametric(GroebnerBasis), /// No solution (ideal = ⟨1⟩). NoSolution, @@ -286,9 +302,69 @@ fn div_expr(num: ExprId, den: ExprId, pool: &ExprPool) -> ExprId { pool.mul(vec![num, inv_den]) } -/// Is this ExprId structurally the integer zero? -fn is_syntactic_zero(e: ExprId, pool: &ExprPool) -> bool { - pool.with(e, |d| matches!(d, ExprData::Integer(n) if n.0 == 0)) +/// Is this `ExprId` certainly zero, by structure or by exact rational value? +fn is_zero_value(e: ExprId, pool: &ExprPool) -> bool { + is_certain_zero(e, pool) || rational_value(e, pool).is_some_and(|v| v == 0) +} + +/// Exact rational value of `expr`, or `None` when it is not a rational +/// arithmetic expression (a radical, a parameter, a division by zero). +/// +/// The expression pool does not fold arithmetic on literals — `0 · 4 · 1` and +/// `(−2)²` both survive as nodes — so a vanishing discriminant reaches +/// [`solve_univariate_symbolic`] unrecognisable by structure alone. Evaluating +/// the handful of node kinds the solver builds costs nothing and decides it +/// exactly, which is what turns `±√0/2` back into the single root it is. +fn rational_value(expr: ExprId, pool: &ExprPool) -> Option { + match pool.get(expr) { + ExprData::Integer(n) => Some(Rational::from(n.0.clone())), + ExprData::Rational(r) => Some(r.0.clone()), + ExprData::Add(args) => args.iter().try_fold(Rational::from(0), |acc, &a| { + Some(acc + rational_value(a, pool)?) + }), + ExprData::Mul(args) => args.iter().try_fold(Rational::from(1), |acc, &a| { + Some(acc * rational_value(a, pool)?) + }), + ExprData::Pow { base, exp } => { + let ExprData::Integer(k) = pool.get(exp) else { + return None; + }; + let k = k.0.to_i32()?; + let b = rational_value(base, pool)?; + if k < 0 && b == 0 { + return None; + } + Some(b.pow(k)) + } + _ => None, + } +} + +/// Is this `ExprId` **certainly** zero? +/// +/// Recognises the shapes back-substitution actually produces without invoking +/// the simplifier: a literal zero, a sum of zeros, `√0`, and `0^k` for `k > 0`. +/// The last two matter because a vanishing discriminant arrives as `0² + 0` +/// rather than as `0`, and a plain literal test then reported the double root +/// of `x² = 0` as the two entries `±√0/2`. +/// +/// One-sided by design: `false` means "not recognised as zero", never "known +/// non-zero". Products are deliberately not folded — a zero factor does not +/// make `0 · 0⁻¹` zero. +fn is_certain_zero(e: ExprId, pool: &ExprPool) -> bool { + match pool.get(e) { + ExprData::Integer(n) => n.0 == 0, + ExprData::Rational(r) => r.0 == 0, + ExprData::Add(args) => args.iter().all(|&a| is_certain_zero(a, pool)), + ExprData::Pow { base, exp } => { + let positive = matches!(pool.get(exp), ExprData::Integer(k) if k.0 > 0); + positive && is_certain_zero(base, pool) + } + ExprData::Func { name, args } if name == "sqrt" && args.len() == 1 => { + is_certain_zero(args[0], pool) + } + _ => false, + } } /// Extract the coefficient of `var_idx^k` in `poly`, substituting @@ -359,16 +435,21 @@ fn extract_coeff_in_var( /// Solve `a₀ + a₁·x + a₂·x² = 0` where each `aᵢ` is an already-substituted /// `ExprId`. Returns a `Vec` of roots (symbolic). Degree is /// inferred from `coeffs.len()`; higher-degree terms must be syntactic-zero -/// (the caller trims first). A degree-2 equation always yields two roots -/// (symbolically distinct even if discriminant = 0; the caller can dedupe -/// numerically if desired). +/// (the caller trims first). +/// +/// A degree-2 equation yields **one** root when the discriminant collapses to +/// a syntactic zero and two otherwise. `x² = 0` has the solution *set* `{0}`; +/// reporting `±√0/2` as two entries was a wrong count, not a multiplicity +/// annotation, and it multiplied across variables (`[x², y², z²]` reported +/// eight copies of the origin). Roots that coincide for a subtler reason are +/// collapsed later by the numeric de-duplication in [`refine_solutions`]. fn solve_univariate_symbolic( coeffs: &[ExprId], pool: &ExprPool, ) -> Result, SolverError> { let mut degree = 0usize; for (i, &c) in coeffs.iter().enumerate() { - if !is_syntactic_zero(c, pool) { + if !is_zero_value(c, pool) { degree = i; } } @@ -396,10 +477,13 @@ fn solve_univariate_symbolic( let four_ac = pool.mul(vec![four, a, c]); let neg_four_ac = neg_expr(four_ac, pool); let disc = pool.add(vec![b2, neg_four_ac]); - let sqrt_disc = pool.func("sqrt", vec![disc]); let two_b = pool.integer(rug::Integer::from(2)); let two_a = pool.mul(vec![two_b, a]); let neg_b = neg_expr(b, pool); + if is_zero_value(disc, pool) { + return Ok(vec![div_expr(neg_b, two_a, pool)]); + } + let sqrt_disc = pool.func("sqrt", vec![disc]); let root_plus = div_expr(pool.add(vec![neg_b, sqrt_disc]), two_a, pool); let neg_sqrt = neg_expr(sqrt_disc, pool); let root_minus = div_expr(pool.add(vec![neg_b, neg_sqrt]), two_a, pool); @@ -413,10 +497,19 @@ fn solve_univariate_symbolic( // Main solver // --------------------------------------------------------------------------- -/// Return the set of variable indices that actually appear (with positive -/// exponent in any term) in `poly`. -fn active_vars(poly: &GbPoly, n_vars: usize) -> Vec { - (0..n_vars) +/// Highest power of `var_idx` occurring in `poly`. +fn max_degree_in_var(poly: &GbPoly, var_idx: usize) -> u32 { + poly.terms + .keys() + .map(|e| e.get(var_idx).copied().unwrap_or(0)) + .max() + .unwrap_or(0) +} + +/// Solve-variable indices that occur in `poly` (parameters are ignored: they +/// are pre-bound and never block a step). +fn active_solve_vars(poly: &GbPoly, n_solve: usize) -> Vec { + (0..n_solve) .filter(|&i| { poly.terms .keys() @@ -425,53 +518,202 @@ fn active_vars(poly: &GbPoly, n_vars: usize) -> Vec { .collect() } -fn max_degree_in_var(poly: &GbPoly, var_idx: usize) -> u32 { - poly.terms - .keys() - .map(|e| e.get(var_idx).copied().unwrap_or(0)) - .max() - .unwrap_or(0) +// --------------------------------------------------------------------------- +// Assumed hypotheses, reported out of band +// --------------------------------------------------------------------------- + +thread_local! { + /// Leading coefficients the back-solver divided by without being able to + /// prove them non-zero, for the [`solve_polynomial_system`] call in + /// progress. De-duplicated, in the order they were assumed. + static ASSUMED_NONZERO: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; } -/// Given the current partial assignment, find a generator solvable in -/// exactly one unsolved variable. Returns `(var_idx, gen, max_deg)`. -fn find_solvable<'a>( - gens: &'a [GbPoly], - assigned: &[Option], - n_vars: usize, -) -> Option<(usize, &'a GbPoly, u32)> { +/// Record that the solver divided by `lead` without deciding it is non-zero. +fn assume_nonzero(lead: ExprId) { + ASSUMED_NONZERO.with(|c| { + let mut v = c.borrow_mut(); + if !v.contains(&lead) { + v.push(lead); + } + }); +} + +/// The hypotheses the solutions from the most recent [`solve_polynomial_system`] +/// call on this thread rest on, as [`crate::deriv::SideCondition::NonZero`]. +/// +/// `solve([a·x − b], [x])` returns `b/a`, which is the answer **for `a ≠ 0`**: +/// at `a = 0` the equation is `−b = 0`, so there is either no solution (`b ≠ 0`) +/// or every `x` (`b = 0`), and neither is `b/a`. The generic-parameter reading +/// is a deliberate and useful one, but a caller cannot audit an assumption that +/// is never stated — and a parametric tuple is returned *unverified* by design +/// (it is not a number, so the post-condition filter has nothing to substitute), so +/// this is the only honest signal available on that path. +/// +/// # Why out of band +/// +/// [`SolutionSet`] is a public *exhaustive* enum and `solve_polynomial_system`'s +/// return type is public, so neither can grow a conditions field without a major +/// semver break. The hypotheses therefore travel beside the result, in the shape +/// `DerivedResult.verification["side_conditions"]` already uses — the same +/// treatment `zeilberger`'s natural-boundary hypothesis was given, and the same +/// out-of-band channel as [`crate::matrix::take_zero_test_refusal`]. +/// +/// Consuming, so one call's hypotheses cannot be read as a later call's. Empty +/// means the solver proved every coefficient it divided by to be non-zero — not +/// that it did not look. +pub fn take_solve_side_conditions() -> Vec { + ASSUMED_NONZERO.with(|c| { + std::mem::take(&mut *c.borrow_mut()) + .into_iter() + .map(crate::deriv::log::SideCondition::NonZero) + .collect() + }) +} + +/// Can the degree-`d` coefficient be relied on to be non-zero at this partial +/// assignment? +/// +/// This is the property that makes one back-substitution step *complete*: if +/// the leading coefficient does not vanish, the substituted generator really +/// has degree `d` in the unknown and the quadratic formula returns **all** of +/// its roots. When it does vanish, the same formula divides by zero and the +/// branch's true roots disappear — which is how `⟨x² + 3y, 2xy + 3x⟩` lost +/// `(±3/√2, −3/2)`: the chosen generator's leading coefficient was `2y + 3`, +/// zero on exactly the branch `y = −3/2`. +/// +/// A coefficient still mentioning a free parameter is accepted, preserving the +/// documented generic-parameter reading of `solve([a·x − b], [x]) → b/a` — but +/// it is accepted as an **assumption**, recorded through [`assume_nonzero`] and +/// reported by [`take_solve_side_conditions`]. The reading is only defensible +/// while the caller can see what was assumed: `b/a` is the solution for `a ≠ 0` +/// and is wrong at `a = 0`, where the system has no solution, or every `x`. +fn leading_is_reliable(lead: ExprId, pool: &ExprPool) -> LeadStatus { + if let Some(v) = rational_value(lead, pool) { + return if v != 0 { + LeadStatus::Nonzero + } else { + LeadStatus::Unusable + }; + } + match verify::CBallEval::default().eval(lead, pool) { + Ok(ball) => { + if ball.excludes_zero() { + LeadStatus::Nonzero + } else { + LeadStatus::Unusable + } + } + // `Unsupported` is the parametric case; `Undefined` is not a usable + // coefficient under any reading. + Err(verify::VerifyGap::Unsupported) => LeadStatus::AssumedNonzero, + Err(verify::VerifyGap::Undefined) => LeadStatus::Unusable, + } +} + +/// What [`leading_is_reliable`] could establish about a leading coefficient. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum LeadStatus { + /// Proved non-zero — the step is unconditional. + Nonzero, + /// Not decidable here (it mentions a free parameter): usable only under the + /// hypothesis that it does not vanish, which the caller must be told about. + AssumedNonzero, + /// Zero, or not a usable coefficient under any reading. + Unusable, +} + +/// One back-substitution step for one partial assignment: which unknown to +/// solve next, and the coefficients of the univariate it satisfies. +/// +/// A generator is usable when every solve variable it mentions except the +/// chosen one is already assigned, and its leading coefficient in that unknown +/// survives [`leading_is_reliable`]. Nothing here depends on the elimination +/// order matching the monomial order, which matters: a Lex basis such as +/// `⟨x² − 2, x·y − y², y³ − 2y⟩` is only tractable by eliminating `x` first — +/// insisting on the Lex-last unknown reaches the cubic `y³ − 2y` and refuses a +/// system that is perfectly within scope. +/// +/// `Err(HighDegree)` is reserved for the case where the *only* obstruction is +/// a degree above 2, which keeps `E-SOLVE-002` meaning what it documents. +fn find_step( + gens: &[GbPoly], + partial: &[Option], + vars: &[ExprId], + n_solve: usize, + pool: &ExprPool, +) -> Result)>, SolverError> { + let mut best: Option<(usize, Vec, u32, Option)> = None; + let mut blocked_by_degree: Option = None; + for g in gens { - let active = active_vars(g, n_vars); - let unsolved: Vec = active - .iter() - .copied() - .filter(|&i| assigned[i].is_none()) + let unassigned: Vec = active_solve_vars(g, n_solve) + .into_iter() + .filter(|&i| partial[i].is_none()) .collect(); - if unsolved.len() == 1 { - let var_idx = unsolved[0]; - let max_deg = max_degree_in_var(g, var_idx); - if max_deg > 0 { - return Some((var_idx, g, max_deg)); + let [var_idx] = unassigned[..] else { + continue; + }; + let deg = max_degree_in_var(g, var_idx); + if deg == 0 { + continue; + } + if deg > 2 { + blocked_by_degree = Some(blocked_by_degree.map_or(deg, |d: u32| d.min(deg))); + continue; + } + if best.as_ref().is_some_and(|(_, _, bd, _)| *bd <= deg) { + continue; + } + let coeffs: Vec = (0..=deg) + .map(|k| extract_coeff_in_var(g, var_idx, k, vars, partial, pool)) + .collect(); + let lead = coeffs[deg as usize]; + let assumed = match leading_is_reliable(lead, pool) { + LeadStatus::Unusable => continue, + LeadStatus::Nonzero => None, + LeadStatus::AssumedNonzero => Some(lead), + }; + best = Some((var_idx, coeffs, deg, assumed)); + } + + match best { + // Only the step actually taken contributes a hypothesis: generators + // that were examined and passed over divide nothing. + Some((var_idx, coeffs, _, assumed)) => { + if let Some(lead) = assumed { + assume_nonzero(lead); } + Ok(Some((var_idx, coeffs))) } + None => match blocked_by_degree { + Some(d) => Err(SolverError::HighDegree(d as usize)), + None => Ok(None), + }, } - None } -/// Lex-order backsolve over a fixed generator list (full Gröbner basis or a -/// triangular subset). +/// Backsolve over a fixed generator list (full Gröbner basis or a triangular +/// subset). enum BacksolveOutcome { Finite(Vec), - /// No triangular step applied (`find_solvable` stuck) — caller may retry a smaller set. + /// Some branch reached a point where no generator determines a remaining + /// unknown — caller may retry a smaller set. Stuck, NoSolution, } -/// Lex-order backsolve over a fixed generator list. +/// Backsolve over a fixed generator list. /// /// `vars` is the full indeterminate list (solve unknowns first, then free /// parameters). `n_solve` is the number of unknowns to assign; indices /// `n_solve..vars.len()` are pre-bound to themselves (parametric coefficients). +/// +/// Each branch picks its own next step (see [`find_step`]), so the candidate +/// set it produces contains every solution of the ideal that the branch's +/// partial assignment is consistent with. Filtering the union back down to +/// the true solutions is [`refine_solutions`]' job. fn try_backsolve_generators( gens: &[GbPoly], vars: &[ExprId], @@ -489,33 +731,37 @@ fn try_backsolve_generators( for _ in 0..n_solve { let mut new_partials = Vec::new(); + let mut high_degree: Option = None; for partial in &partials { - let solvable = find_solvable(gens, partial, n_vars); - let (var_idx, gen, max_deg) = match solvable { - Some(t) => t, - None => return Ok(BacksolveOutcome::Stuck), + let step = match find_step(gens, partial, vars, n_solve, pool) { + Ok(s) => s, + // A degree-blocked branch does not end the level on its own: + // another branch may turn out to be under-determined, and that + // is the refusal worth reporting. If nothing worse turns up, + // the whole solve declines with `E-SOLVE-002` — returning the + // branches that *did* resolve would be an incomplete solution + // set presented as a complete one. + Err(e) => { + high_degree = Some(e); + continue; + } }; - // Only solve unknowns; a generator whose sole unsolved var is a - // parameter should not appear (parameters are pre-assigned). - if var_idx >= n_solve { + let Some((var_idx, coeffs)) = step else { + if partial_is_refuted(gens, partial, n_solve, n_vars, pool) { + // A dead branch, not an under-determined one: drop it. + continue; + } return Ok(BacksolveOutcome::Stuck); - } - if max_deg > 2 { - return Err(SolverError::HighDegree(max_deg as usize)); - } - let coeffs: Vec = (0..=max_deg) - .map(|k| extract_coeff_in_var(gen, var_idx, k, vars, partial, pool)) - .collect(); - let roots = solve_univariate_symbolic(&coeffs, pool)?; - if roots.is_empty() { - continue; - } - for root in roots { + }; + for root in solve_univariate_symbolic(&coeffs, pool)? { let mut np = partial.clone(); np[var_idx] = Some(root); new_partials.push(np); } } + if let Some(e) = high_degree { + return Err(e); + } partials = new_partials; if partials.is_empty() { return Ok(BacksolveOutcome::NoSolution); @@ -535,6 +781,101 @@ fn try_backsolve_generators( Ok(BacksolveOutcome::Finite(solutions)) } +/// Is this partial assignment already inconsistent with a generator all of +/// whose solve variables it binds? +/// +/// Used only to tell "this branch is dead" from "this branch is +/// under-determined"; an undecidable answer is reported as `false`, which is +/// the conservative direction (the caller then declines rather than pruning). +fn partial_is_refuted( + gens: &[GbPoly], + partial: &[Option], + n_solve: usize, + n_vars: usize, + pool: &ExprPool, +) -> bool { + if n_solve != n_vars { + return false; // parameters: no numeric residual to test + } + let mut evaluator = verify::CBallEval::default(); + let mut values: Vec> = Vec::with_capacity(n_vars); + for slot in partial.iter().take(n_vars) { + values.push(match slot { + Some(v) => evaluator.eval(*v, pool).ok(), + None => None, + }); + } + gens.iter() + .any(|g| verify::poly_residual_partial(g, &values).is_some_and(|r| r.excludes_zero())) +} + +/// The solver's post-condition: drop every candidate that provably fails the +/// original system, and collapse candidates that cannot be told apart. +/// +/// Substituting a finished tuple back into the equations costs a handful of +/// ball multiplications — orders of magnitude less than the Gröbner basis that +/// produced it — so it runs unconditionally rather than behind a flag. The +/// test is one-sided by construction (see [`verify`]): a tuple is removed only +/// when its residual ball is *separated* from zero, so a genuine solution can +/// never be filtered out. +/// +/// Tuples containing a free parameter are not numbers and are returned +/// unexamined; parametric solving keeps its generic-value semantics. +fn refine_solutions( + solutions: Vec, + orig_polys: &[GbPoly], + n_vars: usize, + pool: &ExprPool, +) -> Vec { + let mut kept: Vec = Vec::new(); + let mut kept_values: Vec> = Vec::new(); + let mut evaluator = verify::CBallEval::default(); + + for sol in solutions { + let mut values: Vec = Vec::with_capacity(n_vars); + let mut gap = None; + for &v in &sol { + match evaluator.eval(v, pool) { + Ok(b) => values.push(b), + Err(g) => { + gap = Some(g); + break; + } + } + } + match gap { + // A parameter (or any node the checker does not model): nothing can + // be proved, so nothing is claimed — keep it as it was produced. + Some(verify::VerifyGap::Unsupported) => { + kept.push(sol); + continue; + } + // `0/0`, `0^-1`: the tuple denotes no point of ℂⁿ. + Some(verify::VerifyGap::Undefined) => continue, + None => {} + } + // Free parameters occupy the tail of the indeterminate list; a tuple + // that evaluated fully cannot have any, so `values` covers every + // indeterminate the polynomials mention. + if values.len() < n_vars { + kept.push(sol); + continue; + } + if verify::is_refuted(orig_polys, &values) { + continue; + } + if kept_values + .iter() + .any(|prev| verify::same_point(prev, &values)) + { + continue; + } + kept_values.push(values); + kept.push(sol); + } + kept +} + /// Free symbols in `equations` that are not among the declared solve `vars`, /// in stable [`ExprId`] order (via [`collect_free_vars`]'s `BTreeSet`). fn collect_parameters(equations: &[ExprId], vars: &[ExprId], pool: &ExprPool) -> Vec { @@ -561,11 +902,34 @@ fn collect_parameters(equations: &[ExprId], vars: &[ExprId], pool: &ExprPool) -> /// /// Returns a [`SolutionSet`] with symbolic `ExprId` values for each solution /// (parallel to `vars` only — parameters are not included in solution tuples). +/// +/// # Post-condition +/// +/// Every parameter-free tuple in a [`SolutionSet::Finite`] has been substituted +/// back into the **input** equations and survived: its residual could not be +/// separated from zero in rigorous ball arithmetic. Checking costs a few +/// hundred microseconds against a Gröbner basis that is superexponential in the +/// worst case, so it is unconditional rather than opt-in. A tuple whose +/// coordinates are not numbers (a `0/0` produced by a degenerate division) is +/// dropped for the same reason, and tuples that denote the same point are +/// reported once. +/// +/// # Hypotheses +/// +/// A *parametric* tuple is not a number and so cannot be checked at all: it is +/// returned unverified, under whatever non-vanishing assumptions the +/// back-substitution made about leading coefficients that mention free +/// parameters. Those assumptions are not left unsaid — see +/// [`take_solve_side_conditions`], which must be read before the next call on +/// this thread. pub fn solve_polynomial_system( equations: Vec, vars: Vec, pool: &ExprPool, ) -> Result { + // Hypotheses describe *this* call; a caller reading them after it must + // never see one left behind by an earlier solve. + let _ = take_solve_side_conditions(); let n_solve = vars.len(); let params = collect_parameters(&equations, &vars, pool); let mut all_vars = vars; @@ -577,7 +941,7 @@ pub fn solve_polynomial_system( polys.push(expr_to_gbpoly(*eq, &all_vars, pool)?); } - let gb = GroebnerBasis::compute(polys, MonomialOrder::Lex); + let gb = GroebnerBasis::compute(polys.clone(), MonomialOrder::Lex); let gens = gb.generators(); // Trivial ideal ⟨1⟩ → no solution. @@ -588,20 +952,47 @@ pub fn solve_polynomial_system( return Ok(SolutionSet::NoSolution); } + // Candidates are checked against the *input* equations rather than the + // basis: that is the contract the caller stated, and it does not inherit + // any mistake the basis computation might have made. + let finish = |solutions: Vec| -> Option { + let had_candidates = !solutions.is_empty(); + let refined = refine_solutions(solutions, &polys, n_vars, pool); + if had_candidates && refined.is_empty() { + // Over ℂ a proper ideal always has a zero, so a candidate set that + // is entirely refuted means the enumeration itself was unsound. + // Reporting `Finite([])` here would be the worst possible answer — + // "this system has no solutions" — so decline instead. + return None; + } + Some(SolutionSet::Finite(refined)) + }; + match try_backsolve_generators(gens, &all_vars, n_solve, pool)? { - BacksolveOutcome::Finite(solutions) => Ok(SolutionSet::Finite(solutions)), - BacksolveOutcome::NoSolution => Ok(SolutionSet::NoSolution), - BacksolveOutcome::Stuck => { - let chain = extract_regular_chain_from_basis(gens, n_vars, MonomialOrder::Lex); - if chain.polys.is_empty() { - return Ok(SolutionSet::Parametric(gb)); + BacksolveOutcome::Finite(solutions) => { + if let Some(set) = finish(solutions) { + return Ok(set); } - match try_backsolve_generators(&chain.polys, &all_vars, n_solve, pool)? { - BacksolveOutcome::Finite(solutions) => Ok(SolutionSet::Finite(solutions)), - _ => Ok(SolutionSet::Parametric(gb)), + } + BacksolveOutcome::NoSolution => return Ok(SolutionSet::NoSolution), + BacksolveOutcome::Stuck => {} + } + + // The full basis had no complete triangular elimination (or its candidates + // did not survive): retry from a regular chain extracted from the same + // basis. A regular chain lies in the ideal, so its solution set contains + // the true one and the post-condition filter still applies. + let chain = extract_regular_chain_from_basis(gens, n_vars, MonomialOrder::Lex); + if !chain.polys.is_empty() { + if let BacksolveOutcome::Finite(solutions) = + try_backsolve_generators(&chain.polys, &all_vars, n_solve, pool)? + { + if let Some(set) = finish(solutions) { + return Ok(set); } } } + Ok(SolutionSet::Parametric(gb)) } // --------------------------------------------------------------------------- @@ -732,6 +1123,174 @@ mod tests { } } + /// `x^k` as an `ExprId`. + fn powk(pool: &ExprPool, base: ExprId, k: i32) -> ExprId { + pool.pow(base, pool.integer(k)) + } + + fn finite(eqs: Vec, vars: Vec, pool: &ExprPool) -> Vec { + match solve_polynomial_system(eqs, vars, pool).expect("solve") { + SolutionSet::Finite(s) => s, + other => panic!( + "expected a finite solution set, got {}", + match other { + SolutionSet::NoSolution => "NoSolution", + _ => "Parametric", + } + ), + } + } + + #[test] + fn spurious_tuple_is_refuted() { + // x² − xy = 0, xy − y = 0. y(x−1) = 0 forces y = 0 or x = 1; + // y = 0 ⇒ x² = 0 ⇒ x = 0, and x = 1 ⇒ 1 − y = 0 ⇒ y = 1. + // The solution set is exactly {(0,0), (1,1)}. The tuple (−1, 1) has + // residual x² − xy = 1 + 1 = 2 and must never be reported. + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let neg_one = pool.integer(-1_i32); + let eq1 = pool.add(vec![powk(&pool, x, 2), pool.mul(vec![neg_one, x, y])]); + let eq2 = pool.add(vec![pool.mul(vec![x, y]), pool.mul(vec![neg_one, y])]); + let sols = finite(vec![eq1, eq2], vec![x, y], &pool); + assert!(has_numeric_pair(&sols, &pool, &[(0.0, 0.0), (1.0, 1.0)])); + assert_eq!(sols.len(), 2, "exactly two points, got {sols:?}"); + } + + #[test] + fn vanishing_leading_coefficient_branch_is_kept() { + // −3x − 2xy = −x(3 + 2y) = 0 and −3y − x² = 0. + // y = −3/2 kills the first equation for every x, and then + // x² = −3y = 9/2, so (±3/√2, −3/2) are solutions; (0,0) is the third. + // The old back-solver divided by the leading coefficient 2y + 3, which + // is zero on exactly that branch, and lost both of them. + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let eq1 = pool.add(vec![ + pool.mul(vec![pool.integer(-3_i32), x]), + pool.mul(vec![pool.integer(-2_i32), x, y]), + ]); + let eq2 = pool.add(vec![ + pool.mul(vec![pool.integer(-3_i32), y]), + pool.mul(vec![pool.integer(-1_i32), powk(&pool, x, 2)]), + ]); + let sols = finite(vec![eq1, eq2], vec![x, y], &pool); + let r = (4.5_f64).sqrt(); + assert!(has_numeric_pair( + &sols, + &pool, + &[(0.0, 0.0), (r, -1.5), (-r, -1.5)] + )); + assert_eq!(sols.len(), 3, "exactly three points, got {sols:?}"); + } + + #[test] + fn undefined_coordinate_is_not_a_solution() { + // xy − y = 0, y − 2x² = 0 → {(0,0), (1,2)}. The old back-solver + // reported `0·0⁻¹` for the first coordinate, which is not a number. + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let eq1 = pool.add(vec![ + pool.mul(vec![x, y]), + pool.mul(vec![pool.integer(-1_i32), y]), + ]); + let eq2 = pool.add(vec![ + y, + pool.mul(vec![pool.integer(-2_i32), powk(&pool, x, 2)]), + ]); + let sols = finite(vec![eq1, eq2], vec![x, y], &pool); + assert!(has_numeric_pair(&sols, &pool, &[(0.0, 0.0), (1.0, 2.0)])); + assert_eq!(sols.len(), 2, "exactly two points, got {sols:?}"); + } + + #[test] + fn unfolded_vanishing_discriminant_is_recognised() { + // The pool folds no arithmetic on literals: `0^2` and `0 * 4 * 1` both + // survive as nodes, so a purely structural zero test misses the + // discriminant of x² = 0 and of (x−1)² = 0 alike. + let pool = ExprPool::new(); + let zero = pool.integer(0_i32); + let b2 = pool.pow(zero, pool.integer(2_i32)); + let four_ac = pool.mul(vec![pool.integer(4_i32), pool.integer(1_i32), zero]); + let disc = pool.add(vec![b2, pool.mul(vec![pool.integer(-1_i32), four_ac])]); + assert!(is_zero_value(disc, &pool), "0² − 4·1·0 = 0"); + + let b2 = pool.pow(pool.integer(-2_i32), pool.integer(2_i32)); + let four_ac = pool.mul(vec![ + pool.integer(4_i32), + pool.integer(1_i32), + pool.integer(1_i32), + ]); + let disc = pool.add(vec![b2, pool.mul(vec![pool.integer(-1_i32), four_ac])]); + assert!(is_zero_value(disc, &pool), "(−2)² − 4·1·1 = 0"); + + // A non-zero discriminant, an irrational one, and a parametric one all + // stay undecided-or-non-zero, so the two-root branch is kept. + assert!(!is_zero_value(pool.integer(8_i32), &pool)); + assert!(!is_zero_value(pool.symbol("a", Domain::Real), &pool)); + // √0 is zero, but only the structural arm can see it. + assert!(is_zero_value(pool.func("sqrt", vec![zero]), &pool)); + } + + #[test] + fn conjugate_roots_behind_a_nested_radical_both_survive() { + // x·y = 0 and x² − y + 1 = 0. y = 0 forces x² = −1, so (±i, 0) are + // both solutions, alongside (0, 1). The y value reaches the inner + // discriminant as √1 rather than as 1, and an enclosure that lets that + // leak a spurious imaginary width can no longer tell +i from −i. + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let eq1 = pool.mul(vec![x, y]); + let eq2 = pool.add(vec![ + powk(&pool, x, 2), + pool.mul(vec![pool.integer(-1_i32), y]), + pool.integer(1_i32), + ]); + let sols = finite(vec![eq1, eq2], vec![x, y], &pool); + assert_eq!(sols.len(), 3, "(0,1) and (±i,0), got {sols:?}"); + } + + #[test] + fn repeated_root_is_one_solution() { + // The solution *set* of x² = 0 is {0} — one element, not ±√0. + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let sols = finite(vec![powk(&pool, x, 2)], vec![x], &pool); + assert_eq!(sols.len(), 1, "{sols:?}"); + assert!(eval_no_env(sols[0][0], &pool).abs() < 1e-12); + } + + #[test] + fn repeated_roots_do_not_multiply_across_variables() { + // x² = y² = z² = 0 has the single solution (0,0,0); the duplicate + // ±√0 entries used to multiply out to eight copies of the origin. + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let z = pool.symbol("z", Domain::Real); + let sols = finite( + vec![powk(&pool, x, 2), powk(&pool, y, 2), powk(&pool, z, 2)], + vec![x, y, z], + &pool, + ); + assert_eq!(sols.len(), 1, "{sols:?}"); + } + + #[test] + fn shifted_double_root_is_one_solution() { + // (x−1)² = 0: one solution, x = 1. + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let shifted = pool.add(vec![x, pool.integer(-1_i32)]); + let sols = finite(vec![powk(&pool, shifted, 2)], vec![x], &pool); + assert_eq!(sols.len(), 1, "{sols:?}"); + assert!((eval_no_env(sols[0][0], &pool) - 1.0).abs() < 1e-12); + } + #[test] fn parametric_quadratic_free_rhs() { // x² − y = 0 in [x] → x = ±√y @@ -780,6 +1339,53 @@ mod tests { assert!((val - 3.0).abs() < 1e-10); } + /// `b/a` is the solution **for `a ≠ 0`**. At `a = 0` the equation reads + /// `−b = 0`, which has no solution for `b ≠ 0` and every `x` for `b = 0`; + /// the returned tuple is a number for neither. The answer is defensible + /// under the generic-parameter reading and indefensible unstated, and a + /// parametric tuple is returned unverified, so the hypothesis is the only + /// signal the caller gets. + #[test] + fn a_parametric_division_states_its_non_vanishing_hypothesis() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let a = pool.symbol("a", Domain::Real); + let b = pool.symbol("b", Domain::Real); + let eq = pool.add(vec![ + pool.mul(vec![a, x]), + pool.mul(vec![pool.integer(-1_i32), b]), + ]); + let _ = solve_polynomial_system(vec![eq], vec![x], &pool).unwrap(); + + let conds = take_solve_side_conditions(); + assert_eq!(conds.len(), 1, "{conds:?}"); + let crate::deriv::log::SideCondition::NonZero(id) = conds[0] else { + panic!("expected a non-vanishing hypothesis, got {:?}", conds[0]); + }; + assert_eq!(id, a); + // Consuming: one call's hypotheses cannot be read as the next call's. + assert!(take_solve_side_conditions().is_empty()); + } + + /// The control: a system whose leading coefficients are *proved* non-zero + /// carries no hypothesis. Without this, "state a condition always" would + /// pass the test above and say nothing. + #[test] + fn a_solve_that_proves_its_divisors_states_nothing() { + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let b = pool.symbol("b", Domain::Real); + // 2x − b = 0: the divisor is the literal 2, and b is still a parameter, + // so this is the nearest neighbour of the case above. + let eq = pool.add(vec![ + pool.mul(vec![pool.integer(2_i32), x]), + pool.mul(vec![pool.integer(-1_i32), b]), + ]); + let result = solve_polynomial_system(vec![eq], vec![x], &pool).unwrap(); + assert!(matches!(result, SolutionSet::Finite(ref s) if s.len() == 1)); + assert!(take_solve_side_conditions().is_empty()); + } + #[test] fn parametric_system_line_with_parameter() { // x + y − c = 0, x − y = 0 in [x, y] → x = y = c/2 diff --git a/alkahest-core/src/solver/polyhedral.rs b/alkahest-core/src/solver/polyhedral.rs index b9097d9d..8bd50961 100644 --- a/alkahest-core/src/solver/polyhedral.rs +++ b/alkahest-core/src/solver/polyhedral.rs @@ -392,6 +392,15 @@ pub(crate) fn polyhedral_starts_2d(poly1: &GbPoly, poly2: &GbPoly) -> (Vec<[C64; /// (`[GbPoly; 2]`) and the list of start points in ℂ² (`Vec>`). /// /// Called by `solve_numerical` when polyhedral homotopy is used. +/// +/// **Currently returns an empty list for every input.** The cell criterion +/// below keeps edge pairs whose edge vectors are parallel, and +/// [`solve_binomial_cell`] returns nothing for exactly those pairs (its +/// determinant is that same cross product), so no start point can ever be +/// produced. See the TODO on `polyhedral_starts_smoke` for what a correct +/// implementation needs. `solve_numerical` compares the number of start +/// points against the mixed volume and falls back to the Bézout start, so this +/// deficiency costs paths, not solutions. pub(crate) fn polyhedral_cell_iter( poly1: &GbPoly, poly2: &GbPoly, diff --git a/alkahest-core/src/solver/regular_chains.rs b/alkahest-core/src/solver/regular_chains.rs index a94c1560..3d9445e5 100644 --- a/alkahest-core/src/solver/regular_chains.rs +++ b/alkahest-core/src/solver/regular_chains.rs @@ -16,8 +16,9 @@ use std::collections::BTreeMap; use super::{expr_to_gbpoly, SolverError}; -/// A triangular set extracted from a lex Gröbner basis: polynomials ordered by -/// increasing recursive main variable (see [`main_variable_recursive`]). +/// A triangular set extracted from a lex Gröbner basis: at most one polynomial +/// per recursive main variable (see [`main_variable_recursive`]), stored in +/// increasing variable index — that is, from the lex-greatest variable down. #[derive(Debug, Clone)] pub struct RegularChain { pub n_vars: usize, @@ -35,13 +36,26 @@ impl RegularChain { } } -/// Largest variable index that appears with positive total degree (recursive main variable). +/// The **recursive main variable** of `poly`: the greatest variable, in the +/// ambient ordering, that occurs with positive degree. +/// +/// Variables are indexed in *decreasing* rank — index `0` is the lex-greatest +/// variable, index `n − 1` the lex-least, which is the layout +/// [`crate::solver::expr_to_gbpoly`] builds and the one the bottom-univariate +/// split assumes when it calls `n − 1` "the bottom". The main variable is +/// therefore the **smallest** occurring index. +/// +/// This returned the *largest* index until 3.8. Under that reading every +/// generator mentioning a low-ranked variable was filed in the same slot of +/// [`extract_regular_chain_from_basis`], and the min-degree tie-break dropped +/// the rest: `[x − y − 1, y² − 2]` — already a reduced lex basis of a two-point +/// ideal — came back as the single chain `[x − y − 1]`, which cuts out a curve. pub fn main_variable_recursive(poly: &GbPoly) -> Option { let mut best: Option = None; for exp in poly.terms.keys() { for (i, &e) in exp.iter().enumerate() { if e > 0 { - best = Some(best.map_or(i, |b| b.max(i))); + best = Some(best.map_or(i, |b| b.min(i))); } } } @@ -73,6 +87,15 @@ fn is_unit_ideal(gens: &[GbPoly], n_vars: usize) -> bool { /// From a Gröbner basis, pick one polynomial per recursive main variable — the /// one of minimal degree in that variable among candidates. +/// +/// This is a *selection*, not a decomposition: when two basis elements share a +/// main variable only one survives, so `⟨chain⟩` can be strictly smaller than +/// the ideal and `V(chain)` strictly larger than `V(I)`. Every polynomial kept +/// is a basis element, so `⟨chain⟩ ⊆ I` always holds — which is what +/// [`crate::solver::solve_polynomial_system`] relies on when it uses the chain +/// as a source of *candidate* solutions that its own post-condition filter then +/// checks. [`triangularize`], whose output is the answer rather than a +/// candidate list, verifies the reverse containment and refuses without it. pub fn extract_regular_chain_from_basis( gens: &[GbPoly], n_vars: usize, @@ -220,16 +243,44 @@ fn split_chain_at_bottom_univariate( } } +/// True iff `⟨chain⟩ ⊇ I`, i.e. every generator of the input basis reduces to +/// zero modulo the chain. +/// +/// This is the soundness direction that matters: with it, `V(chain) ⊆ V(I)`, so +/// the chain cannot describe points the input system does not have. Without it +/// the chain cuts out a *larger* set than the system — a curve where the answer +/// is two points — which is exactly the failure `main_variable_recursive`'s +/// reversed ordering used to produce. +fn chain_contains_ideal(chain: &RegularChain, gb_gens: &[GbPoly]) -> bool { + if chain.polys.is_empty() { + return gb_gens.iter().all(|g| g.is_zero()); + } + let chain_gb = GroebnerBasis::compute(chain.polys.clone(), MonomialOrder::Lex); + gb_gens.iter().all(|g| chain_gb.contains(g)) +} + /// Kalkbrener / Lazard style triangular decomposition: compute a lex Gröbner basis, /// extract a recursive main-variable chain, then split along square-free factors of /// the bottom univariate when possible (V2-7). /// /// Returns an empty list when the ideal is the whole ring (`⟨1⟩`). +/// +/// # Refusals +/// +/// Chain extraction keeps one polynomial per main variable, so a basis with two +/// generators sharing a main variable — `⟨xy, xz⟩` is the smallest example — +/// loses one of them and the surviving chain describes a larger variety than +/// the input system. Splitting on general initials (Lazard–Kalkbrener) is what +/// would decompose those ideals properly, and is not implemented. Rather than +/// return the under-determined chain, this function checks `⟨chain⟩ ⊇ I` for +/// every chain it is about to return and refuses when the check fails; see +/// [`TriangularizeRefusal`] for the code that refusal carries. pub fn triangularize( equations: Vec, vars: Vec, pool: &ExprPool, ) -> Result, SolverError> { + forget_triangularize_refusal(); let n_vars = vars.len(); if n_vars == 0 { return Ok(vec![]); @@ -249,7 +300,120 @@ pub fn triangularize( } let chain = extract_regular_chain_from_basis(gens, n_vars, MonomialOrder::Lex); - split_chain_at_bottom_univariate(chain, last_var) + let chains = split_chain_at_bottom_univariate(chain, last_var)?; + + for c in &chains { + if !chain_contains_ideal(c, gens) { + return Err(refuse_triangularize(gens.len(), c.polys.len())); + } + } + Ok(chains) +} + +// --------------------------------------------------------------------------- +// Refusals, reported out of band +// --------------------------------------------------------------------------- + +/// `triangularize` declined because the chain it extracted is not a triangular +/// decomposition of the input ideal. +/// +/// # Why this is not an error variant +/// +/// [`SolverError`] is a public *exhaustive* enum, so growing it a +/// `NotTriangularizable` variant is a major semver break — and so is marking it +/// `#[non_exhaustive]` to allow one later. A correctness fix inside a patch +/// release cannot spend a major version, so the refusal travels out of band: +/// [`triangularize`] returns `SolverError::NotPolynomial`, which this module +/// already uses as its generic carrier (the FLINT failure path of the +/// bottom-univariate split reports through it too), with a message that names +/// the real reason, and the stable `E-SOLVE-004` code is recorded here for +/// [`take_triangularize_refusal`] to hand to the bindings. +/// +/// This is the pattern +/// [`crate::matrix::take_zero_test_refusal`] uses for undecided zero tests +/// inside `LinearAlgebraError::UnsupportedField`, and +/// [`crate::calculus::limits::last_budget_trip`] for budget trips inside +/// `LimitError::DepthExceeded`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TriangularizeRefusal { + basis_len: usize, + chain_len: usize, +} + +impl TriangularizeRefusal { + /// How many generators the lex basis had. + pub fn basis_len(&self) -> usize { + self.basis_len + } + + /// How many polynomials the extracted chain kept. + pub fn chain_len(&self) -> usize { + self.chain_len + } +} + +impl std::fmt::Display for TriangularizeRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "triangularize: the {} lex basis generators do not extract to a \ + triangular set — the {}-polynomial chain does not generate an ideal \ + containing them, so it cuts out a larger variety than the input \ + system; refusing rather than returning an under-determined chain", + self.basis_len, self.chain_len + ) + } +} + +impl std::error::Error for TriangularizeRefusal {} + +impl crate::errors::AlkahestError for TriangularizeRefusal { + fn code(&self) -> &'static str { + "E-SOLVE-004" + } + + fn remediation(&self) -> Option<&'static str> { + Some( + "this ideal needs a splitting triangular decomposition (Lazard–Kalkbrener \ + on the initials), which is not implemented; use GroebnerBasis::compute or \ + primary_decomposition instead", + ) + } +} + +thread_local! { + /// The refusal behind the `SolverError::NotPolynomial` the current thread is + /// about to return, when that variant is a carrier rather than what it + /// usually means. + static LAST_TRIANGULARIZE_REFUSAL: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// Drop any recorded refusal, so a later unrelated `NotPolynomial` — a genuinely +/// non-polynomial equation, say — can never be re-attributed to it. +fn forget_triangularize_refusal() { + LAST_TRIANGULARIZE_REFUSAL.with(|c| *c.borrow_mut() = None); +} + +fn refuse_triangularize(basis_len: usize, chain_len: usize) -> SolverError { + let refusal = TriangularizeRefusal { + basis_len, + chain_len, + }; + let message = refusal.to_string(); + LAST_TRIANGULARIZE_REFUSAL.with(|c| *c.borrow_mut() = Some(refusal)); + SolverError::NotPolynomial(message) +} + +/// Take the refusal behind the error that just came back, if there was one. +/// +/// Bindings call this when [`triangularize`] returns +/// `SolverError::NotPolynomial` and raise the refusal's own `E-SOLVE-004` when +/// it is present, so the caller still gets the specific code. `Some` means +/// *this* error is a refusal; `None` means the variant means what it usually +/// means. Consuming, so one refusal is reported once; thread-local. +pub fn take_triangularize_refusal() -> Option { + LAST_TRIANGULARIZE_REFUSAL.with(|c| c.borrow_mut().take()) } #[cfg(test)] @@ -270,6 +434,69 @@ mod tests { assert!(!chains[0].is_empty()); } + #[test] + fn main_variable_is_the_lex_greatest_occurring() { + // x - y in vars [x, y]: x is lex-greatest, so the main variable is 0. + let p = GbPoly { + terms: [ + (vec![1u32, 0], Rational::from(1)), + (vec![0, 1], Rational::from(-1)), + ] + .into_iter() + .collect(), + n_vars: 2, + }; + assert_eq!(main_variable_recursive(&p), Some(0)); + } + + #[test] + fn triangularize_keeps_every_generator_of_a_two_point_ideal() { + // {x - y - 1, y² - 2} is already a reduced lex basis; its variety is the + // two points (1 ± √2, ±√2). A one-polynomial chain in two variables + // cuts out a curve, so it cannot be the answer whichever poly is kept. + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let eq1 = pool.add(vec![ + x, + pool.mul(vec![pool.integer(-1), y]), + pool.integer(-1), + ]); + let eq2 = pool.add(vec![pool.pow(y, pool.integer(2)), pool.integer(-2)]); + let chains = triangularize(vec![eq1, eq2], vec![x, y], &pool).unwrap(); + assert_eq!(chains.len(), 1); + assert_eq!(chains[0].len(), 2, "both generators must survive"); + let mains: Vec> = chains[0] + .polys + .iter() + .map(main_variable_recursive) + .collect(); + assert_eq!(mains, vec![Some(0), Some(1)], "one poly per main variable"); + } + + #[test] + fn triangularize_refuses_rather_than_drop_a_generator() { + // ⟨xy, xz⟩ = ⟨x⟩ ∩ ⟨y, z⟩ needs a *split*; both generators have main + // variable x, so extraction can only keep one and ⟨xy⟩ ⊉ ⟨xy, xz⟩. + let pool = ExprPool::new(); + let x = pool.symbol("x", Domain::Real); + let y = pool.symbol("y", Domain::Real); + let z = pool.symbol("z", Domain::Real); + let err = triangularize( + vec![pool.mul(vec![x, y]), pool.mul(vec![x, z])], + vec![x, y, z], + &pool, + ) + .expect_err("must refuse rather than return a chain missing xz"); + assert!(matches!(err, SolverError::NotPolynomial(_))); + let refusal = take_triangularize_refusal().expect("refusal recorded out of band"); + assert_eq!(crate::errors::AlkahestError::code(&refusal), "E-SOLVE-004"); + assert_eq!(refusal.basis_len(), 2); + assert_eq!(refusal.chain_len(), 1); + // Consuming: a second take must not re-report it. + assert_eq!(take_triangularize_refusal(), None); + } + #[test] fn split_univariate_square() { // (x^2 - 1) = 0 → two chains after bottom split: x-1 and x+1 diff --git a/alkahest-core/src/solver/verify.rs b/alkahest-core/src/solver/verify.rs new file mode 100644 index 00000000..b6795127 --- /dev/null +++ b/alkahest-core/src/solver/verify.rs @@ -0,0 +1,508 @@ +//! Rigorous post-condition checking for the polynomial system solver. +//! +//! [`solve_polynomial_system`](super::solve_polynomial_system) builds candidate +//! solutions by back-substituting through a Lex Gröbner basis. Substituting a +//! finished tuple back into the *original* equations is far cheaper than +//! producing it, so the solver does exactly that before returning: a tuple +//! whose residual is **provably** non-zero is dropped rather than reported. +//! +//! "Provably" is the operative word. The check runs in complex ball +//! arithmetic ([`CBall`], built on [`ArbBall`]), where every operation is +//! outward-rounded, so the true residual is always inside the returned ball. +//! A candidate is discarded only when its residual ball is *separated from +//! zero* — a rigorous certificate that the tuple is not a solution. A ball +//! that straddles zero proves nothing and the candidate survives, so the check +//! can never remove a genuine solution (which would trade one silent error for +//! a worse one). +//! +//! The same separation test drives de-duplication: two candidates collapse +//! only when no coordinate can be proved distinct. +//! +//! Values the solver can build are rationals combined with `+`, `*`, integer +//! powers and `sqrt`. Anything else — a free parameter, say — makes the tuple +//! *unverifiable* rather than *refuted*, and it is returned untouched. + +use crate::ball::ArbBall; +use crate::kernel::{ExprData, ExprId, ExprPool}; +use crate::poly::groebner::GbPoly; +use rug::ops::Pow; +use rug::Rational; + +/// Working precision, in bits, for the solver's post-condition check. +/// +/// Well above `f64`: the residual of a true solution shrinks towards zero as +/// precision grows, while the residual of a spurious one does not, so the +/// separation test only sharpens with more bits. 192 keeps the whole check in +/// the microsecond range for the systems the symbolic solver accepts (degree +/// ≤ 2 per variable). +const VERIFY_PREC: u32 = 192; + +/// Why a candidate could not be evaluated to a complex ball. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VerifyGap { + /// The expression is a well-formed value the evaluator does not model + /// (a free parameter, an unsupported function). Nothing can be concluded. + Unsupported, + /// The expression denotes no complex number at all — `0/0`, `0^-1`. + /// A tuple with such a coordinate is not a point of ℂⁿ. + Undefined, +} + +/// A rigorous enclosure of a complex number: `re ± re.rad` and `im ± im.rad`. +#[derive(Clone, Debug)] +pub(crate) struct CBall { + re: ArbBall, + im: ArbBall, +} + +fn zero_ball() -> ArbBall { + ArbBall::from_f64(0.0, VERIFY_PREC) +} + +impl CBall { + fn real(re: ArbBall) -> Self { + CBall { + re, + im: zero_ball(), + } + } + + fn from_rational(r: &Rational) -> Self { + CBall::real(ArbBall::from_rational(r, VERIFY_PREC)) + } + + fn from_integer(n: &rug::Integer) -> Self { + CBall::real(ArbBall::from_integer(n, VERIFY_PREC)) + } + + fn from_f64(v: f64) -> Self { + CBall::real(ArbBall::from_f64(v, VERIFY_PREC)) + } + + fn one() -> Self { + CBall::from_f64(1.0) + } + + fn zero() -> Self { + CBall::from_f64(0.0) + } + + fn add(&self, other: &CBall) -> CBall { + CBall { + re: self.re.clone() + other.re.clone(), + im: self.im.clone() + other.im.clone(), + } + } + + fn mul(&self, other: &CBall) -> CBall { + let ac = self.re.clone() * other.re.clone(); + let bd = self.im.clone() * other.im.clone(); + let ad = self.re.clone() * other.im.clone(); + let bc = self.im.clone() * other.re.clone(); + CBall { + re: ac - bd, + im: ad + bc, + } + } + + /// `1/z = conj(z) / |z|²`. `None` when `|z|²` cannot be separated from + /// zero, which is exactly when the reciprocal may not exist. + fn recip(&self) -> Option { + let d = self.re.clone() * self.re.clone() + self.im.clone() * self.im.clone(); + let re = (self.re.clone() / d.clone())?; + let im = (-self.im.clone() / d)?; + Some(CBall { re, im }) + } + + fn powi(&self, n: i64) -> Option { + if n == 0 { + return Some(CBall::one()); + } + if n < 0 { + let pos = self.powi(-n)?; + return pos.recip(); + } + let mut acc = CBall::one(); + let mut base = self.clone(); + let mut e = n as u64; + while e > 0 { + if e & 1 == 1 { + acc = acc.mul(&base); + } + e >>= 1; + if e > 0 { + base = base.mul(&base); + } + } + Some(acc) + } + + /// Principal-branch complex square root, matching `eval_complex_f64` + /// (`sqrt(-1) = +i`). + /// + /// `sqrt(a + bi) = u + sign(b)·v·i` with `u = √((|z| + a)/2)` and + /// `v = √((|z| − a)/2)`, and `sign(0) = +1`. When the sign of `b` cannot + /// be decided from its ball, the imaginary part is widened to `[−v, v]`, + /// which encloses both branches: weaker, never wrong. + fn sqrt(&self) -> Option { + // A real argument keeps the result on one axis *exactly*, which is what + // preserves the distinction between the two roots further up. Going + // through the general formula instead would leave `√1` with a spurious + // imaginary width of about 2^-95 (the square root of the discarded + // rounding term), and one more level of nesting then widens `√−4` to + // "±2i", at which point `+i` and `−i` are no longer provably different + // and de-duplication silently merges two genuine solutions. + let im_is_exact_zero = self.im.is_exact() && self.im.mid_f64() == 0.0; + if im_is_exact_zero { + if self.re.lo() >= 0 { + return Some(CBall::real(self.re.sqrt()?)); + } + if self.re.hi() <= 0 { + return Some(CBall { + re: zero_ball(), + im: (-self.re.clone()).sqrt()?, + }); + } + } + let norm2 = self.re.clone() * self.re.clone() + self.im.clone() * self.im.clone(); + let modulus = clamp_nonneg(norm2).sqrt()?; + let half = ArbBall::from_f64(0.5, VERIFY_PREC); + let u = clamp_nonneg((modulus.clone() + self.re.clone()) * half.clone()).sqrt()?; + let v = clamp_nonneg((modulus - self.re.clone()) * half).sqrt()?; + // `im` is exactly zero for every discriminant built from rationals, + // which is the case that has to stay sharp. + let im_is_exact_zero = self.im.is_exact() && self.im.mid_f64() == 0.0; + let im = if im_is_exact_zero || self.im.lo() > 0 { + v + } else if self.im.hi() < 0 { + -v + } else { + widen_around_zero(&v) + }; + Some(CBall { re: u, im }) + } + + /// True when the ball is separated from the origin, i.e. **no** complex + /// number it encloses is zero. This is the only direction the check may + /// act on: a ball containing zero proves nothing either way. + pub(crate) fn excludes_zero(&self) -> bool { + !self.re.contains(0.0) || !self.im.contains(0.0) + } + + /// Is every point of this ball within `2^exp` of the origin? + fn is_within_scale(&self, exp: i32) -> bool { + let bound = rug::Float::with_val(VERIFY_PREC, 2.0_f64).pow(exp); + let reach = + |b: &ArbBall| rug::Float::with_val(VERIFY_PREC, b.mid.clone().abs()) + b.rad.clone(); + reach(&self.re) < bound && reach(&self.im) < bound + } + + #[cfg(test)] + fn neg_ball(&self) -> CBall { + CBall { + re: -self.re.clone(), + im: -self.im.clone(), + } + } + + fn sub(&self, other: &CBall) -> CBall { + CBall { + re: self.re.clone() - other.re.clone(), + im: self.im.clone() - other.im.clone(), + } + } +} + +/// Replace a ball whose lower end has drifted below zero by rounding with one +/// clamped at zero. Sound only for quantities that are non-negative by +/// construction (`|z|`, `|z| ± Re z`), which is where it is used. +fn clamp_nonneg(b: ArbBall) -> ArbBall { + if b.lo() >= 0 { + return b; + } + let hi = b.hi(); + let mid = rug::Float::with_val(VERIFY_PREC, &hi / 2u32); + ArbBall { + mid: mid.clone(), + rad: mid, + prec: VERIFY_PREC, + } +} + +/// `[−|b|, +|b|]` — encloses both `+b` and `−b`. +fn widen_around_zero(b: &ArbBall) -> ArbBall { + let hi = rug::Float::with_val(VERIFY_PREC, b.hi().abs()); + let lo = rug::Float::with_val(VERIFY_PREC, b.lo().abs()); + let bound = if hi > lo { hi } else { lo }; + ArbBall { + mid: rug::Float::with_val(VERIFY_PREC, 0.0), + rad: bound, + prec: VERIFY_PREC, + } +} + +/// Evaluate a solver-produced value to a rigorous complex enclosure, memoising +/// on [`ExprId`]. +/// +/// Sibling roots `(−b ± √D)/2a` and the successive back-substitution levels +/// share almost all of their structure, so the expression is a DAG whose +/// tree expansion grows with the variable count. The memo keeps the check +/// linear in the number of distinct nodes. +#[derive(Default)] +pub(crate) struct CBallEval { + memo: std::collections::HashMap>, +} + +impl CBallEval { + pub(crate) fn eval(&mut self, expr: ExprId, pool: &ExprPool) -> Result { + if let Some(hit) = self.memo.get(&expr) { + return hit.clone(); + } + let out = self.eval_uncached(expr, pool); + self.memo.insert(expr, out.clone()); + out + } + + fn eval_uncached(&mut self, expr: ExprId, pool: &ExprPool) -> Result { + match pool.get(expr) { + ExprData::Integer(n) => Ok(CBall::from_integer(&n.0)), + ExprData::Rational(r) => Ok(CBall::from_rational(&r.0)), + ExprData::Float(f) => Ok(CBall::from_f64(f.inner.to_f64())), + ExprData::Add(args) => { + let mut acc = CBall::zero(); + for a in args { + acc = acc.add(&self.eval(a, pool)?); + } + Ok(acc) + } + ExprData::Mul(args) => { + let mut acc = CBall::one(); + for a in args { + acc = acc.mul(&self.eval(a, pool)?); + } + Ok(acc) + } + ExprData::Pow { base, exp } => { + let ExprData::Integer(n) = pool.get(exp) else { + return Err(VerifyGap::Unsupported); + }; + let n = n.0.to_i64().ok_or(VerifyGap::Unsupported)?; + let b = self.eval(base, pool)?; + b.powi(n).ok_or(VerifyGap::Undefined) + } + ExprData::Func { name, args } if name == "sqrt" && args.len() == 1 => { + let x = self.eval(args[0], pool)?; + x.sqrt().ok_or(VerifyGap::Undefined) + } + _ => Err(VerifyGap::Unsupported), + } + } +} + +/// Evaluate `poly` at the given complex enclosures (one per indeterminate). +pub(crate) fn poly_residual(poly: &GbPoly, values: &[CBall]) -> Option { + let mut acc = CBall::zero(); + for (exp, coeff) in &poly.terms { + let mut term = CBall::from_rational(coeff); + for (i, &e) in exp.iter().enumerate() { + if e == 0 { + continue; + } + term = term.mul(&values.get(i)?.powi(e as i64)?); + } + acc = acc.add(&term); + } + Some(acc) +} + +/// Residual at a *partial* assignment: `None` unless every indeterminate the +/// polynomial actually uses has a value. +pub(crate) fn poly_residual_partial(poly: &GbPoly, values: &[Option]) -> Option { + let mut acc = CBall::zero(); + for (exp, coeff) in &poly.terms { + let mut term = CBall::from_rational(coeff); + for (i, &e) in exp.iter().enumerate() { + if e == 0 { + continue; + } + term = term.mul(&values.get(i)?.as_ref()?.powi(e as i64)?); + } + acc = acc.add(&term); + } + Some(acc) +} + +/// True when some equation's residual at `values` is provably non-zero, i.e. +/// the tuple is certainly **not** a solution of the system. +pub(crate) fn is_refuted(polys: &[GbPoly], values: &[CBall]) -> bool { + polys.iter().any(|p| match poly_residual(p, values) { + Some(r) => r.excludes_zero(), + // An undefined residual means the tuple does not lie in the domain of + // the polynomial map — it is not a point of ℂⁿ, so it is not a + // solution either. + None => true, + }) +} + +/// Binary exponent below which two candidates are treated as the same point. +/// +/// Chosen well above the widest enclosure the solver produces: a discriminant +/// that is zero but only numerically so (`4y − 4` reached through `√1`) leaves +/// its two roots enclosed to about 2^-94, so anything tighter than that would +/// report a double root twice. Chosen well below any separation the +/// *representable* fragment can produce: two distinct roots of a quadratic +/// over ℚ are further apart than 2^-64 unless its coefficients exceed 2^32. +const SAME_POINT_EXP: i32 = -64; + +/// True when every coordinate of `a` is enclosed within 2^`SAME_POINT_EXP` of +/// the matching coordinate of `b`. +/// +/// This is deliberately *positive* evidence rather than "could not be proved +/// different". De-duplication removes a solution, so basing it on ignorance +/// would let an imprecise enclosure delete a genuine root — the exact failure +/// mode this module exists to prevent. When the enclosures are too wide to +/// decide, both candidates are kept: a duplicate entry is a cosmetic fault, a +/// dropped solution is a silent error. +pub(crate) fn same_point(a: &[CBall], b: &[CBall]) -> bool { + a.len() == b.len() + && a.iter() + .zip(b.iter()) + .all(|(x, y)| x.sub(y).is_within_scale(SAME_POINT_EXP)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::ExprPool; + + fn cball(expr: ExprId, pool: &ExprPool) -> Result { + CBallEval::default().eval(expr, pool) + } + + fn sqrt_of(pool: &ExprPool, n: i32) -> ExprId { + pool.func("sqrt", vec![pool.integer(n)]) + } + + #[test] + fn rational_arithmetic_is_exact_enough_to_separate() { + let pool = ExprPool::new(); + let two = pool.integer(2_i32); + let three = pool.integer(3_i32); + let sum = pool.add(vec![two, three]); + let v = cball(sum, &pool).expect("evaluable"); + assert!(v.excludes_zero()); + let diff = pool.add(vec![two, pool.integer(-2_i32)]); + let z = cball(diff, &pool).expect("evaluable"); + assert!(!z.excludes_zero(), "2 + (-2) must not be separated from 0"); + } + + #[test] + fn zero_over_zero_is_undefined_not_unsupported() { + let pool = ExprPool::new(); + let zero = pool.integer(0_i32); + let inv = pool.pow(zero, pool.integer(-1_i32)); + assert!(matches!(cball(inv, &pool), Err(VerifyGap::Undefined))); + } + + #[test] + fn free_symbol_is_unsupported() { + let pool = ExprPool::new(); + let a = pool.symbol("a", crate::kernel::Domain::Real); + assert!(matches!(cball(a, &pool), Err(VerifyGap::Unsupported))); + } + + #[test] + fn principal_sqrt_of_negative_is_positive_imaginary() { + let pool = ExprPool::new(); + let s = sqrt_of(&pool, -4); + let v = cball(s, &pool).expect("evaluable"); + assert!(v.re.contains(0.0), "real part of √-4 is 0"); + assert!(v.im.contains(2.0), "√-4 = +2i, got im = {}", v.im); + assert!(!v.im.contains(-2.0)); + } + + #[test] + fn sqrt_of_zero_is_zero() { + let pool = ExprPool::new(); + let s = sqrt_of(&pool, 0); + let v = cball(s, &pool).expect("evaluable"); + assert!(!v.excludes_zero()); + assert!(v.re.contains(0.0) && v.im.contains(0.0)); + } + + #[test] + fn spurious_root_is_refuted_and_true_root_is_not() { + // x² − x·y and x·y − y at (−1, 1): the first residual is 1 + 1 = 2. + let pool = ExprPool::new(); + let mut f1 = GbPoly::zero(2); + f1 = f1.add(&GbPoly::monomial(vec![2, 0], Rational::from(1))); + f1 = f1.add(&GbPoly::monomial(vec![1, 1], Rational::from(-1))); + let minus_one = cball(pool.integer(-1_i32), &pool).unwrap(); + let one = cball(pool.integer(1_i32), &pool).unwrap(); + assert!(is_refuted(&[f1.clone()], &[minus_one, one.clone()])); + assert!(!is_refuted(&[f1], &[one.clone(), one])); + } + + #[test] + fn irrational_root_survives_and_its_negation_is_separated() { + // x² − 2 at x = √2 must not be refuted; at x = √2 + 1 it must be. + let pool = ExprPool::new(); + let mut f = GbPoly::zero(1); + f = f.add(&GbPoly::monomial(vec![2], Rational::from(1))); + f = f.add(&GbPoly::monomial(vec![0], Rational::from(-2))); + let root = cball(sqrt_of(&pool, 2), &pool).unwrap(); + assert!(!is_refuted(&[f.clone()], std::slice::from_ref(&root))); + let shifted = cball( + pool.add(vec![sqrt_of(&pool, 2), pool.integer(1_i32)]), + &pool, + ) + .unwrap(); + assert!(is_refuted(&[f], std::slice::from_ref(&shifted))); + } + + #[test] + fn real_argument_keeps_the_root_on_one_axis_exactly() { + // √1 must come back with an imaginary part that is *exactly* zero, and + // √−4 with a real part that is exactly zero. Anything wider survives + // one more nesting level and stops `+i` and `−i` being distinguishable. + let pool = ExprPool::new(); + let one = cball(sqrt_of(&pool, 1), &pool).unwrap(); + assert!(one.im.is_exact() && one.im.mid_f64() == 0.0); + let neg = cball(sqrt_of(&pool, -4), &pool).unwrap(); + assert!(neg.re.is_exact() && neg.re.mid_f64() == 0.0); + // …and the two square roots of −4 stay provably apart after halving. + let half = CBall::from_rational(&Rational::from((1, 2))); + let plus = neg.mul(&half); + let minus = plus.neg_ball(); + assert!(!same_point( + std::slice::from_ref(&plus), + std::slice::from_ref(&minus) + )); + } + + #[test] + fn distinctness_separates_plus_and_minus_root() { + let pool = ExprPool::new(); + let plus = cball(sqrt_of(&pool, 2), &pool).unwrap(); + let minus = cball( + pool.mul(vec![pool.integer(-1_i32), sqrt_of(&pool, 2)]), + &pool, + ) + .unwrap(); + assert!(!same_point( + std::slice::from_ref(&plus), + std::slice::from_ref(&minus) + )); + assert!(same_point( + std::slice::from_ref(&plus), + std::slice::from_ref(&plus) + )); + // ±√0 is one point, and the enclosure has to say so. + let z = cball(sqrt_of(&pool, 0), &pool).unwrap(); + let neg_z = z.neg_ball(); + assert!(same_point( + std::slice::from_ref(&z), + std::slice::from_ref(&neg_z) + )); + } +} diff --git a/alkahest-core/src/sum/mod.rs b/alkahest-core/src/sum/mod.rs index 4cd7f596..2c64fdfa 100644 --- a/alkahest-core/src/sum/mod.rs +++ b/alkahest-core/src/sum/mod.rs @@ -169,6 +169,20 @@ pub fn sum_definite( m_lower.insert(k, lo); let lower = simp(pool, subs(g, &m_lower, pool)); + // A pole of the *summand* strictly between the bounds is invisible in the + // telescoped difference — `G(hi+1) − G(lo)` never mentions the interior + // indices — so it has to be looked for in the summand itself, the same way + // `integrate::engine`'s interior-pole guards look at the integrand rather + // than at `F(b) − F(a)`. `Σ_{k=1}^{10} 1/((k−3)(k−2))` telescoped to a + // clean `−5/8` while its `k = 2` and `k = 3` terms divide by zero. + if let Some(bad) = interior_undefined_index(term, k, lo, hi, pool) { + return Err(SumError::BoundSubstitution(format!( + "the summand is undefined at k = {bad}, which lies inside the summation \ + range: that term of the sum is a division by zero, so the sum has no \ + value and the telescoped difference G(hi+1) - G(lo) is not it", + ))); + } + let diff = simp( pool, pool.add(vec![upper, pool.mul(vec![lower, pool.integer(-1_i32)])]), @@ -192,6 +206,139 @@ pub fn sum_definite( Ok(DerivedExpr::with_log(diff, log)) } +/// Largest number of individual indices [`interior_undefined_index`] will +/// substitute into when it cannot narrow the candidates structurally. +/// +/// The structural pass below is exact and range-independent for a summand whose +/// negative powers are polynomials in `k`, which is every rational summand; this +/// bound only limits the brute-force fallback used for shapes it cannot parse, +/// so a very long range with an exotic summand is answered "no opinion" rather +/// than made slow. +const MAX_POLE_SCAN: i64 = 2048; + +/// `expr` as an `i64` when it is an integer literal. +fn const_i64(pool: &ExprPool, e: ExprId) -> Option { + match pool.get(e) { + crate::kernel::ExprData::Integer(n) => n.0.to_i64(), + _ => None, + } +} + +/// Collect the bases of every `X^negative` node, i.e. every denominator. +fn negative_power_bases(expr: ExprId, pool: &ExprPool, out: &mut Vec) { + use crate::kernel::ExprData; + match pool.get(expr) { + ExprData::Pow { base, exp } => { + let negative_exp = match pool.get(exp) { + ExprData::Integer(n) => n.0 < 0, + ExprData::Rational(r) => r.0 < 0, + _ => false, + }; + if negative_exp { + out.push(base); + } + negative_power_bases(base, pool, out); + negative_power_bases(exp, pool, out); + } + ExprData::Add(xs) | ExprData::Mul(xs) => { + for &x in xs.iter() { + negative_power_bases(x, pool, out); + } + } + ExprData::Func { args, .. } => { + for &a in args.iter() { + negative_power_bases(a, pool, out); + } + } + _ => {} + } +} + +/// Integer roots of `p` in `[lo, hi]`, read off its ℤ-factorisation. +fn integer_roots_in(p: &crate::poly::UniPoly, lo: i64, hi: i64) -> Vec { + let Ok(fac) = p.factor_z() else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (fact, _) in &fac.factors { + if fact.degree() != 1 { + continue; + } + let coeffs = fact.coefficients(); + let (Some(b), Some(a)) = (coeffs.first(), coeffs.get(1)) else { + continue; + }; + if *a == 0 { + continue; + } + // root = -b/a, an integer only when a divides b. + let (q, r) = (-b.clone()).div_rem(a.clone()); + if r != 0 { + continue; + } + if let Some(root) = q.to_i64() { + if root >= lo && root <= hi { + out.push(root); + } + } + } + out +} + +/// The smallest integer in `[lo, hi]` at which `term` is undefined, when that +/// can be established; `None` means *no opinion*, never *no pole*. +/// +/// Refusal must rest on positive evidence, so this returns an index only after +/// substituting it and seeing an actual `0^{negative}` survive simplification. +/// Candidates come from the roots of the summand's own denominators, so the cost +/// does not scale with the length of the range. +fn interior_undefined_index( + term: ExprId, + k: ExprId, + lo: ExprId, + hi: ExprId, + pool: &ExprPool, +) -> Option { + let (lo_i, hi_i) = (const_i64(pool, lo)?, const_i64(pool, hi)?); + if lo_i > hi_i { + return None; + } + + // Simplify first: `(k−2)/(k−2)` is `1`, and the caller asked about the + // summand, not about an unreduced spelling of it. + let term = simp(pool, term); + + let mut bases = Vec::new(); + negative_power_bases(term, pool, &mut bases); + + let mut candidates: Vec = Vec::new(); + let mut unparsed = false; + for base in bases { + match crate::poly::UniPoly::from_symbolic_clear_denoms(base, k, pool) { + Ok(p) if p.degree() >= 1 => candidates.extend(integer_roots_in(&p, lo_i, hi_i)), + // A constant denominator has no root; anything else (a `gamma`, a + // `2^k`, a nested quotient) is outside this pass's reach. + Ok(_) => {} + Err(_) => unparsed = true, + } + } + + if unparsed && hi_i.saturating_sub(lo_i) < MAX_POLE_SCAN { + candidates.extend(lo_i..=hi_i); + } + candidates.sort_unstable(); + candidates.dedup(); + + for j in candidates { + let mut m = HashMap::new(); + m.insert(k, pool.integer(j)); + if contains_zero_to_negative_power(simp(pool, subs(term, &m, pool)), pool) { + return Some(j); + } + } + None +} + /// True when `expr` contains a `0^n` node with `n` negative — an unresolved /// division by zero that survived simplification. fn contains_zero_to_negative_power(expr: ExprId, pool: &ExprPool) -> bool { @@ -368,6 +515,69 @@ mod tests { assert!((v - 63.0).abs() < 1e-9, "got {v}"); // 1+2+4+8+16+32 } + /// A pole of the summand *strictly inside* the range makes the sum + /// undefined; the telescoped difference does not know that. + #[test] + fn interior_pole_is_refused_not_telescoped() { + let pool = ExprPool::new(); + let k = pool.symbol("k", Domain::Real); + let i = |v: i32| pool.integer(v); + + // Σ_{k=1}^{10} 1/((k−3)(k−2)) — the k = 2 and k = 3 terms divide by zero. + let den = simp( + &pool, + pool.mul(vec![ + simp(&pool, pool.add(vec![k, i(-3)])), + simp(&pool, pool.add(vec![k, i(-2)])), + ]), + ); + let term = simp(&pool, pool.pow(den, i(-1))); + let err = sum_definite(term, k, i(1), i(10), &pool).expect_err("must refuse"); + assert!(matches!(err, SumError::BoundSubstitution(_))); + assert_eq!(crate::errors::AlkahestError::code(&err), "E-SUM-003"); + + // Σ_{k=−2}^{5} 1/(k(k+1)) — poles at k = −1 and k = 0. + let den = simp( + &pool, + pool.mul(vec![k, simp(&pool, pool.add(vec![k, i(1)]))]), + ); + let term = simp(&pool, pool.pow(den, i(-1))); + assert!(sum_definite(term, k, i(-2), i(5), &pool).is_err()); + } + + /// The nearest convergent neighbours must still evaluate: the guard has to + /// fire on a pole, not on the shape. + #[test] + fn poles_outside_the_range_do_not_block_the_sum() { + let pool = ExprPool::new(); + let k = pool.symbol("k", Domain::Real); + let i = |v: i32| pool.integer(v); + + // Σ_{k=1}^{10} 1/(k(k+1)) = 1 − 1/11 = 10/11. + let den = simp( + &pool, + pool.mul(vec![k, simp(&pool, pool.add(vec![k, i(1)]))]), + ); + let term = simp(&pool, pool.pow(den, i(-1))); + let s = sum_definite(term, k, i(1), i(10), &pool).expect("no pole in [1, 10]"); + let v = eval_interp(s.value, &HashMap::new(), &pool).expect("eval"); + assert!((v - 10.0 / 11.0).abs() < 1e-12, "got {v}"); + + // Σ_{k=4}^{10} 1/((k−3)(k−2)): poles at 2 and 3, both below the range. + let den = simp( + &pool, + pool.mul(vec![ + simp(&pool, pool.add(vec![k, i(-3)])), + simp(&pool, pool.add(vec![k, i(-2)])), + ]), + ); + let term = simp(&pool, pool.pow(den, i(-1))); + let s = sum_definite(term, k, i(4), i(10), &pool).expect("no pole in [4, 10]"); + let v = eval_interp(s.value, &HashMap::new(), &pool).expect("eval"); + // Σ_{k=4}^{10} (1/(k−3) − 1/(k−2)) = 1 − 1/8 = 7/8. + assert!((v - 0.875).abs() < 1e-12, "got {v}"); + } + #[test] fn wz_pair_zero_is_certificate() { let pool = ExprPool::new(); diff --git a/alkahest-core/src/sum/product.rs b/alkahest-core/src/sum/product.rs index 0ba46d58..532f78a6 100644 --- a/alkahest-core/src/sum/product.rs +++ b/alkahest-core/src/sum/product.rs @@ -106,9 +106,17 @@ fn rational_to_expr(pool: &ExprPool, r: &Rational) -> ExprId { } } -fn ratuni_poly_to_univ(p: &RatUniPoly, var: ExprId) -> Result { +/// A `ℚ[k]` polynomial as an integer-coefficient [`UniPoly`] **plus the scale +/// that was used to clear denominators**. +/// +/// The returned pair `(u, c)` satisfies `p(k) = u(k) / c` exactly. Returning `c` +/// is not optional bookkeeping: `∏_{k=lo}^{hi} p(k)` differs from +/// `∏_{k=lo}^{hi} u(k)` by `c^{hi−lo+1}`, so a caller that drops it reports an +/// answer off by a constant factor with no other symptom — `∏_{k=1}^{5} ½` came +/// back as `1` instead of `2^{-5}`. +fn ratuni_poly_to_univ(p: &RatUniPoly, var: ExprId) -> Result<(UniPoly, Integer), ProductError> { if p.is_zero() { - return Ok(UniPoly::zero(var)); + return Ok((UniPoly::zero(var), Integer::from(1u32))); } let mut lcm = Integer::from(1u32); for c in &p.coeffs { @@ -140,7 +148,7 @@ fn ratuni_poly_to_univ(p: &RatUniPoly, var: ExprId) -> Result Result { @@ -369,13 +377,11 @@ pub fn product_definite( pool: &ExprPool, ) -> Result, ProductError> { let rf = expr_to_ratfunc(term, k, pool)?; - if rf.num.is_zero() { - let z = simp(pool, pool.integer(0_i32)); - let mut log = DerivationLog::new(); - log.push(RewriteStep::simple("product_definite_zero", term, z)); - return Ok(DerivedExpr::with_log(z, log)); - } + // The empty product is `1` by universal convention — *including* for a term + // that is identically zero, because no factor is ever taken. The zero test + // has to come second or `∏_{k=1}^{0} 0` reports `0` while `∏_{k=1}^{0} k` + // reports `1`, for the same empty range. if let (Some(lo_i), Some(hi_i)) = (const_i64(pool, lo), const_i64(pool, hi)) { if lo_i > hi_i { let one = simp(pool, pool.integer(1_i32)); @@ -385,8 +391,15 @@ pub fn product_definite( } } - let univ_n = ratuni_poly_to_univ(&rf.num, k)?; - let univ_d = ratuni_poly_to_univ(&rf.den, k)?; + if rf.num.is_zero() { + let z = simp(pool, pool.integer(0_i32)); + let mut log = DerivationLog::new(); + log.push(RewriteStep::simple("product_definite_zero", term, z)); + return Ok(DerivedExpr::with_log(z, log)); + } + + let (univ_n, scale_n) = ratuni_poly_to_univ(&rf.num, k)?; + let (univ_d, scale_d) = ratuni_poly_to_univ(&rf.den, k)?; let fac_n = factor_univ(&univ_n)?; let fac_d = factor_univ(&univ_d)?; @@ -398,10 +411,25 @@ pub fn product_definite( let top = definite_side_from_factorization(pool, &fac_n, lo, hi, delta_n)?; let bot = definite_side_from_factorization(pool, &fac_d, lo, hi, delta_n)?; - let q = simp( - pool, - pool.mul(vec![top, pool.pow(bot, pool.integer(-1_i32))]), - ); + // `term(k) = (scale_d / scale_n) · univ_n(k) / univ_d(k)` exactly, and the + // constant contributes one factor per index, i.e. `c^{hi−lo+1}`. + let c = Rational::from((scale_d, scale_n)); + let q = if c == 1 { + simp( + pool, + pool.mul(vec![top, pool.pow(bot, pool.integer(-1_i32))]), + ) + } else { + let c_e = rational_to_expr(pool, &c); + simp( + pool, + pool.mul(vec![ + pool.pow(c_e, delta_n), + top, + pool.pow(bot, pool.integer(-1_i32)), + ]), + ) + }; let q = fold_pow_one_bases(pool, q); let mut log = DerivationLog::new(); @@ -421,16 +449,34 @@ pub fn product_indefinite( "indefinite product of zero unsupported".into(), )); } - let fac_n = factor_univ(&ratuni_poly_to_univ(&rf.num, k)?)?; - let fac_d = factor_univ(&ratuni_poly_to_univ(&rf.den, k)?)?; + let (univ_n, scale_n) = ratuni_poly_to_univ(&rf.num, k)?; + let (univ_d, scale_d) = ratuni_poly_to_univ(&rf.den, k)?; + let fac_n = factor_univ(&univ_n)?; + let fac_d = factor_univ(&univ_d)?; let top = indefinite_side_from_factorization(pool, &fac_n, k)?; let bot = indefinite_side_from_factorization(pool, &fac_d, k)?; - let q = simp( - pool, - pool.mul(vec![top, pool.pow(bot, pool.integer(-1_i32))]), - ); + // As in `product_definite`: the denominator-clearing scale is part of the + // term. `Z(k) = c^k · Z₀(k)` has ratio `c · Z₀(k+1)/Z₀(k)`, which is the + // term, where `c = scale_d / scale_n`. + let c = Rational::from((scale_d, scale_n)); + let q = if c == 1 { + simp( + pool, + pool.mul(vec![top, pool.pow(bot, pool.integer(-1_i32))]), + ) + } else { + let c_e = rational_to_expr(pool, &c); + simp( + pool, + pool.mul(vec![ + pool.pow(c_e, k), + top, + pool.pow(bot, pool.integer(-1_i32)), + ]), + ) + }; let mut log = DerivationLog::new(); log.push(RewriteStep::simple("product_indefinite", term, q)); @@ -485,6 +531,86 @@ mod tests { assert_eq!(p.value, pool.integer(1_i32)); } + /// `∏_{k=1}^{5} ½ = 2⁻⁵` and `∏_{k=1}^{5} 1/(2k) = 1/3840`, straight from + /// the definition. Both used to come back scaled by a power of the + /// denominator-clearing factor that `ratuni_poly_to_univ` threw away. + #[test] + fn rational_coefficients_keep_their_scale() { + let pool = ExprPool::new(); + let k = pool.symbol("k", Domain::Real); + let one = pool.integer(1_i32); + let five = pool.integer(5_i32); + + let half = pool.rational(Integer::from(1), Integer::from(2)); + let p = product_definite(half, k, one, five, &pool).expect("prod"); + let v = eval_g(p.value, &HashMap::new(), &pool).unwrap(); + assert!((v - 1.0 / 32.0).abs() < 1e-12, "∏ 1/2 over 5 terms: {v}"); + + // 1/(2k): 1/(2·4·6·8·10) = 1/3840. + let two_k = simp(&pool, pool.mul(vec![pool.integer(2_i32), k])); + let term = simp(&pool, pool.pow(two_k, pool.integer(-1_i32))); + let p = product_definite(term, k, one, five, &pool).expect("prod"); + let v = eval_g(p.value, &HashMap::new(), &pool).unwrap(); + assert!((v - 1.0 / 3840.0).abs() < 1e-15, "∏ 1/(2k): {v}"); + + // Wallis: ∏_{k=1}^{6} (2k−1)/(2k) = C(12,6)/4^6 = 924/4096. + let num = simp( + &pool, + pool.add(vec![ + pool.mul(vec![pool.integer(2_i32), k]), + pool.integer(-1), + ]), + ); + let term = simp( + &pool, + pool.mul(vec![num, pool.pow(two_k, pool.integer(-1_i32))]), + ); + let p = product_definite(term, k, one, pool.integer(6_i32), &pool).expect("prod"); + let v = eval_g(p.value, &HashMap::new(), &pool).unwrap(); + let want = 924.0 / 4096.0; + assert!((v - want).abs() < 1e-9 * want, "Wallis at n = 6: {v}"); + } + + /// The multiplicative antiderivative carries the same scale: `Z(k+1)/Z(k)` + /// has to be the term, not the term times a constant. + #[test] + fn indefinite_product_ratio_reproduces_the_term() { + let pool = ExprPool::new(); + let k = pool.symbol("k", Domain::Real); + let two_k = simp(&pool, pool.mul(vec![pool.integer(2_i32), k])); + let term = simp(&pool, pool.pow(two_k, pool.integer(-1_i32))); + let z = product_indefinite(term, k, &pool).expect("prod"); + for ki in 1..=6 { + let at = |x: f64| { + let mut env = HashMap::new(); + env.insert(k, x); + eval_g(z.value, &env, &pool).unwrap() + }; + let ratio = at(ki as f64 + 1.0) / at(ki as f64); + let want = 1.0 / (2.0 * ki as f64); + assert!( + (ratio - want).abs() < 1e-9 * want, + "k={ki}: Z(k+1)/Z(k) = {ratio}, term = {want}" + ); + } + } + + /// The empty product is `1` whatever the term is — including `0`. + #[test] + fn empty_range_is_one_even_for_a_zero_term() { + let pool = ExprPool::new(); + let k = pool.symbol("k", Domain::Real); + let p = product_definite( + pool.integer(0_i32), + k, + pool.integer(1_i32), + pool.integer(0_i32), + &pool, + ) + .expect("prod"); + assert_eq!(p.value, pool.integer(1_i32)); + } + #[test] fn product_linear_k_matches_factorial_gamma() { let pool = ExprPool::new(); diff --git a/alkahest-core/src/sum/recurrence.rs b/alkahest-core/src/sum/recurrence.rs index f664f89e..2424d571 100644 --- a/alkahest-core/src/sum/recurrence.rs +++ b/alkahest-core/src/sum/recurrence.rs @@ -145,6 +145,34 @@ fn solve_order2( return Err(LinearRecurrenceError::OrderUnsupported(2)); } + // Repeated root: the basis is `{rⁿ, n·rⁿ}`, not `{rⁿ, rⁿ}`. Falling through + // to the distinct-root formula divides by `r₁ − r₂ = 0`, so the "closed + // form" contained a `0^{-1}` and evaluated nowhere. + if disc == 0 { + let r = -b.clone() / Rational::from(2); + if r.is_zero() { + return Err(LinearRecurrenceError::OrderUnsupported(2)); + } + let r_expr = rational_atom(pool, &r); + // u(n) = (A + B·n)·rⁿ with u(0) = A and u(1) = (A + B)·r. + let big_a = initials[0]; + let r_u0 = simp(pool, pool.mul(vec![initials[0], r_expr])); + let num_b = simp( + pool, + pool.add(vec![ + initials[1], + pool.mul(vec![r_u0, pool.integer(-1_i32)]), + ]), + ); + let big_b = expr_div(pool, num_b, r_expr); + let coeff = simp(pool, pool.add(vec![big_a, pool.mul(vec![big_b, n])])); + let closed = simp(pool, pool.mul(vec![coeff, pool.pow(r_expr, n)])); + return Ok(RecurrenceSolution { + n, + closed_form: closed, + }); + } + let sqrt_e = sqrt_disc_expr(pool, &disc); let neg_b = rational_atom(pool, &(-b.clone())); let half = rational_atom(pool, &Rational::from((1, 2))); @@ -226,4 +254,32 @@ mod tests { assert!((v - expected as f64).abs() < 1e-6); } } + + /// `u(n+2) = 4u(n+1) − 4u(n)` has the double root `2`, so the closed form is + /// `(A + Bn)·2ⁿ`. The distinct-root formula divides by `r₁ − r₂ = 0` and + /// produced a "closed form" containing `0^{-1}`, which evaluates nowhere. + #[test] + fn repeated_root_has_a_closed_form_that_evaluates() { + let pool = ExprPool::new(); + let n_sym = pool.symbol("n", Domain::Real); + // c0·u(n) + c1·u(n+1) + c2·u(n+2) = 0 with (4, −4, 1): r² − 4r + 4. + let coeffs = vec![Rational::from(4), Rational::from(-4), Rational::from(1)]; + let initials = vec![pool.integer(0_i32), pool.integer(2_i32)]; + let sol = + solve_linear_recurrence_homogeneous(&pool, n_sym, &coeffs, &initials).expect("solve"); + + let mut want = vec![0.0_f64, 2.0]; + for i in 2..=10 { + want.push(4.0 * want[i - 1] - 4.0 * want[i - 2]); + } + for (ni, &expected) in want.iter().enumerate() { + let mut env = HashMap::new(); + env.insert(n_sym, ni as f64); + let v = eval_interp(sol.closed_form, &env, &pool).expect("closed form evaluates"); + assert!( + (v - expected).abs() < 1e-6 * expected.abs().max(1.0), + "u({ni}) = {v}, want {expected}" + ); + } + } } diff --git a/alkahest-core/src/sum/rsolve.rs b/alkahest-core/src/sum/rsolve.rs index 732f88b0..b9dead72 100644 --- a/alkahest-core/src/sum/rsolve.rs +++ b/alkahest-core/src/sum/rsolve.rs @@ -314,6 +314,22 @@ fn extract_recurrence( simp(pool, pool.mul(vec![s, pool.integer(-1_i32)])) }; + // The sequence terms above were re-indexed to lag form: `f(n+o)` became + // `f(n−(max_o−o))`, which is the original equation with `n ↦ n − max_o`. + // The right-hand side lives in the *same* equation and has to be shifted by + // the same amount, or a forward-shift spelling silently solves a different + // problem: `f(n+1) − f(n) = n²` became `f(n) − f(n−1) = n²` instead of + // `f(n) − f(n−1) = (n−1)²`, and `rsolve` returned `Σ_{j=1}^{n} j²` where the + // equation asks for `Σ_{j=0}^{n−1} j²`. + let rhs_expr = if max_o == 0 { + rhs_expr + } else { + let shifted_n = simp(pool, pool.add(vec![n, pool.integer(-max_o)])); + let mut m = HashMap::new(); + m.insert(n, shifted_n); + simp(pool, subs(rhs_expr, &m, pool)) + }; + if contains_seq(rhs_expr, seq_name, pool) { return Err(RsolveError::NotLinearRecurrence( "right-hand side still references the sequence".into(), @@ -779,7 +795,19 @@ fn hom_solution_from_roots( } } -fn order2_r_exprs(pool: &ExprPool, a_rec: &[Rational]) -> Result<(ExprId, ExprId), RsolveError> { +/// The two basis solutions of an order-2 constant-coefficient homogeneous +/// recurrence, as expressions in `n_sym`. +/// +/// Distinct characteristic roots give `(r₁ⁿ, r₂ⁿ)`. A **repeated** root gives +/// `(rⁿ, n·rⁿ)` — not `(rⁿ, rⁿ)`, which is the same function twice and presents +/// a one-parameter family as the general solution of a second-order equation. +/// `f(n+2) − 4f(n+1) + 4f(n) = 0` used to come back as `C₀·2ⁿ + C₁·2ⁿ`, losing +/// the `n·2ⁿ` branch that `(n+2)2ⁿ⁺² − 4(n+1)2ⁿ⁺¹ + 4n·2ⁿ = 0` verifies. +fn order2_basis_exprs( + pool: &ExprPool, + a_rec: &[Rational], + n_sym: ExprId, +) -> Result<(ExprId, ExprId), RsolveError> { let p = char_poly_asc(a_rec); if p.degree() != 2 { return Err(RsolveError::Unsupported( @@ -798,6 +826,19 @@ fn order2_r_exprs(pool: &ExprPool, a_rec: &[Rational]) -> Result<(ExprId, ExprId if disc < 0 { return Err(RsolveError::Unsupported("complex roots".into())); } + if disc == 0 { + let r = -b.clone() / Rational::from(2); + if r == 0 { + // `f(n) = 0` for every `n ≥ 2`: not a two-parameter family at all. + return Err(RsolveError::Unsupported( + "degenerate characteristic (double root at 0)".into(), + )); + } + let re = rational_atom(pool, &r); + let rn = simp(pool, pool.pow(re, n_sym)); + let n_rn = simp(pool, pool.mul(vec![n_sym, rn])); + return Ok((rn, n_rn)); + } let sqrt_e = sqrt_disc_expr(pool, &disc); let neg_b = rational_atom(pool, &(-b.clone())); let half = rational_atom(pool, &Rational::from((1, 2))); @@ -808,7 +849,10 @@ fn order2_r_exprs(pool: &ExprPool, a_rec: &[Rational]) -> Result<(ExprId, ExprId pool.add(vec![neg_b, pool.mul(vec![sqrt_e, pool.integer(-1_i32)])]), ); let r2 = simp(pool, pool.mul(vec![half, inner2])); - Ok((r1, r2)) + Ok(( + simp(pool, pool.pow(r1, n_sym)), + simp(pool, pool.pow(r2, n_sym)), + )) } fn fresh_constants(pool: &ExprPool, k: usize) -> Vec { @@ -863,7 +907,7 @@ fn apply_init( return Err(RsolveError::InitialMismatch("need two integers".into())); } let (n0, n1) = (keys[0], keys[1]); - let (r1_e, r2_e) = order2_r_exprs(pool, a)?; + let (basis0, basis1) = order2_basis_exprs(pool, a, n_sym)?; let v0 = *initials.get(&n0).unwrap(); let v1 = *initials.get(&n1).unwrap(); let p0 = subs_n_int(pool, particular, n_sym, n0); @@ -876,10 +920,10 @@ fn apply_init( pool, pool.add(vec![v1, pool.mul(vec![p1, pool.integer(-1_i32)])]), ); - let a00 = simp(pool, pool.pow(r1_e, pool.integer(n0))); - let b00 = simp(pool, pool.pow(r2_e, pool.integer(n0))); - let a10 = simp(pool, pool.pow(r1_e, pool.integer(n1))); - let b10 = simp(pool, pool.pow(r2_e, pool.integer(n1))); + let a00 = subs_n_int(pool, basis0, n_sym, n0); + let b00 = subs_n_int(pool, basis1, n_sym, n0); + let a10 = subs_n_int(pool, basis0, n_sym, n1); + let b10 = subs_n_int(pool, basis1, n_sym, n1); let det = simp( pool, pool.add(vec![ @@ -983,16 +1027,16 @@ pub fn rsolve( 2 => { let c0 = pool.symbol("C0", crate::kernel::Domain::Real); let c1 = pool.symbol("C1", crate::kernel::Domain::Real); - let (r1, r2) = order2_r_exprs(pool, &a)?; - let term0 = if r1 == pool.integer(1_i32) { + let (basis0, basis1) = order2_basis_exprs(pool, &a, n)?; + let term0 = if basis0 == pool.integer(1_i32) { c0 } else { - simp(pool, pool.mul(vec![c0, pool.pow(r1, n)])) + simp(pool, pool.mul(vec![c0, basis0])) }; - let term1 = if r2 == pool.integer(1_i32) { + let term1 = if basis1 == pool.integer(1_i32) { c1 } else { - simp(pool, pool.mul(vec![c1, pool.pow(r2, n)])) + simp(pool, pool.mul(vec![c1, basis1])) }; let h = simp(pool, pool.add(vec![term0, term1])); (h, vec![c0, c1]) @@ -1054,6 +1098,72 @@ mod tests { assert!(has_sym(sol, "C0", &pool)); } + /// `f(n+1) − f(n) = n²` with `f(0) = 0` is `Σ_{j=0}^{n−1} j²`, not + /// `Σ_{j=1}^{n} j²`. Solving the equation the *lag* spelling would have + /// produced, while reading the right-hand side off the forward spelling, + /// silently answered the other question. + #[test] + fn forward_shift_rhs_is_shifted_with_the_sequence_terms() { + let pool = ExprPool::new(); + let n = pool.symbol("n", Domain::Real); + let f = |args: Vec| pool.func("f", args); + // f(n+1) - f(n) - n^2 = 0 + let eq = simp( + &pool, + pool.add(vec![ + f(vec![pool.add(vec![n, pool.integer(1_i32)])]), + pool.mul(vec![f(vec![n]), pool.integer(-1_i32)]), + pool.mul(vec![pool.pow(n, pool.integer(2_i32)), pool.integer(-1_i32)]), + ]), + ); + let mut init = BTreeMap::new(); + init.insert(0, pool.integer(0_i32)); + let sol = rsolve(&pool, eq, n, "f", Some(&init)).expect("rsolve"); + + // Iterating the given equation from f(0) = 0: 0, 0, 1, 5, 14, 30, 55. + let truth = [0.0_f64, 0.0, 1.0, 5.0, 14.0, 30.0, 55.0]; + for (ni, &want) in truth.iter().enumerate() { + let mut env = HashMap::new(); + env.insert(n, ni as f64); + let v = eval_interp(sol, &env, &pool).expect("eval"); + assert!((v - want).abs() < 1e-9, "f({ni}) = {v}, want {want}"); + } + } + + /// `f(n+2) − 4f(n+1) + 4f(n) = 0` has the double root `r = 2`, so its + /// general solution is `(A + Bn)·2ⁿ`. Returning `C₀·2ⁿ + C₁·2ⁿ` presents a + /// one-parameter family as the general solution of a second-order equation, + /// and then cannot meet two independent initial conditions. + #[test] + fn order_two_repeated_root_keeps_both_branches() { + let pool = ExprPool::new(); + let n = pool.symbol("n", Domain::Real); + let f = |args: Vec| pool.func("f", args); + let eq = simp( + &pool, + pool.add(vec![ + f(vec![pool.add(vec![n, pool.integer(2_i32)])]), + pool.mul(vec![ + f(vec![pool.add(vec![n, pool.integer(1_i32)])]), + pool.integer(-4_i32), + ]), + pool.mul(vec![f(vec![n]), pool.integer(4_i32)]), + ]), + ); + // f(0) = 0, f(1) = 2 selects n·2ⁿ, which the old basis could not express. + let mut init = BTreeMap::new(); + init.insert(0, pool.integer(0_i32)); + init.insert(1, pool.integer(2_i32)); + let sol = rsolve(&pool, eq, n, "f", Some(&init)).expect("rsolve"); + for ni in 0..=8 { + let mut env = HashMap::new(); + env.insert(n, ni as f64); + let v = eval_interp(sol, &env, &pool).expect("eval"); + let want = (ni as f64) * 2.0_f64.powi(ni); + assert!((v - want).abs() < 1e-6, "f({ni}) = {v}, want {want}"); + } + } + #[test] fn fibonacci_numeric_with_init() { use crate::sum::recurrence::solve_linear_recurrence_homogeneous; diff --git a/alkahest-core/tests/groebner_cuda.rs b/alkahest-core/tests/groebner_cuda.rs index 80fb258b..36344f85 100644 --- a/alkahest-core/tests/groebner_cuda.rs +++ b/alkahest-core/tests/groebner_cuda.rs @@ -7,7 +7,9 @@ #![cfg(feature = "groebner-cuda")] -use alkahest_cas::poly::groebner::cuda::{compute_groebner_basis_gpu, MacaulayMatrix}; +use alkahest_cas::poly::groebner::cuda::{ + compute_groebner_basis_gpu, GpuBackendReport, MacaulayMatrix, +}; use alkahest_cas::poly::groebner::f4::compute_groebner_basis; use alkahest_cas::poly::groebner::ideal::GbPoly; use alkahest_cas::poly::groebner::monomial_order::MonomialOrder; @@ -49,7 +51,15 @@ fn poly(terms: &[(&[u32], i64)]) -> GbPoly { /// `groebner-cuda` features — so failing here cannot hold up ordinary PRs. fn gpu_available() -> bool { let requested = std::env::var("ALKAHEST_GPU_TESTS").ok().as_deref() == Some("1"); - let device_ok = cudarc::driver::CudaContext::new(0).is_ok(); + // `cudarc` *panics* (rather than returning `Err`) when `libcuda.so` cannot + // be dlopen'd at all, which is the state of any machine with no driver + // installed. Without the `catch_unwind` this helper aborted the three GPU + // tests on exactly the machines its own doc comment promises to support, + // so `cargo test --features groebner-cuda` could not pass off a GPU box. + // Missing library and missing device must both mean "not available"; only + // a device that was *asserted* to exist and is not usable is a failure. + let device_ok = + std::panic::catch_unwind(|| cudarc::driver::CudaContext::new(0).is_ok()).unwrap_or(false); assert!( !requested || device_ok, "ALKAHEST_GPU_TESTS=1 asserts a CUDA device is present, but none could be \ @@ -58,6 +68,32 @@ fn gpu_available() -> bool { requested && device_ok } +/// Pin the invariant that a `device_id: None` run reports itself as CPU-only. +/// +/// The basis is identical either way, so without this the CPU-fallback tests +/// would pass just as happily against a backend report that lied. +fn assert_no_gpu(backend: &GpuBackendReport) { + assert_eq!(backend.requested_device, None); + assert_eq!(backend.reductions_on_gpu, 0); + assert_eq!(backend.first_gpu_error, None); + assert!( + !backend.ran_on_gpu(), + "no device was requested: {backend:?}" + ); + assert!(backend.fell_back_to_cpu(), "{backend:?}"); +} + +/// The counterpart for the `ALKAHEST_GPU_TESTS=1` tier: a test that claims to +/// exercise the GPU must fail if every row reduction silently landed on the +/// CPU. This is the assertion whose absence let a `Some(0)` run whose driver +/// calls all failed report success — the results are correct either way. +fn assert_ran_on_gpu(backend: &GpuBackendReport) { + assert!( + backend.ran_on_gpu(), + "asked for device 0 but the run did not stay on the GPU: {backend:?}" + ); +} + // --------------------------------------------------------------------------- // Correctness — CPU fallback path (no GPU required) // --------------------------------------------------------------------------- @@ -67,8 +103,10 @@ fn linear_system_two_vars() { // x + y - 1, x - y → solutions x=1/2, y=1/2 let f = poly(&[(&[1, 0], 1), (&[0, 1], 1), (&[0, 0], -1)]); let g = poly(&[(&[1, 0], 1), (&[0, 1], -1)]); - let basis = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) - .expect("groebner failed"); + let (basis, backend) = + compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) + .expect("groebner failed"); + assert_no_gpu(&backend); assert!(!basis.is_empty(), "basis should not be empty"); assert!(cpu_reduce(&f, &basis, MonomialOrder::Lex).is_zero()); assert!(cpu_reduce(&g, &basis, MonomialOrder::Lex).is_zero()); @@ -79,8 +117,10 @@ fn x_squared_minus_1() { // (x^2 - 1, x - 1) should give a size-1 basis {x - 1} let f = poly(&[(&[2], 1), (&[0], -1)]); let g = poly(&[(&[1], 1), (&[0], -1)]); - let basis = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) - .expect("groebner failed"); + let (basis, backend) = + compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) + .expect("groebner failed"); + assert_no_gpu(&backend); assert_eq!(basis.len(), 1); assert!(cpu_reduce(&f, &basis, MonomialOrder::Lex).is_zero()); assert!(cpu_reduce(&g, &basis, MonomialOrder::Lex).is_zero()); @@ -91,8 +131,10 @@ fn circle_line_intersection() { // x^2 + y^2 - 1 = 0, y - x = 0 → two solutions (±√2/2, ±√2/2) let f = poly(&[(&[2, 0], 1), (&[0, 2], 1), (&[0, 0], -1)]); let g = poly(&[(&[0, 1], 1), (&[1, 0], -1)]); - let basis = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) - .expect("groebner failed"); + let (basis, backend) = + compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) + .expect("groebner failed"); + assert_no_gpu(&backend); assert!(!basis.is_empty()); assert!( cpu_reduce(&f, &basis, MonomialOrder::Lex).is_zero(), @@ -109,8 +151,10 @@ fn parabola_line() { // y - x^2 = 0, y - x = 0 → x^2 - x = 0, so x=0 or x=1 let f = poly(&[(&[0, 1], 1), (&[2, 0], -1)]); let g = poly(&[(&[0, 1], 1), (&[1, 0], -1)]); - let basis = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) - .expect("groebner failed"); + let (basis, backend) = + compute_groebner_basis_gpu(vec![f.clone(), g.clone()], MonomialOrder::Lex, None) + .expect("groebner failed"); + assert_no_gpu(&backend); assert!(!basis.is_empty()); assert!(cpu_reduce(&f, &basis, MonomialOrder::Lex).is_zero()); assert!(cpu_reduce(&g, &basis, MonomialOrder::Lex).is_zero()); @@ -121,8 +165,9 @@ fn inconsistent_system_gives_unit_basis() { // (x, x - 1) — inconsistent; Gröbner basis should contain a non-zero constant let f = poly(&[(&[1, 0], 1)]); let g = poly(&[(&[1, 0], 1), (&[0, 0], -1)]); - let basis = + let (basis, backend) = compute_groebner_basis_gpu(vec![f, g], MonomialOrder::Lex, None).expect("groebner failed"); + assert_no_gpu(&backend); // The basis for the unit ideal contains 1 (or an element with no free variables) let has_constant = basis.iter().any(|b| { b.terms.len() == 1 @@ -142,7 +187,9 @@ fn agrees_with_pure_rust_f4_lex() { let g = poly(&[(&[0, 1], 1), (&[1, 0], -1)]); // y - x let order = MonomialOrder::Lex; - let basis_gpu = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], order, None).unwrap(); + let (basis_gpu, backend) = + compute_groebner_basis_gpu(vec![f.clone(), g.clone()], order, None).unwrap(); + assert_no_gpu(&backend); let basis_cpu = compute_groebner_basis(vec![f.clone(), g.clone()], order); // Mutual containment check @@ -168,8 +215,9 @@ fn agrees_with_pure_rust_f4_grevlex() { let h = poly(&[(&[0, 1, 0], 1), (&[0, 0, 1], -1)]); let order = MonomialOrder::GRevLex; - let basis_gpu = + let (basis_gpu, backend) = compute_groebner_basis_gpu(vec![f.clone(), g.clone(), h.clone()], order, None).unwrap(); + assert_no_gpu(&backend); let basis_cpu = compute_groebner_basis(vec![f.clone(), g.clone(), h.clone()], order); for p in &basis_cpu { @@ -226,8 +274,10 @@ fn gpu_linear_system_matches_cpu() { let g = poly(&[(&[1, 0], 1), (&[0, 1], -1)]); let order = MonomialOrder::Lex; - let basis_gpu = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], order, Some(0)) - .expect("GPU groebner failed"); + let (basis_gpu, backend) = + compute_groebner_basis_gpu(vec![f.clone(), g.clone()], order, Some(0)) + .expect("GPU groebner failed"); + assert_ran_on_gpu(&backend); let basis_cpu = compute_groebner_basis(vec![f.clone(), g.clone()], order); for p in &basis_cpu { @@ -247,7 +297,9 @@ fn gpu_circle_line_matches_cpu() { let g = poly(&[(&[0, 1], 1), (&[1, 0], -1)]); let order = MonomialOrder::GRevLex; - let basis_gpu = compute_groebner_basis_gpu(vec![f.clone(), g.clone()], order, Some(0)).unwrap(); + let (basis_gpu, backend) = + compute_groebner_basis_gpu(vec![f.clone(), g.clone()], order, Some(0)).unwrap(); + assert_ran_on_gpu(&backend); let basis_cpu = compute_groebner_basis(vec![f.clone(), g.clone()], order); for p in &basis_cpu { diff --git a/alkahest-py/Cargo.toml b/alkahest-py/Cargo.toml index 7cac5e51..6191159c 100644 --- a/alkahest-py/Cargo.toml +++ b/alkahest-py/Cargo.toml @@ -14,7 +14,11 @@ default = ["egraph", "groebner"] egraph = ["alkahest-core/egraph"] jit = ["alkahest-core/jit"] cranelift = ["alkahest-core/cranelift"] -numpy = ["dep:numpy"] +# There is deliberately no `numpy` feature. It gated the `numpy` crate, which +# this crate never used a single item from, and `capabilities()` reported it as +# a capability that therefore meant nothing: `alkahest.numpy_eval` works on the +# default (feature-off) build because it goes through the buffer protocol on +# `CompiledFn.call_batch_into`, which needs no NumPy binding at all. parallel = ["alkahest-core/parallel"] groebner = ["alkahest-core/groebner"] groebner-cuda = ["alkahest-core/groebner-cuda"] @@ -23,7 +27,6 @@ cuda = ["alkahest-core/cuda"] [dependencies] alkahest-core = { path = "../alkahest-core", package = "alkahest-cas" } pyo3.workspace = true -numpy = { version = "0.21", optional = true } rug = { version = "1", features = ["integer", "rational"] } [dev-dependencies] diff --git a/alkahest-py/src/lib.rs b/alkahest-py/src/lib.rs index 9133e5a2..bf98fab7 100644 --- a/alkahest-py/src/lib.rs +++ b/alkahest-py/src/lib.rs @@ -172,6 +172,7 @@ use alkahest_core::real::sos::{ }; // P1 item 7 — creative telescoping / holonomic (D-finite) machinery use alkahest_core::holonomic::{ + boundary_side_condition as core_boundary_side_condition, boundary_term as core_boundary_term, zeilberger as core_zeilberger, HolonomicError as CoreHolonomicError, ZeilbergerOpts as CoreZeilbergerOpts, }; @@ -655,12 +656,62 @@ fn py_is_cancelled() -> bool { } fn series_error_to_py(e: SeriesError) -> PyErr { + // The series engine reports "the requested order is out of reach" as + // `InvalidOrder` (`SeriesError` is an exhaustive public enum, so it cannot + // carry a `Truncated` variant without a major semver break) and records the + // cause out of band. Recover it here so a work-ceiling trip raises + // `E-SERIES-003` and a budget trip raises the same `BudgetExceededError` + // (`E-BUDGET-*`) every other engine raises, instead of masquerading as + // "you passed order 0". + if matches!(e, SeriesError::InvalidOrder) { + if let Some(r) = alkahest_core::calculus::series::take_series_refusal() { + return Python::with_gil(|py| match r.budget() { + Some(b) => { + let exc_type = py.get_type_bound::(); + make_structured_err(py, &exc_type, &b) + } + None => { + 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) }) } +/// Convert a `PrimaryDecompositionError` into a Python exception, recovering a +/// refusal recorded out of band. +/// +/// `radical` and `primary_decomposition` report "I cannot certify this" as +/// `PrimaryDecompositionError::Factorization` (the enum is public and +/// exhaustive, so it cannot grow a `NotCertifiable` variant without a major +/// semver break) and record the real reason for `take_ideal_refusal`. Recover +/// it here so a refusal raises its own `E-IDEAL-005` / `E-IDEAL-006` instead of +/// an uncoded `ValueError` that autoresearch loops cannot branch on. +/// +/// `PyAlkahestError` subclasses `ValueError`, so callers catching `ValueError` +/// are unaffected. +#[cfg(feature = "groebner")] +fn ideal_error_to_py(e: alkahest_core::ideal::PrimaryDecompositionError) -> PyErr { + use alkahest_core::ideal::PrimaryDecompositionError; + if matches!(e, PrimaryDecompositionError::Factorization(_)) { + if let Some(r) = alkahest_core::ideal::take_ideal_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) + }) +} + fn limit_error_to_py(e: LimitError) -> PyErr { // The limit engine reports a budget/cancellation trip as `DepthExceeded` // (`LimitError` is an exhaustive public enum, so it cannot carry a @@ -696,7 +747,23 @@ fn parse_limit_direction(dir: Option<&str>) -> CoreLimitDirection { /// [`alkahest_core::InterpEvalError`], which is intentionally lightweight /// since it's an interpreter-internal detail, not a user-facing subsystem). fn domain_error(py: Python<'_>, code: &str, message: String, remediation: &str) -> PyErr { - let exc_type = py.get_type_bound::(); + coded_error::(py, code, message, remediation) +} + +/// [`domain_error`] for any other exception class in the hierarchy. +/// +/// Same shape as [`make_structured_err`] — `[CODE] message`, plus `.code`, +/// `.remediation` and `.span` attributes — for failures raised at the Python +/// boundary, where there is no Rust error type implementing `AlkahestError` to +/// hand it. The alternative is letting whatever PyO3 happened to produce +/// escape, which is how `residue` came to raise a bare `AttributeError`: not +/// an `AlkahestError`, so invisible to `except ak.AlkahestError`, and carrying +/// no code for a caller to branch on. +fn coded_error(py: Python<'_>, code: &str, message: String, remediation: &str) -> PyErr +where + E: pyo3::type_object::PyTypeInfo, +{ + let exc_type = py.get_type_bound::(); let full_msg = format!("[{code}] {message}\nRemediation: {remediation}"); let exc = exc_type.call1((full_msg,)).unwrap(); exc.setattr("code", code).ok(); @@ -3513,8 +3580,53 @@ fn py_apart(py: Python<'_>, expr: PyRef, var: PyRef) -> PyResult Ok(PyExpr { id, pool: pool_py }) } +/// `ResidueError` has an inherent `code()` but does not implement the +/// `AlkahestError` trait, so this cannot go through [`make_structured_err`]. +/// It still has to produce an `AlkahestError` subclass carrying `.code`: +/// raising a bare `ValueError` with the code glued into the message made the +/// code unreadable except by string-matching. `AlkahestError` subclasses +/// `ValueError`, so `except ValueError` keeps working. fn residue_error_to_py(e: ResidueError) -> PyErr { - pyo3::exceptions::PyValueError::new_err(format!("{} ({})", e, e.code())) + Python::with_gil(|py| { + coded_error::( + py, + e.code(), + e.to_string(), + match e { + ResidueError::NotRational => { + "input must be a rational function of the variable over ℚ" + } + ResidueError::ZeroDenominator => "denominator must be non-zero", + ResidueError::PoleOrderTooHigh { .. } => { + "pole order exceeds supported bound; essential singularities are out of scope" + } + ResidueError::DivisionByZero => { + "division by zero during Laurent coefficient extraction" + } + }, + ) + }) +} + +/// `residue(f, z, point)` was handed a `point` that is not an exact constant. +/// +/// `E-RESIDUE-005` is raised only at the Python boundary — the Rust `residue` +/// takes an already-parsed `GaussRat` and cannot reach this state — so it is +/// deliberately absent from `alkahest-core`'s `REGISTRY`, on the same footing +/// as `E-SMT-001`/`E-SMT-003`/`E-SMT-004` in `alkahest/smt.py` and +/// `E-BATCH-001` in `alkahest/_batch.py`. +fn residue_point_error(py: Python<'_>, point: &Bound<'_, PyAny>) -> PyErr { + coded_error::( + py, + "E-RESIDUE-005", + format!( + "residue: the point must be an exact constant in ℚ(i), got {}", + py_type_name(point) + ), + "pass an int, a fractions.Fraction, a complex with integral parts, or a \ + (re, im) pair of rationals. A symbolic Expr is not accepted — residue \ + evaluates at one point, so substitute a value first", + ) } fn rational_from_py(ob: &Bound<'_, PyAny>) -> PyResult { if let Ok(i) = ob.extract::() { @@ -3584,7 +3696,7 @@ fn py_residue( point: &Bound<'_, PyAny>, ) -> PyResult { let pool_py = expr.pool.clone_ref(py); - let gauss = parse_gauss_point(point)?; + let gauss = parse_gauss_point(point).map_err(|_| residue_point_error(py, point))?; let id = { let pool = pool_py.borrow(py); guard_depth(&pool.inner, expr.id)?; @@ -3597,6 +3709,20 @@ fn apart_error_to_py(e: ApartError) -> PyErr { pyo3::exceptions::PyValueError::new_err(e.to_string()) } +/// `alkahest.series(expr, var, point, order) -> Series` +/// +/// Truncated Taylor / Laurent expansion of *expr* in *var* about *point*, with +/// an explicit ``O(h^order)`` remainder. +/// +/// The expansion is **bounded**: it honours an active :class:`alkahest.Budget` +/// and, with none, an internal work ceiling. Coefficients are formed by +/// repeated differentiation without re-simplifying, so an expression whose +/// derivatives do not close (nested radicals, in particular) grows by a +/// constant factor per coefficient and a high order is unreachable rather than +/// slow. Running out of room raises :exc:`alkahest.SeriesError` with code +/// ``E-SERIES-003`` (or :exc:`alkahest.BudgetExceededError` when a budget +/// stopped it) — never a shorter series, which would wear an ``O(·)`` label +/// nothing bounded. #[pyfunction] #[pyo3(name = "series")] fn py_series( @@ -3609,10 +3735,14 @@ fn py_series( let pool_py = expr.pool.clone_ref(py); let point_id = coerce_substituent(&pool_py, point, py)?; let id = { - let pool = pool_py.borrow(py); - guard_depth(&pool.inner, expr.id)?; + let pool_ref = pool_py.borrow(py); + guard_depth(&pool_ref.inner, expr.id)?; checked_order("series order", order as usize)?; - core_series(expr.id, var.id, point_id, order, &pool.inner) + // GIL released for the core call, like `limit` and `integrate`: the + // coefficient loop honours `Budget`, and a `request_cancel()` from + // another Python thread cannot reach it while this one holds the GIL. + let (id, var_id, pool) = (expr.id, var.id, &pool_ref.inner); + py.allow_threads(|| core_series(id, var_id, point_id, order, pool)) .map_err(series_error_to_py)? .expr() }; @@ -4421,15 +4551,29 @@ fn holonomic_error_to_py(e: CoreHolonomicError) -> PyErr { /// /// ``Σ_i a_i(n)·F(n+i, k) = G(n, k+1) − G(n, k)`` with ``G = R·F``. /// -/// Summing over ``k`` telescopes the right-hand side, so ``S(n) = Σ_k F(n,k)`` -/// satisfies ``Σ_i a_i(n)·S(n+i) = 0``. The identity is re-checked exactly -/// before this object is constructed — a returned certificate is a proof, not a -/// numerical match. +/// That identity is re-checked exactly before this object is constructed — a +/// returned certificate is a proof, not a numerical match. +/// +/// **It is an identity in ``k``, and only that.** Summing it over +/// ``k = k_lo .. k_hi`` telescopes the right-hand side to a *boundary +/// difference*: +/// +/// ``Σ_i a_i(n)·S(n+i) = G(n, k_hi+1) − G(n, k_lo)`` for ``S(n) = Σ_k F(n,k)``. +/// +/// The familiar homogeneous recurrence ``Σ_i a_i(n)·S(n+i) = 0`` therefore needs +/// that difference to vanish — the *natural boundary* hypothesis, which +/// Zeilberger's algorithm does not establish. It holds in the usual case (``F`` +/// vanishing outside ``0 ≤ k ≤ n``) and fails for e.g. ``F = C(n,k)/(k+1)``, +/// where ``G(n,0) = −1`` and ``(n+2)·S(n+1) − (2n+2)·S(n) = 1``. +/// +/// :attr:`side_conditions` states the hypothesis and :attr:`boundary_term` +/// returns ``G(n,k)`` so a caller can discharge it for their own range. #[pyclass(name = "ZeilbergerCertificate")] struct PyZeilbergerCertificate { order: usize, coeff_ids: Vec, certificate_id: ExprId, + boundary_id: ExprId, pool: Py, derivation: String, } @@ -4464,6 +4608,32 @@ impl PyZeilbergerCertificate { } } + /// ``G(n, k) = R(n, k)·F(n, k)`` — the telescoped quantity. + /// + /// The recurrence for a *sum* over ``k = k_lo .. k_hi`` is + /// ``Σ_i a_i(n)·S(n+i) = G(n, k_hi+1) − G(n, k_lo)``; substitute the two + /// endpoints here to find out whether that difference vanishes, which is the + /// hypothesis listed in :attr:`side_conditions`. + #[getter] + fn boundary_term(&self, py: Python<'_>) -> PyExpr { + let _ = py; + PyExpr { + id: self.boundary_id, + pool: self.pool.clone_ref(py), + } + } + + /// Hypotheses the certificate does **not** establish, as plain strings. + /// + /// Mirrors ``DerivedResult.verification["side_conditions"]``: the certificate + /// is a proof of the telescoping identity in ``k``, and everything that has + /// to be assumed on top of it in order to read off a recurrence for the sum + /// is listed here rather than left unsaid. + #[getter] + fn side_conditions(&self) -> Vec { + vec![core_boundary_side_condition().to_string()] + } + /// Human-readable derivation log for the search that produced this. #[getter] fn derivation(&self) -> String { @@ -4493,6 +4663,14 @@ impl PyZeilbergerCertificate { /// ``Σ_i a_i(n)·F(n+i,k) = ΔG`` with ``G = R·F`` is re-checked as an exact /// identity in ``Q(n)(k)`` before it is returned. /// +/// The verified statement is that identity in ``k``. Reading a recurrence for +/// ``S(n) = Σ_k F(n,k)`` off it additionally requires the boundary difference +/// ``G(n, k_hi+1) − G(n, k_lo)`` to vanish over the summation range; that +/// hypothesis is *not* checked here and is reported on the returned object as +/// :attr:`~alkahest.ZeilbergerCertificate.side_conditions`, with +/// :attr:`~alkahest.ZeilbergerCertificate.boundary_term` giving the ``G`` needed +/// to discharge it. +/// /// Raises :exc:`alkahest.HolonomicError` rather than guessing when ``term`` is /// outside the proper hypergeometric class (``E-HOLO-001``) or when the bounded /// search is exhausted (``E-HOLO-002``). @@ -4511,16 +4689,18 @@ fn py_zeilberger( max_order, max_degree, }; - let (order, coeff_ids, certificate_id, derivation) = { + let (order, coeff_ids, certificate_id, boundary_id, derivation) = { let pool = pool_py.borrow(py); let derived = core_zeilberger(term.id, n.id, k.id, &pool.inner, &opts) .map_err(holonomic_error_to_py)?; let derivation = derived.log.display_with(&pool.inner).to_string(); let value = derived.value; + let boundary = core_boundary_term(&value, term.id, &pool.inner); ( value.order, value.coeffs.clone(), value.certificate, + boundary, derivation, ) }; @@ -4528,6 +4708,7 @@ fn py_zeilberger( order, coeff_ids, certificate_id, + boundary_id, pool: pool_py, derivation, }) @@ -6563,6 +6744,24 @@ fn py_jit_is_available() -> bool { /// /// This reports the installed artifact, not the project defaults or the /// availability of Python fallback functions. +/// +/// # Every key must name something a caller can reach +/// +/// A capability bit exists so an agent can decide what to use without probing. +/// That makes an unreachable `true` the same class of defect as a silent wrong +/// answer, and a bit that correlates with nothing at all only marginally +/// better. Two keys were dropped in 3.8 for failing that test, and +/// `tests/test_agent_contract.py::test_every_advertised_feature_has_an_entry_point` +/// now walks this map and refuses any key without a named entry point: +/// +/// - `groebner_cuda` reported `--features groebner-cuda`, but the GPU Gröbner +/// kernel has no PyO3 binding at all — `GroebnerBasis` exposes only CPU +/// methods and `compute_groebner_basis_gpu` is reachable from Rust only. No +/// Python observation could distinguish `true` from `false`. +/// - `numpy` reported a Cargo feature that gated the `numpy` crate, which +/// this crate never used. `alkahest.numpy_eval` works through the buffer +/// protocol regardless, so the bit was `false` on every build that shipped +/// and predicted nothing about NumPy support either way. #[pyfunction] #[pyo3(name = "_build_features")] fn py_build_features() -> std::collections::HashMap { @@ -6571,14 +6770,21 @@ fn py_build_features() -> std::collections::HashMap { ("groebner", cfg!(feature = "groebner")), // Retain the Cargo feature names for compatibility and expose // backend-specific names so callers need not infer what `jit` means. - ("jit", cfg!(feature = "jit")), + // `cuda` implies `alkahest-core/jit` (see alkahest-core's Cargo.toml: + // `cuda = ["jit", "dep:cudarc"]`), so a build with `--features cuda` + // links the LLVM backend even though *this* crate's own `jit` feature + // is off. Reporting `cfg!(feature = "jit")` alone therefore said + // `llvm_jit: false` on a build that demonstrably emits NVPTX — the + // capability contract has to describe what is linked, not which flag + // the caller happened to name. + ("jit", cfg!(feature = "jit") || cfg!(feature = "cuda")), ("cranelift", cfg!(feature = "cranelift")), - ("llvm_jit", cfg!(feature = "jit")), + ("llvm_jit", cfg!(feature = "jit") || cfg!(feature = "cuda")), ("cranelift_jit", cfg!(feature = "cranelift")), ("parallel", cfg!(feature = "parallel")), - ("numpy", cfg!(feature = "numpy")), + // `cuda` stays: it is falsifiable. `true` guarantees `ak.compile_cuda` + // and `ak.CudaCompiledFn` exist, `false` guarantees they do not. ("cuda", cfg!(feature = "cuda")), - ("groebner_cuda", cfg!(feature = "groebner-cuda")), ] .into_iter() .map(|(name, enabled)| (name.to_string(), enabled)) @@ -7213,12 +7419,44 @@ impl PyEvaluationResult { } } +/// The Python type name of `value`, for error messages. Never fails: a type +/// whose `__name__` cannot be read is reported as `` rather than +/// replacing the caller's real error with a second one. +fn py_type_name(value: &Bound<'_, PyAny>) -> String { + value + .get_type() + .name() + .map(|n| n.to_string()) + .unwrap_or_else(|_| "".to_string()) +} + +/// `str(value.)`, or `None` if the attribute is missing or unreadable. +fn attr_as_string(value: &Bound<'_, PyAny>, attr: &str) -> Option { + value.getattr(attr).ok()?.str().ok()?.extract().ok() +} + fn exact_binding(value: &Bound<'_, PyAny>) -> PyResult { if let Ok(integer) = value.extract::() { return Ok(Rational::from(integer)); } - let numerator: String = value.getattr("numerator")?.str()?.extract()?; - let denominator: String = value.getattr("denominator")?.str()?.extract()?; + // `value.getattr("numerator")?` used to propagate a bare `AttributeError` + // for anything that is neither an int nor a `Fraction` — including an + // `Expr`, which is the obvious thing to pass as `residue(f, z, point)`. + // That error named this function's implementation rather than the caller's + // mistake, and `AttributeError` is not an `AlkahestError`, so it escaped + // `except ak.AlkahestError` entirely. Probe instead of propagate. + let (numerator, denominator) = match ( + attr_as_string(value, "numerator"), + attr_as_string(value, "denominator"), + ) { + (Some(n), Some(d)) => (n, d), + _ => { + return Err(PyTypeError::new_err(format!( + "exact bindings must be int or fractions.Fraction, got {}", + py_type_name(value) + ))) + } + }; let numerator = Integer::parse(numerator) .map_err(|_| PyTypeError::new_err("exact bindings must be int or fractions.Fraction"))? .complete(); @@ -9320,8 +9558,39 @@ impl PyCudaCompiledFn { /// ``inputs`` is a list of length ``n_inputs``. Each entry is a 1-D sequence /// of ``N`` values for that variable (column-major / SoA: one array per /// symbolic input). Returns a Python ``list`` of ``N`` outputs. + /// + /// Equivalent to ``call_batch_on(0, inputs)``. #[pyo3(name = "call_batch")] fn call_batch_py(&self, inputs: &Bound<'_, PyList>) -> PyResult> { + self.eval_on_device(0, inputs) + } + + /// Evaluate the compiled kernel on a specific CUDA device ordinal. + /// + /// Same contract as :meth:`call_batch`, which is this method with + /// ``device = 0``. The PTX is device-independent — it is generated once and + /// each device gets its own lazily-loaded module — so the same + /// ``CudaCompiledFn`` can be driven across every device on the host. + /// + /// `alkahest-core` has always been able to target a chosen device + /// (`CudaCompiledFn::call_batch_on`, exercised by + /// `nvptx_gpu::nvptx_multi_device_both_3090s`), but the binding only ever + /// exposed device 0, so on a multi-GPU host every device but the first was + /// unreachable from Python. + /// + /// Raises :class:`CudaError` if *device* is not a valid ordinal on this + /// host. + #[pyo3(name = "call_batch_on")] + fn call_batch_on_py(&self, device: usize, inputs: &Bound<'_, PyList>) -> PyResult> { + self.eval_on_device(device, inputs) + } +} + +#[cfg(feature = "cuda")] +impl PyCudaCompiledFn { + /// Shared body of `call_batch` / `call_batch_on`: validate the SoA input + /// columns, then dispatch to the requested device ordinal. + fn eval_on_device(&self, device: usize, inputs: &Bound<'_, PyList>) -> PyResult> { if inputs.len() != self.inner.n_inputs { return Err(pyo3::exceptions::PyValueError::new_err(format!( "expected {} input columns, got {}", @@ -9342,12 +9611,14 @@ impl PyCudaCompiledFn { } let col_refs: Vec<&[f64]> = cols.iter().map(|c| c.as_slice()).collect(); let mut out = vec![0.0f64; n_pts]; - self.inner.call_batch(&col_refs, &mut out).map_err(|e| { - Python::with_gil(|py2| { - let exc_type = py2.get_type_bound::(); - make_structured_err(py2, &exc_type, &e) - }) - })?; + self.inner + .call_batch_on(device, &col_refs, &mut out) + .map_err(|e| { + Python::with_gil(|py2| { + let exc_type = py2.get_type_bound::(); + make_structured_err(py2, &exc_type, &e) + }) + })?; Ok(out) } } @@ -9834,8 +10105,7 @@ fn py_primary_decomposition( gb_polys.push(gbp); } drop(pool); - let comps = primary_decomposition(gb_polys, MonomialOrder::Lex) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let comps = primary_decomposition(gb_polys, MonomialOrder::Lex).map_err(ideal_error_to_py)?; let mut out = Vec::with_capacity(comps.len()); for c in comps { out.push(Py::new( @@ -9902,8 +10172,7 @@ fn py_ideal_radical( gb_polys.push(gbp); } drop(pool); - let gb = core_ideal_radical(gb_polys, MonomialOrder::Lex) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let gb = core_ideal_radical(gb_polys, MonomialOrder::Lex).map_err(ideal_error_to_py)?; Py::new( py, PyGroebnerBasis { @@ -10255,8 +10524,19 @@ fn py_solve_numerical( /// list[dict] /// Each dict maps a variable ``Expr`` to ``Expr`` (symbolic Groebner) or /// ``float`` (Groebner with ``numeric=True``, or ``method="homotopy"``). +/// Solutions are a *set*: a double root is one entry, not two. Every +/// parameter-free tuple has been substituted back into the equations you +/// passed and could not be shown to violate them. A tuple containing a free +/// parameter is **not** a number and is returned unverified, under the +/// non-vanishing hypotheses reported by +/// :func:`alkahest.solve_side_conditions` — ``solve([a*x - b], [x])`` is +/// ``b/a`` *for* ``a ≠ 0``, and that condition is listed there. /// GroebnerBasis -/// When ``method="groebner"`` and the ideal is parametric / not zero-dim finite. +/// When ``method="groebner"`` and no finite solution list could be +/// produced — usually a positive-dimensional ideal, but also when the +/// Lex basis admits no complete triangular elimination in *vars*. It is +/// "here is the ideal" rather than a claim that the solutions are +/// infinite. #[cfg(feature = "groebner")] #[pyfunction] #[pyo3(name = "solve", signature = (equations, vars, *, numeric = false, method = "groebner"))] @@ -10277,6 +10557,9 @@ fn py_solve( 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)?; + // `solve_side_conditions()` must describe *this* call, including the paths + // below that never reach the symbolic solver (homotopy, transcendental). + reset_solve_side_conditions(); if method == "homotopy" { let opts = HomotopyOpts::default(); @@ -10330,7 +10613,12 @@ fn py_solve( let result = { let pool = pool_py.borrow(py); - solve_polynomial_system(eq_ids.clone(), var_ids.clone(), &pool.inner) + let r = solve_polynomial_system(eq_ids.clone(), var_ids.clone(), &pool.inner); + // Whatever the back-substitution had to assume about a parametric + // leading coefficient, rendered while the pool is in hand — see + // `py_solve_side_conditions`. + capture_solve_side_conditions(&pool.inner); + r }; // B5: `numeric=True` means the caller accepts floats — when Lex back-substitution @@ -10366,6 +10654,61 @@ fn py_solve( finite_solutions_to_py(py, result, &pool_py, &var_ids, numeric) } +#[cfg(feature = "groebner")] +thread_local! { + /// Hypotheses recorded by the most recent `solve` on this thread, rendered + /// against the pool that call used. + static SOLVE_SIDE_CONDITIONS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Reset both ends of the side-condition channel at the start of a `solve`. +#[cfg(feature = "groebner")] +fn reset_solve_side_conditions() { + let _ = alkahest_core::solver::take_solve_side_conditions(); + SOLVE_SIDE_CONDITIONS.with(|c| c.borrow_mut().clear()); +} + +/// Render the hypotheses the core solver just assumed, for +/// [`py_solve_side_conditions`]. +#[cfg(feature = "groebner")] +fn capture_solve_side_conditions(pool: &alkahest_core::ExprPool) { + let rendered: Vec = alkahest_core::solver::take_solve_side_conditions() + .iter() + .map(|c| c.display_with(pool).to_string()) + .collect(); + SOLVE_SIDE_CONDITIONS.with(|c| *c.borrow_mut() = rendered); +} + +/// `alkahest.solve_side_conditions() -> list[str]` +/// +/// The hypotheses the most recent :func:`alkahest.solve` on this thread +/// **assumed** in order to return the solutions it did — one string per +/// condition, e.g. ``"a ≠ 0"``. +/// +/// ``solve([a*x - b], [x])`` returns ``b/a``, which is the solution *for +/// ``a ≠ 0``*: at ``a = 0`` the equation reads ``-b = 0``, so there is either no +/// solution (``b ≠ 0``) or every ``x`` (``b = 0``), and neither of those is +/// ``b/a``. That generic-parameter reading is deliberate and useful, but a +/// parametric tuple is not a number, so it is returned **unverified** — nothing +/// substitutes it back — and the hypothesis is the only signal a caller can +/// audit. +/// +/// This mirrors ``DerivedResult.verification["side_conditions"]`` and +/// :attr:`alkahest.ZeilbergerCertificate.side_conditions`; ``solve`` returns +/// plain ``dict`` s, which cannot carry the attribute, so it is reported beside +/// the result instead. +/// +/// An empty list means the solver *proved* every coefficient it divided by to +/// be non-zero — not that it did not look. Reset by each ``solve`` call, so +/// read it before the next one; repeated reads of the same call agree. +#[cfg(feature = "groebner")] +#[pyfunction] +#[pyo3(name = "solve_side_conditions")] +fn py_solve_side_conditions() -> Vec { + SOLVE_SIDE_CONDITIONS.with(|c| c.borrow().clone()) +} + /// Shared formatting for a [`SolutionSet`] result into the Python return shape /// (list of dicts, a `GroebnerBasis`, or a structured error). #[cfg(feature = "groebner")] @@ -10501,6 +10844,17 @@ fn py_triangularize( match result { Err(e) => Python::with_gil(|py2| { let exc_type = py2.get_type_bound::(); + // `triangularize` reports "this needs a splitting decomposition" as + // `NotPolynomial` (the enum is public and exhaustive) and records the + // real reason out of band. Recover it so the refusal raises its own + // `E-SOLVE-004` rather than `E-SOLVE-001`, which means something else + // entirely — a genuinely non-polynomial equation. + if matches!(e, alkahest_core::SolverError::NotPolynomial(_)) { + if let Some(r) = alkahest_core::solver::regular_chains::take_triangularize_refusal() + { + return Err(make_structured_err(py2, &exc_type, &r)); + } + } Err(make_structured_err(py2, &exc_type, &e)) }), Ok(chains) => { @@ -11127,6 +11481,7 @@ fn alkahest(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(py_solve_numerical, m)?)?; m.add_function(wrap_pyfunction!(py_diophantine, m)?)?; m.add_function(wrap_pyfunction!(py_solve, m)?)?; + m.add_function(wrap_pyfunction!(py_solve_side_conditions, m)?)?; m.add_function(wrap_pyfunction!(py_triangularize, m)?)?; m.add_function(wrap_pyfunction!(py_primary_decomposition, m)?)?; m.add_function(wrap_pyfunction!(py_ideal_radical, m)?)?; diff --git a/alkahest-skill/alkahest.md b/alkahest-skill/alkahest.md index 3d1f1711..8172e405 100644 --- a/alkahest-skill/alkahest.md +++ b/alkahest-skill/alkahest.md @@ -49,7 +49,7 @@ ak.jit_is_available() Since 3.6.0 the default wheel already has a JIT (Cranelift), so `+jit` / `+full` now buy you only the **LLVM** backend and (for `+full`) parallel F4 — not "JIT vs no JIT". Most agent code does not need them. -**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. +**Why a separate index or direct wheel URL:** feature-heavy wheels use a PEP 440 **local version** (for example `3.8.0+jit` or `3.8.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.8.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. @@ -63,13 +63,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/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" +pip install "https://github.com/alkahest-cas/alkahest/releases/download/v3.8.0/alkahest-3.8.0+full-cp311-cp311-linux_x86_64.whl" +pip install "https://github.com/alkahest-cas/alkahest/releases/download/v3.8.0/alkahest-3.8.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). -If your client chokes on `+` in the URL, use percent-encoding (`3.7.0%2Bfull` in the filename segment). +If your client chokes on `+` in the URL, use percent-encoding (`3.8.0%2Bfull` in the filename segment). After installing `+jit` or `+full`, `capabilities()["features"]["llvm_jit"]` should be `True` (`jit_is_available()` is already `True` on the default wheel via Cranelift, so it does not distinguish the builds — check `llvm_jit` / `parallel` instead). Gröbner-backed APIs such as `alkahest.solve` are available in **all** wheels (including the default PyPI wheel) since `groebner` became a default feature. @@ -78,7 +78,7 @@ After installing `+jit` or `+full`, `capabilities()["features"]["llvm_jit"]` sho **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==3.7.0+full' --extra-index-url https://EXAMPLE/alkahest-extras/simple +pip install 'alkahest==3.8.0+full' --extra-index-url https://EXAMPLE/alkahest-extras/simple ``` ### From source @@ -108,7 +108,17 @@ pip install maturin maturin develop --manifest-path alkahest-py/Cargo.toml --release --features "parallel egraph jit groebner" ``` -Optional Cargo features: `parallel` (sharded pool + parallel F4 + `numpy_eval_par`), `egraph` (vendored egglog backend; **default** in PyPI wheels), `groebner` (Gröbner solver + Diophantine + homotopy; **default** in both the Rust crate and PyPI wheels), `cranelift` (pure-Rust Tier-1 JIT; **shipped in PyPI wheels** but *not* in the Cargo `default` set — pass it explicitly in a source build), `jit` (LLVM JIT), `cuda` (NVPTX codegen). +Optional Cargo features: `parallel` (sharded pool + parallel F4 + `numpy_eval_par`), `egraph` (vendored egglog backend; **default** in PyPI wheels), `groebner` (Gröbner solver + Diophantine + homotopy; **default** in both the Rust crate and PyPI wheels), `cranelift` (pure-Rust Tier-1 JIT; **shipped in PyPI wheels** but *not* in the Cargo `default` set — pass it explicitly in a source build), `jit` (LLVM JIT), `cuda` (NVPTX codegen — needs LLVM 15 with the NVPTX target), `groebner-cuda` (CUDA Macaulay-matrix kernel — needs only `cudarc`). + +**GPU:** neither CUDA feature is in any published wheel, so `pip install alkahest` has +no GPU support. On a `--features cuda` source build, `ak.compile_cuda(expr, [x, y])` +returns a `CudaCompiledFn` with `.ptx` / `.n_inputs` / `.call_batch([xs, ys])`; the +name does not exist otherwise, so branch on +`ak.capabilities()["features"]["cuda"]` rather than calling it and catching +`AttributeError`. There is **no** `features["groebner_cuda"]` key: the CUDA +Macaulay-matrix kernel has no Python binding at all, so the bit could neither +be confirmed nor refuted from Python and was removed in contract v3. Nothing +about `solve` changes on a `--features groebner-cuda` build. ### Rust crate @@ -227,7 +237,7 @@ yourself if you need the proof checked. |---|---| | `diff` | Yes — chain rule, log/sqrt/tan, quotient | | `integrate` (indefinite) | Yes | -| `integrate` (definite) | Yes, **since 3.7.0** (Mathlib FTC / interval-integral lemmas) | +| `integrate` (definite) | Yes, **since 3.8.0** (Mathlib FTC / interval-integral lemmas) | | exp/log identities | Yes, assumption-gated | Certificates that do not typecheck are **withheld** rather than emitted broken, so @@ -704,7 +714,7 @@ R.to_list() # list[list[Expr]] ### Arithmetic `*` is the **matrix product** (SymPy convention), not elementwise — use `hadamard` -for elementwise. Since 3.7.0: +for elementwise. Since 3.8.0: ```python A * B # matrix product (same as A.multiply(B)) @@ -801,7 +811,7 @@ code `E-LINALG-010` (the code names what could not be decided, not the wrapper). 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 +Symbolic eigenvalues are closed-form for 2×2 and, since 3.8.0, for parametric 3×3 matrices whose characteristic polynomial is an irreducible cubic (Cardano / trigonometric path). @@ -871,7 +881,7 @@ Other experimental exports worth knowing: `asymptotic_expand`, `multilimit`, `series_solve`, `residue`, `heaviside`, `dirac_delta`, `Fps`, `to_jax`. Transform round-trips are supported but not total — inverse Laplace covers -repeated irreducible quadratic poles and sinh/cosh forms as of 3.7.0. Literal +repeated irreducible quadratic poles and sinh/cosh forms as of 3.8.0. Literal negative Heaviside/Dirac shifts (`θ(t+a)`, `δ(t+a)` with `a > 0`) are **refused** with `E-TRANSFORM-001` rather than silently applying the wrong unilateral formula. diff --git a/docs/features.md b/docs/features.md index be04b9ce..06321378 100644 --- a/docs/features.md +++ b/docs/features.md @@ -84,7 +84,7 @@ Current stable feature surface. - Gröbner basis: Buchberger F4 with product-criterion pruning - Parallel F4 S-polynomial reduction via Rayon (`--features parallel`) -- CUDA Macaulay-matrix row reduction (`--features groebner-cuda`) +- CUDA Macaulay-matrix row reduction (`--features groebner-cuda`) — a Rust-crate entry point (`compute_groebner_basis_gpu`); **not** wired into `solve`/`GroebnerBasis.compute`, so it accelerates nothing from Python - Monomial orders: Lex, GrLex, GRevLex - `solve` — symbolic solution of polynomial systems (exact symbolic output) - Regular chains / triangular decomposition (`triangularize`, `RegularChain`) @@ -108,7 +108,7 @@ Current stable feature surface. - `CompileCache` — memoize compiled functions keyed by `(ExprId, input variables)`; Python `CompileCache` class - Bulk column-major batch evaluation (`CompiledFn::call_bulk` / `call_batch`; native `alkahest_eval_bulk` when JIT backends are enabled) - LLVM JIT for native CPU code (`--features jit`; `+jit` / `+full` release wheels) -- NVPTX (CUDA GPU) codegen for `sm_86` (`--features cuda`, 16.2× over CPU on RTX 3090) +- NVPTX (CUDA GPU) codegen for `sm_86` via `compile_cuda` (`--features cuda`, 16.2× over CPU on RTX 3090; source build only — no published wheel carries it) - Custom `alkahest` MLIR dialect with three lowering targets: ArithMath, StableHLO, LLVM - `to_stablehlo` — emit textual StableHLO MLIR for XLA/JAX - DAG-aware memoization on hot recursive paths (simplify, diff, integrate, interpreter eval) diff --git a/docs/mdbook/src/SUMMARY.md b/docs/mdbook/src/SUMMARY.md index c98800f7..14109767 100644 --- a/docs/mdbook/src/SUMMARY.md +++ b/docs/mdbook/src/SUMMARY.md @@ -13,6 +13,7 @@ - [Asymptotics of sums](./asymptotics.md) - [Transformations](./transformations.md) - [Code generation](./codegen.md) + - [GPU support (CUDA)](./gpu.md) - [Ball arithmetic](./ball-arithmetic.md) - [Rigorous global bounds](./validated-bounds.md) - [ODE and DAE modeling](./ode-dae.md) diff --git a/docs/mdbook/src/codegen.md b/docs/mdbook/src/codegen.md index 6edf5754..8f6d6be0 100644 --- a/docs/mdbook/src/codegen.md +++ b/docs/mdbook/src/codegen.md @@ -156,13 +156,24 @@ print(mlir_text) # valid input to mlir-opt / XLA ## GPU codegen (NVPTX) -With `--features cuda` and an LLVM installation with NVPTX support: +With `--features cuda` and an LLVM 15 installation with NVPTX support — **not** in any +published wheel, so `pip install alkahest` never has this. Full detail, including +build prerequisites, the supported node set, error codes and the state of testing, is +in [GPU support (CUDA)](./gpu.md). ```python from alkahest import compile_cuda f_gpu = compile_cuda(expr, [x, y]) -result = f_gpu.call_batch(inputs) # runs on the first CUDA device +result = f_gpu.call_batch(inputs) # runs on CUDA device 0 +``` + +Guard on the capability bit before reaching for it, since the name does not exist at +all without the feature: + +```python +if alkahest.capabilities()["features"]["cuda"]: + ... ``` The GPU compiler: diff --git a/docs/mdbook/src/errors.md b/docs/mdbook/src/errors.md index bd0cf95b..0598b212 100644 --- a/docs/mdbook/src/errors.md +++ b/docs/mdbook/src/errors.md @@ -18,7 +18,7 @@ AlkahestError (base) ├── DaeError (E-DAE-*) — DAE structural analysis ├── SolverError (E-SOLVE-*) — polynomial system solving ├── JitError (E-JIT-*) — LLVM/JIT codegen -├── CudaError (E-CUDA-*) — CUDA kernel launch or driver +├── CudaError (E-CUDA-*) — NVPTX compile, kernel launch, or driver, see [GPU support](./gpu.md) ├── PoolError (E-POOL-*) — ExprPool misuse ├── AnsatzError (E-ANSATZ-*) — ansatz family construction or fitting, see [Ansatz families](./ansatz.md) ├── CrossCheckError (E-XCHECK-*) — cross-CAS check could not be posed, see [Cross-CAS testing](./crosscheck.md) @@ -91,6 +91,15 @@ 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 | +### PrimaryDecompositionError (E-IDEAL-*) + +| Code | Cause | Remediation | +|---|---|---| +| `E-IDEAL-001` | No generators supplied | Pass at least one generator | +| `E-IDEAL-002` | Generators disagree on the variable list | Use one variable list for every generator | +| `E-IDEAL-003` | Saturation split exceeded its recursion depth | Simplify the generating set | +| `E-IDEAL-004` | FLINT could not factor a generator | Report the generating set as a minimal failing example | + ### Refusals: when Alkahest declines to answer A refusal is not a malfunction. These codes all mean *"I could not establish this, and @@ -103,9 +112,31 @@ loop must record as **undecided**, never as a negative result. | `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-IDEAL-005` | `IdealRefusal` | `radical` cannot certify `√I` for this ideal. Only monomial, principal and zero-dimensional ideals — and anything whose primary decomposition is certified — are answered; the alternative is asserting `√I = I` with nothing behind it | +| `E-IDEAL-006` | `IdealRefusal` | `primary_decomposition` reached a component it cannot show is primary, so it will not report the ideal itself with an unjustified `associated_prime` | +| `E-SOLVE-004` | `TriangularizeRefusal` | `triangularize` extracted a chain that does not generate an ideal containing the input, i.e. one that cuts out a larger variety than the system. Splitting on the initials (Lazard–Kalkbrener) is not implemented | +| `E-SERIES-003` | `SeriesError` | `series` ran past its work ceiling (or an active `Budget`) before reaching the requested order. Coefficients are formed by repeated differentiation without re-simplifying, so a nested radical's derivatives grow by a constant factor each time; a *shorter* series would carry an `O(h^order)` label nothing bounded | | `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 | +`E-SERIES-003` travels out of band for the same reason (`SeriesError` is exhaustive) but *is* +wired into the bindings: `series` returns `SeriesError::InvalidOrder` with +`calculus::series::take_series_refusal()` pending, and the Python layer raises `SeriesError` +with `.code == "E-SERIES-003"` — or `BudgetExceededError` when a budget was what stopped it. + +`E-IDEAL-005`, `E-IDEAL-006` and `E-SOLVE-004` are new in 3.8 and travel **out of band**: +`PrimaryDecompositionError` and `SolverError` are public exhaustive enums that cannot gain +a variant in a patch release, so the refusal is returned inside an existing variant and the +real code is available from `ideal::take_ideal_refusal()` / +`solver::regular_chains::take_triangularize_refusal()`. The Python bindings consult both, so +`radical` and `primary_decomposition` raise `AlkahestError` with `.code == "E-IDEAL-005"` / +`"E-IDEAL-006"`, and `triangularize` raises `SolverError` with `.code == "E-SOLVE-004"`. +`AlkahestError` subclasses `ValueError`, so code that catches `ValueError` is unaffected. + +The takers are *consuming*, which is what keeps the carrier variant honest: a genuinely +non-polynomial equation still reports `E-SOLVE-001`, because no refusal is pending for it. +Both readings of the shared variant stay distinguishable. + 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. @@ -182,6 +213,15 @@ Every error is classified on two independent axes: **subsystem** (determines the | `E-ANSATZ-*` | `AnsatzError` | Ansatz family construction and fitting — see [Ansatz families](./ansatz.md) | | `E-XCHECK-*` | `CrossCheckError` | Cross-CAS differential testing — see [Cross-CAS testing](./crosscheck.md) | | `E-SMT-*` | `SmtError` | SMT-LIB export, solver invocation, model lift — see [SMT bridge](./smt.md) | +| `E-RESIDUE-*` | `AlkahestError` | `residue` — not a rational function, zero denominator, pole order out of range, or (`E-RESIDUE-005`) a point that is not an exact constant in ℚ(i) | + +`E-RESIDUE-005` is raised only at the Python boundary — the Rust `residue` takes an +already-parsed point and cannot reach that state — so it is deliberately absent from +`alkahest-core`'s `REGISTRY`, on the same footing as `E-SMT-001`/`003`/`004` in +`alkahest/smt.py` and `E-BATCH-001` in `alkahest/_batch.py`. It exists because +`residue(f, z, a)` with a symbolic `a` reads perfectly well and used to escape as a +bare `AttributeError` naming an attribute of the implementation, which is not an +`AlkahestError` and so was invisible to `except ak.AlkahestError`. Three of these describe outcomes that are **results rather than malfunctions**, and the wording of each is deliberate. `E-ANSATZ-003` means *no member of this family diff --git a/docs/mdbook/src/getting-started.md b/docs/mdbook/src/getting-started.md index ad68c633..ef616625 100644 --- a/docs/mdbook/src/getting-started.md +++ b/docs/mdbook/src/getting-started.md @@ -71,9 +71,13 @@ print(features) | Release `+full` | Linux x86_64 | `+jit` profile plus `parallel` | `jit` and `cranelift` remain compatibility names in this mapping. Prefer -`llvm_jit` and `cranelift_jit` when selecting a backend explicitly. CUDA and -`groebner_cuda` indicate that the extension was compiled with those features; -they do not claim that a usable GPU is present at runtime. +`llvm_jit` and `cranelift_jit` when selecting a backend explicitly. `cuda` +indicates that the extension was compiled with NVPTX codegen — it guarantees +that `ak.compile_cuda` and `ak.CudaCompiledFn` exist, but not that a usable GPU +is present at runtime, and it is in no published wheel. There is **no +`groebner_cuda` bit**: the GPU Gröbner kernel has no Python binding, so the bit +was unfalsifiable from Python and was removed in contract v3. Read +[GPU support](./gpu.md) before branching on `cuda`. ### Optional: RL environments (`alkahest[rl]`) diff --git a/docs/mdbook/src/gpu.md b/docs/mdbook/src/gpu.md new file mode 100644 index 00000000..00e1b2f7 --- /dev/null +++ b/docs/mdbook/src/gpu.md @@ -0,0 +1,255 @@ +# GPU support (CUDA) + +Alkahest has two *independent* CUDA features. Neither is in the wheel published to +PyPI, so **`pip install alkahest` gives you no GPU support at all** — a source build +is required, and each feature has different build prerequisites. + +| Cargo feature | What it provides | Build prerequisites | Reachable from Python? | +|---|---|---|---| +| `cuda` | NVPTX codegen: [`compile_cuda`](#compile_cuda) turns an expression into a GPU kernel | **LLVM 15 with the NVPTX target** (`cuda` implies `alkahest-core/jit`, i.e. inkwell), plus `libcuda.so.1` at runtime | **Yes** — `compile_cuda`, `CudaCompiledFn` | +| `groebner-cuda` | A Macaulay-matrix mod-p row reduction kernel used by the Rust function `compute_groebner_basis_gpu` | Only `cudarc` — **no LLVM**, because the kernel is a static PTX string rather than LLVM output | **No** — see [below](#groebner-cuda-is-not-reachable-from-python) | + +The two do not imply each other. `cuda = ["jit", "dep:cudarc"]` and +`groebner-cuda = ["groebner", "dep:cudarc"]` (`alkahest-core/Cargo.toml`). + +## Building + +```bash +# NVPTX expression codegen. Needs LLVM 15 built with NVPTX: +# llc --version | grep nvptx # must list nvptx64 +maturin develop --manifest-path alkahest-py/Cargo.toml --release --features cuda + +# GPU Gröbner kernel (Rust-only; nothing changes at the Python surface) +maturin develop --manifest-path alkahest-py/Cargo.toml --release --features groebner-cuda + +# Both +maturin develop --manifest-path alkahest-py/Cargo.toml --release \ + --features "cuda groebner-cuda" +``` + +`cudarc` uses dynamic loading, so **the extension builds on a machine with no CUDA +installed**; the driver is only needed when a kernel actually launches. LLVM 15 with +NVPTX, by contrast, is needed at *build* time for `cuda` — a build without it fails +or produces `E-CUDA-001` at compile time. + +`libdevice.10.bc` (from the CUDA toolkit) is linked into every generated module so +that `sin`, `cos`, … resolve to `__nv_*`. If it is not found automatically, point at +it explicitly: + +```bash +export ALKAHEST_LIBDEVICE_PATH=/usr/local/cuda/nvvm/libdevice/libdevice.10.bc +``` + +## What `capabilities()` reports + +```python +import alkahest as ak + +features = ak.capabilities()["features"] +features["cuda"] # `--features cuda` was compiled in +features["llvm_jit"] # True on any `cuda` build: `cuda` implies the LLVM backend +``` + +Read these bits precisely — each says **what was linked**, and nothing more: + +- `cuda == True` guarantees `ak.compile_cuda` and `ak.CudaCompiledFn` exist and that + PTX can be emitted on the host. It does **not** promise a GPU: the driver is loaded + lazily, so a machine with no device compiles happily and fails at `call_batch` with + `E-CUDA-003`. The only way to find out is to launch something. +- `llvm_jit == True` on a `cuda` build even when *alkahest-py*'s own `jit` feature was + never named, because `alkahest-core`'s `cuda` feature turns on `jit`. Cranelift and + LLVM are not mutually exclusive; a CUDA build can link both. + +**There is no `groebner_cuda` bit** (contract v3 and later — `capabilities()["features"]` +raises `KeyError` for it). It was removed rather than wired up because it was +*unfalsifiable*: no Python observation distinguished `True` from `False`. See +[below](#groebner-cuda-is-not-reachable-from-python). + +`ak.CudaError` is importable on **every** build, CUDA or not — it is an exception +class, not an entry point, and code that writes `except ak.CudaError` around a +compile step must keep working when moved between wheels. `compile_cuda` and +`CudaCompiledFn` genuinely do not exist without the feature, and are appended to +`__all__` only when they do. + +## `compile_cuda` + +```python +import alkahest as ak + +pool = ak.ExprPool() +x, y = pool.symbol("x"), pool.symbol("y") +expr = ak.sin(x) * ak.cos(y) + (x * x + y * y) * pool.rational(1, 100) + +fn = ak.compile_cuda(expr, [x, y]) # -> CudaCompiledFn +fn.n_inputs # 2 +fn.ptx # generated PTX assembly (str), `.target sm_86` + +out = fn.call_batch([xs, ys]) # list[float], one output per point +``` + +`call_batch` takes one column per symbolic input (structure-of-arrays: `xs` is every +`x` value, not the first point), all of equal length, and returns a Python list with +one `float` per point. It copies host → device, launches on **device 0**, and copies +back; a mismatched column count or ragged columns raise `ValueError` before anything +touches the GPU. `fn.call_batch_on(ordinal, inputs)` is the same call on a chosen +device. + +Pipeline: expression → LLVM IR via inkwell → link `libdevice.10.bc` → internalize and +DCE → PTX for `sm_86` (Ampere) → loaded through the CUDA driver by `cudarc`. + +### Discovering the valid device ordinals + +There is **no `ak.cuda_device_count()`**. The only way to find the valid range for +`call_batch_on` today is to try an ordinal and catch the refusal: + +```python +def device_count(fn, limit=16): + """Largest N such that ordinals 0..N-1 accept a launch.""" + n = 0 + while n < limit: + try: + fn.call_batch_on(n, [[0.0]] * fn.n_inputs) + except ak.CudaError: # E-CUDA-003 — no such device + return n + n += 1 + return n +``` + +That is a workaround, not an API, and it is recorded here rather than fixed because a +`cuda_device_count` binding could not be verified by anything: `cuda` implies LLVM 15 +with NVPTX, so it cannot even be *compiled* on an ordinary dev box, no CI job builds +the Python extension with the feature (see below), and running it needs a device. It +belongs in the same change as the missing `maturin develop --features cuda` nightly +step — shipping it before that would add exactly the kind of unverified surface that +produced the capability overclaims this page now documents. + +### Limits worth knowing before you reach for it + +- **`sm_86` is hard-coded.** Newer or older architectures rely on the driver's PTX JIT. +- **`f64` only**, one output value per point. There is no vector or complex return. +- **Supported nodes**: integer/rational/float constants, `+`, `*`, `**`, and the + unary functions `sin`, `cos`, `tan`, `exp`, `log`, `sqrt`, `abs`. Integer exponents + in `0..=16` are unrolled to multiplies; anything else goes through `__nv_pow`. + Any other function — `atan`, `sinh`, `erf`, … — is **refused** with `E-CUDA-002` + rather than approximated, as is any symbol you forgot to pass in `inputs`. +- **Host lists in, host list out.** The zero-copy device-pointer entry point + (`call_device_ptrs`) exists in the Rust crate only; it has no PyO3 binding, so a + CuPy or Torch CUDA tensor is round-tripped through host memory today. + +### Errors + +All are `CudaError`, a subclass of `AlkahestError`, each carrying `.code` and +`.remediation` (see [Error handling](./errors.md)). + +| Code | Meaning | +|---|---| +| `E-CUDA-001` | LLVM has no NVPTX target — rebuild LLVM with `nvptx64` in `LLVM_TARGETS_TO_BUILD` | +| `E-CUDA-002` | PTX generation failed: unbound symbol, unsupported node, or a verifier complaint | +| `E-CUDA-003` | CUDA driver error — no device, context creation, module load, or a memcpy | +| `E-CUDA-004` | Not implemented | +| `E-CUDA-005` | `libdevice` bitcode not found — install the CUDA toolkit or set `ALKAHEST_LIBDEVICE_PATH` | +| `E-CUDA-006` | Kernel launch failed | + +## `groebner-cuda` is not reachable from Python + +The feature compiles a real, tested CUDA kernel — `MacaulayMatrix::reduce_gpu` plus a +multi-prime CRT lift — and exports `compute_groebner_basis_gpu` from the Rust crate. +But **no shipped code path calls it.** `GroebnerBasis.compute`, `solve`, and +`triangularize` all go through `compute_buchberger_basis` on the CPU, and +`alkahest-py` never references the GPU entry point at all. So on a +`--features groebner-cuda` build: + +- no Python name appears or disappears, +- no Python call gets faster, +- and, since 3.8, **no capability bit claims otherwise.** + +This is deliberate rather than accidental — the crossover policy in +[`docs/symbolic-gpu-benchmarks.md`](https://github.com/alkahest-cas/alkahest/blob/main/docs/symbolic-gpu-benchmarks.md) +says production dispatch must not prefer the GPU until the benchmark harness says it +wins, and that wiring does not exist yet. Rust users can call +`alkahest_cas::poly::groebner::compute_groebner_basis_gpu` directly. + +### Why the bit was removed rather than wired up + +`capabilities()["features"]["groebner_cuda"]` used to report `True` here. It was the +only occurrence of the string `groebner_cuda` anywhere in `alkahest-py` — there was no +binding to go with it, no `*gpu*` name in the public or the private module, and +`GroebnerBasis` exposing only CPU methods. That made it strictly worse than the `cuda` +overclaim fixed in `d139a46`, which at least had a private route in. + +An unreachable `True` is the same class of defect as a silent wrong answer: it makes a +caller trust something it should not. The two ways out are to add a binding or to drop +the claim, and dropping it was the right one days before a release — a binding would +have been new public API that no CI job can build (no job builds the Python extension +with either CUDA feature; see below) and that nobody without a GPU can run. Adding +unverifiable surface is how the original defect got in. The bit is gone; the kernel is +unchanged and still Rust-reachable. If dispatch ever prefers the GPU, the binding lands +first and a bit follows it. + +### `compute_groebner_basis_gpu` now reports where it ran + +The Rust entry point falls back to CPU row reduction when no device is present, and it +used to say nothing about having done so — a `device_id: None` run, a run whose driver +calls all failed, and a real GPU run returned identical, indistinguishable values. A +function named `..._gpu` that quietly runs on the CPU is a footgun of exactly the kind +this release has spent its time eliminating, so both it and `reduce_batch` now return a +`GpuBackendReport` alongside the polynomials: + +```rust +use alkahest_cas::poly::groebner::{compute_groebner_basis_gpu, MonomialOrder}; + +let (basis, backend) = compute_groebner_basis_gpu(gens, MonomialOrder::Lex, Some(0))?; +assert!(backend.ran_on_gpu(), "fell back to the CPU: {backend:?}"); +``` + +`ran_on_gpu()` is true only when at least one mod-p row reduction executed on a device +and none fell back; `fell_back_to_cpu()` is its counterpart; `reductions_on_gpu`, +`reductions_on_cpu` and `first_gpu_error` carry the detail. The stderr warning on +fallback is still emitted, but it is no longer the only channel. This is a **breaking +change to the Rust signature** — a compile error on upgrade, which is the correct +failure mode for a caller who was reading a result as a GPU result. + +## State of testing — read this before trusting the feature + +**Rust, on hardware.** `alkahest-core/tests/nvptx_gpu.rs` and +`alkahest-core/tests/groebner_cuda.rs` run under +`.github/workflows/cuda_nightly.yml` on a self-hosted dual-RTX-3090 runner: 17 +CUDA-gated tests, plus `compute-sanitizer` `memcheck` and `racecheck`. The last full +run was green and both sanitizers clean. `--target-processes all` is load-bearing in +that workflow — without it the sanitizer instruments `cargo`, a process that makes no +CUDA calls, and reports success having inspected nothing. + +**Python.** `tests/test_cuda.py` covers the binding: the capability/namespace +contract, PTX emission, the `CudaError` refusals, `call_batch` argument validation, +and — the point of the exercise — GPU-versus-CPU numerical agreement on polynomial, +transcendental, `compile_expr` and `numpy_eval` comparisons. Only the contract tier +runs without the feature; everything else skips, which is what happens in CI and on +the wheel. Setting `ALKAHEST_GPU_TESTS=1` (as the nightly does for Rust) turns those +skips into a hard error, so a job that promises hardware cannot quietly report +success without reaching it. + +**The honest gap:** no CI job has ever built the *Python extension* with `cuda` or +`groebner-cuda`. The nightly runs `cargo`, never `maturin`, so the Python tier is +only exercised when someone builds with the feature and runs `pytest` by hand on a +GPU box. Until a `maturin develop --features cuda` + `pytest tests/test_cuda.py` step +is added to the nightly, treat the Python GPU surface as verified by hand and not by +CI — which is precisely how the `compile_cuda` export gap survived three releases +while `capabilities()` advertised the feature. + +**A second gate that was inspecting nothing.** `cargo test --features groebner-cuda` +could not pass on a machine with no NVIDIA driver at all, contradicting the header +comment of `alkahest-core/tests/groebner_cuda.rs`. `cudarc` *panics* rather than +returning `Err` when `libcuda.so` cannot be `dlopen`ed, so `gpu_available()` — whose +whole job is to answer "should the GPU tier run?" — aborted the three GPU tests +instead of skipping them. It now treats a missing library and a missing device alike +(both mean *not available*), while still failing hard when `ALKAHEST_GPU_TESTS=1` +asserted a device that is not usable. The `ALKAHEST_GPU_TESTS=1` tier additionally +asserts `GpuBackendReport::ran_on_gpu()`, so a "GPU test" that silently reduced every +matrix on the CPU now fails rather than passing on identical results. + +## See also + +- [Code generation](./codegen.md) — the CPU JIT tiers, `emit_c`, StableHLO +- [Error handling](./errors.md) — the full code registry +- `examples/gpu_batch_eval.py` — CPU/GPU batch comparison, degrades cleanly to + CPU-only on a wheel without the feature diff --git a/docs/mdbook/src/interop.md b/docs/mdbook/src/interop.md index bd1eb5c4..766f164f 100644 --- a/docs/mdbook/src/interop.md +++ b/docs/mdbook/src/interop.md @@ -40,7 +40,7 @@ xs = torch.linspace(0, 1, 10_000) ys = numpy_eval(f, xs) # returns a NumPy array ``` -For GPU tensors, use the `compile_cuda` path (requires `--features cuda`), which accepts device pointers via `call_device_ptrs`. +For GPU tensors, use the `compile_cuda` path (requires `--features cuda`; see [GPU support](./gpu.md)). Note that its Python `call_batch` takes and returns **host** sequences: a CUDA tensor is copied to the host and back. The zero-copy device-pointer entry point (`call_device_ptrs`) exists in the Rust crate only and has no PyO3 binding. ## JAX @@ -150,7 +150,7 @@ boundary.** [`alkahest.crosscheck`](./crosscheck.md) reports exactly this situat ## DLPack -All DLPack-compatible arrays (NumPy, PyTorch, JAX, CuPy) are accepted at the `numpy_eval` and `call_device_ptrs` boundaries. The DLPack conversion is zero-copy for CPU arrays with matching dtypes. +All DLPack-compatible arrays (NumPy, PyTorch, JAX, CuPy) are accepted at the `numpy_eval` boundary. The DLPack conversion is zero-copy for CPU arrays with matching dtypes; a device array is copied to the host first. There is no device-pointer boundary exposed to Python — `call_device_ptrs` is a Rust-crate API. ## Exporting C code diff --git a/docs/mdbook/src/solving.md b/docs/mdbook/src/solving.md index b906a102..8dc634f7 100644 --- a/docs/mdbook/src/solving.md +++ b/docs/mdbook/src/solving.md @@ -75,9 +75,13 @@ Supported orders: `Lex` (lexicographic), `GrLex` (graded lexicographic), `GRevLe With `--features "groebner parallel"`, Gröbner basis computation uses Rayon for parallel S-polynomial reduction via the F4 algorithm. -### GPU-accelerated Macaulay matrix (groebner-cuda) +### GPU-accelerated Macaulay matrix (groebner-cuda) — Rust only, not wired into the solver -With `--features "groebner-cuda"`, the mod-p row reduction of the Macaulay matrix is offloaded to CUDA. Multi-prime CRT lifts reconstruct rational coefficients. Falls back to pure-Rust when no CUDA device is present. +`--features "groebner-cuda"` compiles a CUDA kernel for the mod-p row reduction of the Macaulay matrix, with multi-prime CRT lifts reconstructing rational coefficients, and falls back to pure-Rust row reduction when no CUDA device is present. + +**It does not accelerate anything on this page.** `GroebnerBasis.compute`, `solve` and `triangularize` run Buchberger/F4 on the CPU regardless; the GPU routine is reachable only as the Rust function `alkahest_cas::poly::groebner::compute_groebner_basis_gpu`, and production dispatch deliberately does not prefer it until the [benchmark harness](https://github.com/alkahest-cas/alkahest/blob/main/docs/symbolic-gpu-benchmarks.md) says it wins. There is correspondingly **no `capabilities()["features"]["groebner_cuda"]` bit** — it used to exist and report that the kernel had been compiled in, which no Python observation could confirm or refute, so 3.8 removed it. See [GPU support](./gpu.md#groebner-cuda-is-not-reachable-from-python). + +Because the Rust entry point falls back to CPU row reduction when no device is present, it returns a `GpuBackendReport` alongside the basis: `let (basis, backend) = compute_groebner_basis_gpu(gens, order, Some(0))?;` and `backend.ran_on_gpu()` is the only way to tell a real GPU run from a fallback, since the basis is identical either way. ## Elimination ideals diff --git a/docs/mdbook/src/telescoping.md b/docs/mdbook/src/telescoping.md index 29aded1b..7d27f28a 100644 --- a/docs/mdbook/src/telescoping.md +++ b/docs/mdbook/src/telescoping.md @@ -22,14 +22,14 @@ cert.coeffs # [a_0(n), a_1(n)] — here proportional to [-2, 1] cert.certificate # R(n, k) ``` -The result says: with `S(n) = Σ_k F(n,k)`, +The result says: with `S(n) = Σ_{k=k_lo}^{k_hi} F(n,k)`, ```text -Σ_i a_i(n)·S(n+i) = 0 +Σ_i a_i(n)·S(n+i) = G(n, k_hi+1) − G(n, k_lo) ``` -Here that reads `S(n+1) − 2·S(n) = 0`, which together with `S(0) = 1` gives -`Σ_k C(n,k) = 2^n`. +and that boundary difference vanishes here, so the recurrence reads +`S(n+1) − 2·S(n) = 0`, which together with `S(0) = 1` gives `Σ_k C(n,k) = 2^n`. ## What the certificate asserts @@ -40,11 +40,39 @@ The returned `certificate` is a rational function `R(n, k)` such that, with Σ_i a_i(n)·F(n+i, k) = G(n, k+1) − G(n, k) ``` -holds **identically**. Summing over `k` telescopes the right-hand side to zero -(over a range where the boundary terms vanish), which is what licenses the -recurrence for `S(n)`. Because the identity is a rational-function identity, a -reader — or a referee, or another CAS — can verify it by clearing denominators -and expanding, with no reference to how it was found. +holds **identically**. That identity in `k` is the whole of what is verified. +Because it is a rational-function identity, a reader — or a referee, or another +CAS — can check it by clearing denominators and expanding, with no reference to +how it was found. + +## The boundary hypothesis is yours to discharge + +Summing that identity over `k = k_lo .. k_hi` telescopes the right-hand side to +`G(n, k_hi+1) − G(n, k_lo)` — a *boundary difference*, not zero. The familiar +homogeneous recurrence for `S(n)` therefore holds only when that difference +vanishes: the **natural boundary** hypothesis, which Zeilberger's algorithm does +not establish and which `zeilberger` does not check. + +It holds in the usual case, where `F` vanishes outside `0 ≤ k ≤ n`, and that +covers every classical identity in this chapter. It fails, for instance, for +`F(n,k) = C(n,k)/(k+1)`: there `G(n,0) = −1`, and the true relation is +`(n+2)·S(n+1) − (2n+2)·S(n) = 1`, not `0`. Reading the homogeneous recurrence +off the certificate there gives a false lemma. + +The certificate carries what you need to settle it: + +```python +cert.side_conditions # the hypothesis, stated +cert.boundary_term # G(n, k) = R(n, k)·F(n, k) + +# Substitute your own summation endpoints and check the difference is 0. +g_at_lo = ak.simplify(ak.subs(cert.boundary_term, {k: pool.integer(0)})).value +``` + +`side_conditions` is a `list[str]`, the same shape as +`DerivedResult.verification["side_conditions"]`: things the result depends on +that were assumed rather than proved. An empty list would be a claim; this one +is never empty. ## Verification is not optional diff --git a/docs/sphinx/api/errors.rst b/docs/sphinx/api/errors.rst index d4f8bba3..140d6375 100644 --- a/docs/sphinx/api/errors.rst +++ b/docs/sphinx/api/errors.rst @@ -167,6 +167,19 @@ Exception subclasses - ``E-SOLVE-001`` — inconsistent system (no solutions) - ``E-SOLVE-002`` — high-degree factor (degree > 2, no symbolic solution) - ``E-SOLVE-003`` — Gröbner basis did not converge + - ``E-SOLVE-004`` — ``triangularize`` could not extract a triangular set whose + ideal contains the input, so it refused rather than return a chain cutting + out a larger variety. Travels inside ``E-SOLVE-001`` until the bindings + read ``solver::regular_chains::take_triangularize_refusal()``. + +.. note:: + + ``radical`` and ``primary_decomposition`` refuse rather than return an ideal + they cannot certify: ``E-IDEAL-005`` (no certified radical) and + ``E-IDEAL-006`` (no certified primary decomposition). Both currently arrive + as a plain :class:`ValueError` whose message states the reason; the stable + code is available to Rust callers from + ``alkahest_cas::ideal::take_ideal_refusal()``. .. exception:: JitError diff --git a/pyproject.toml b/pyproject.toml index f13bde69..93f8e59b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "alkahest" -version = "3.7.0" +version = "3.8.0" description = "A high-performance computer algebra system for Python" readme = "README.md" license = { text = "Apache-2.0" } diff --git a/python/alkahest/__init__.py b/python/alkahest/__init__.py index 994ed094..afb21645 100644 --- a/python/alkahest/__init__.py +++ b/python/alkahest/__init__.py @@ -317,6 +317,7 @@ rosenfeld_groebner, solve, solve_numerical, + solve_side_conditions, triangularize, ) @@ -335,6 +336,7 @@ CertificateUnavailableError, ConversionError, CrossCheckError, + CudaError, DaeError, DepthLimitError, DiffError, @@ -379,6 +381,11 @@ "BudgetExceededError", "CadError", "ConversionError", + # Registered unconditionally by the native module (it is an exception class, + # not a GPU entry point), so it is overlaid on every build — including the + # wheels with no CUDA support, where it is simply never raised. Catching it + # must work in code written against the default wheel and run on a CUDA one. + "CudaError", "DaeError", "DepthLimitError", "DiffError", @@ -1061,6 +1068,21 @@ def series(expr, var, point, order): Returns ------- Series + + Notes + ----- + The expansion is **bounded**: it honours an active :class:`Budget` and, with + none, an internal work ceiling. Coefficients are formed by repeated + differentiation without re-simplifying, so an expression whose derivatives + do not close — a nested radical such as ``sqrt(t**-2 + t**-1)`` — grows by a + constant factor per coefficient, and a high *order* is unreachable rather + than merely slow. + + Running out of room raises :exc:`SeriesError` with code ``E-SERIES-003`` + (or :exc:`BudgetExceededError` when a budget stopped it). It never returns + a *shorter* series: the ``O(h**order)`` term is a claim about the remainder, + and attaching it to fewer coefficients than were asked for would be a false + one that no caller could audit. """ return _native_series(_coerce_expr(expr), _coerce_expr(var), _coerce_expr(point), order) @@ -1169,7 +1191,18 @@ def product_indefinite(expr, k): def product_definite(expr, k, lo, hi): - """Definite symbolic product of *expr* for *k* from *lo* to *hi* (inclusive).""" + """Definite symbolic product of *expr* for *k* from *lo* to *hi* (inclusive). + + Supports ``q ∈ ℚ(k)`` whose numerator and denominator split into ℤ-linear + factors; the result is emitted as a ratio of ``gamma`` values times integer + powers. Anything outside that class raises ``ProductError`` + (``E-PROD-001``…``E-PROD-003``) rather than being approximated. + + **Empty range.** ``hi < lo`` gives ``1``, the empty product, *whatever the + term is* — including a term that is identically zero, since no factor is + ever taken. (Note this differs from :func:`sum_definite`, which follows the + reversal convention for ``hi < lo`` rather than treating it as empty.) + """ return _maybe_context_simplify( _native_product_definite( _coerce_expr(expr), @@ -1184,7 +1217,20 @@ def product_definite(expr, k, lo, hi): def rsolve(equation, n, seq_name, initials=None): - """Solve a linear recurrence; *equation* may be :class:`DerivedResult`.""" + """Solve a linear recurrence; *equation* may be :class:`DerivedResult`. + + *equation* is read as ``equation == 0`` and may be written with either + spelling of the shift — ``f(n+1) - f(n) - n**2`` and + ``f(n) - f(n-1) - (n-1)**2`` are the *same* equation and give the same + answer. Both are solved as stated: the right-hand side is re-indexed + together with the sequence terms, so substituting the result back into the + equation you wrote reproduces it. + + Without *initials* the general solution is returned with fresh symbols + ``C0``, ``C1``, …. For an order-2 equation with a repeated characteristic + root the basis is ``{r**n, n*r**n}``, so the family really is + two-parameter and two independent initial conditions can be met. + """ return _native_rsolve(_coerce_expr(equation), _coerce_expr(n), seq_name, initials) @@ -1557,6 +1603,13 @@ def solve_numerical(*_args, **_kwargs): "See alkahest.solve.__doc__ for details." ) + def solve_side_conditions(*_args, **_kwargs): + """Hypotheses of the last solve (groebner feature missing from this build).""" + raise ImportError( + "alkahest.solve_side_conditions is unavailable — groebner feature missing. " + "See alkahest.solve.__doc__ for details." + ) + class GroebnerBasis: """Gröbner basis type (groebner feature missing from this build). @@ -1623,7 +1676,9 @@ def capabilities() -> dict: ``contract_version`` identifies this schema. ``groebner``, ``jit``, ``egraph``, and ``parallel`` are compatibility feature booleans. ``features`` contains installed Cargo features and explicit - ``llvm_jit`` / ``cranelift_jit`` backend flags; ``primitives`` is + ``llvm_jit`` / ``cranelift_jit`` backend flags — every key names + something a caller can actually reach, which is why v3 drops + ``groebner_cuda`` and ``numpy`` (see below); ``primitives`` is deterministic per-primitive implementation coverage, and ``verification`` describes available evidence artifacts and checkers. ``verification["coverage"]`` summarises the generated certificate @@ -1635,6 +1690,25 @@ def capabilities() -> dict: on :class:`Matrix`; unsupported inputs raise :class:`LinearAlgebraError` with stable ``E-LINALG-*`` codes. + Notes + ----- + **Contract v3 removed two ``features`` keys.** Code that indexes + ``caps["features"]["groebner_cuda"]`` or ``["numpy"]`` now raises + ``KeyError``; use ``.get(name, False)`` if you must span versions, and + gate on ``contract_version`` when the distinction matters. + + Both were removed rather than wired up because neither was *falsifiable*. + ``groebner_cuda`` reported that the GPU Gröbner kernel was compiled in, + but that kernel has no Python binding at all — ``GroebnerBasis.compute``, + :func:`solve` and :func:`triangularize` run on the CPU whatever it said, + so no observation a caller could make distinguished ``True`` from + ``False``. ``numpy`` reported a Cargo feature gating a crate the + extension never used; :func:`numpy_eval` goes through the buffer + protocol and works identically with the bit ``False``, which is its value + on every build ever shipped. A bit that reads ``False`` honestly is + better than one that reads ``True`` and lies, and a bit that correlates + with nothing is better removed than left to be misread. + Example ------- >>> import alkahest as ak @@ -1656,7 +1730,11 @@ def capabilities() -> dict: for row in primitive_rows: row["lean_theorem"] = row["name"] in _certifiable_primitives return { - "contract_version": 2, + # v3: `features` dropped `groebner_cuda` and `numpy`, neither of which + # named a reachable entry point. Same rule that dropped the + # never-emitted `lean_checked` verification status in v2 — advertise a + # bit only when some observation can tell it apart from its negation. + "contract_version": 3, # Compatibility keys: report what this extension was compiled with, # even where a Python-level fallback exists. "groebner": features["groebner"], @@ -1786,6 +1864,7 @@ def wrapper(*args, **kwargs): "Component", "ConversionError", "CrossCheckError", + "CudaError", "DaeError", "DaeIndexReduction", "DepthLimitError", @@ -2052,6 +2131,8 @@ def wrapper(*args, **kwargs): "solve", "solve_linear_recurrence_homogeneous", "solve_numerical", + # The non-vanishing hypotheses `solve` assumed for a parametric answer + "solve_side_conditions", # P1 item 8 — positivity certificates (SOS / Positivstellensatz) "sos_decompose", "sparse_interp", @@ -2102,3 +2183,37 @@ def __getattr__(name: str): return importlib.import_module(f".{name}", __name__) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +# --------------------------------------------------------------------------- +# CUDA codegen — present only in a build made with `--features cuda`. +# +# The native module defines `compile_cuda` and `CudaCompiledFn` under that +# feature, but they were never re-exported here, so on a CUDA build +# `capabilities()["features"]["cuda"]` reported True while `ak.compile_cuda` +# raised AttributeError and the only way in was the private +# `alkahest.alkahest` module. A capability bit that advertises something the +# public namespace cannot reach is exactly the kind of overclaim the rest of +# this contract exists to prevent. +# +# Appended at runtime rather than listed in the literal `__all__` above, +# because the names genuinely do not exist in a default (non-CUDA) build and +# every name in `__all__` must resolve. `scripts/check_api_freeze.py` parses +# the literal via AST, so this is invisible to it — which is correct: it is an +# addition, and only on builds that have the feature. +# +# `CudaError` is deliberately *not* handled here: the native module registers +# it unconditionally (it is an exception class, not a GPU entry point), so it +# is bound with the rest of the hierarchy above and is importable on every +# build. Bundling it into this feature-gated import is what made +# `alkahest.CudaError` raise AttributeError on the shipped wheel while +# `alkahest.exceptions.CudaError` existed as a *different, non-identical* +# class — so `except alkahest.exceptions.CudaError` would not have caught a +# native raise. See `tests/test_cuda.py`. +# --------------------------------------------------------------------------- +try: # pragma: no cover - exercised only on CUDA builds + from .alkahest import CudaCompiledFn, compile_cuda +except ImportError: # the overwhelmingly common case: no CUDA feature + pass +else: + __all__ += ["CudaCompiledFn", "compile_cuda"] diff --git a/python/alkahest/exceptions.py b/python/alkahest/exceptions.py index 4cfa79d8..14ebb6f3 100644 --- a/python/alkahest/exceptions.py +++ b/python/alkahest/exceptions.py @@ -31,7 +31,9 @@ E-RSOLVE-001 … E-RSOLVE-005 RsolveError (V2-18 difference equations) E-DIOPH-001 … E-DIOPH-004 DiophantineError (V2-19) E-NT-001 … E-NT-005 NumberTheoryError (V3-1 integer number theory) - E-SERIES-001 … E-SERIES-002 SeriesError + E-SERIES-001 … E-SERIES-003 SeriesError (003 = expansion ran past its work + ceiling / budget before reaching the requested + order; refused rather than returned short) E-LIMIT-001 … E-LIMIT-005 LimitError E-CUDA-001 … E-CUDA-006 CudaError E-IO-001 … E-IO-009 IoError (formerly PoolPersistError / E-POOL-*) @@ -55,10 +57,27 @@ from __future__ import annotations +try: # The compiled extension imports no Python modules, so this cannot cycle. + from .alkahest import AlkahestError as _NativeAlkahestError +except ImportError: # pragma: no cover - pure-Python fallback (no extension) + _NativeAlkahestError = Exception -class AlkahestError(Exception): + +class AlkahestError(_NativeAlkahestError): """Base class for all alkahest errors. + Inherits the **native** base class registered by the extension. That is + load-bearing rather than cosmetic: the Rust engines raise the native + classes, the pure-Python subsystems (``ansatz``, ``crosscheck``, ``smt``, + the batch helpers) raise the wrappers below, and before this the two + hierarchies were disjoint. ``except alkahest.AlkahestError`` — the + documented way to catch anything this library raises — therefore caught + the Rust half and silently missed the Python half. + + Subclasses keep their keyword constructors: only the message is forwarded + to the native base, whose ``__init__`` is ``BaseException``'s and takes + positional arguments only. + Attributes ---------- code : str diff --git a/tests/silent_errors/corpus.py b/tests/silent_errors/corpus.py index 29db7290..fa8431e1 100644 --- a/tests/silent_errors/corpus.py +++ b/tests/silent_errors/corpus.py @@ -20,6 +20,7 @@ from typing import Any, Callable import alkahest as ak +import alkahest.experimental as ex import alkahest.number_theory as nt # ``tests/`` is on sys.path via the root conftest, so the textbook gate's series @@ -145,6 +146,79 @@ def op() -> int: return op +def solution_count( + equations: list[ak.Expr], unknowns: list[ak.Expr], **kwargs: Any +) -> Callable[[], int]: + """Answer = how many solutions ``solve`` reports over ℂ. + + A count is the sharpest single number for a solver: it moves if a spurious + tuple is added, if a true one is dropped, and if one root is reported twice. + A parametric (``GroebnerBasis``) answer is not a count and is surfaced as a + refusal rather than silently scored. + """ + + def op() -> int: + sols = ak.solve(equations, unknowns, **kwargs) + if not isinstance(sols, list): + raise ak.SolverError("solve returned a parametric ideal, not a solution list") + return len(sols) + + return op + + +def numeric_solution_count(equations: list[ak.Expr], unknowns: list[ak.Expr]) -> Callable[[], int]: + """Answer = how many returned tuples actually name a point of ℂⁿ. + + An entry whose coordinate is ``0·0⁻¹`` is not a solution and not a + refusal either — it is a list entry that looks like an answer. Counting + only the tuples that evaluate keeps the score a number rather than an + exception, so the case is scored as the wrong *count* it is. + """ + + def op() -> int: + sols = ak.solve(equations, unknowns) + if not isinstance(sols, list): + raise ak.SolverError("solve returned a parametric ideal, not a solution list") + n = 0 + for sol in sols: + if all(ak.evaluate(sol[v], {}, mode="complex").status == "ok" for v in unknowns): + n += 1 + return n + + return op + + +def max_solution_residual(equations: list[ak.Expr], unknowns: list[ak.Expr]) -> Callable[[], float]: + """Answer = max |eq(sol)| over every returned solution and every equation. + + Substitution back into the original system is self-certifying: no oracle is + consulted, and any tuple that is not a solution shows up as a residual the + solver itself cannot explain away. A coordinate that is not a number + (``0·0⁻¹``) makes ``eval_expr`` raise, which the runner scores as a refusal. + """ + + def op() -> float: + sols = ak.solve(equations, unknowns) + if not isinstance(sols, list) or not sols: + raise ak.SolverError("solve produced no solution list to substitute back") + worst = 0.0 + for sol in sols: + point = {} + for v in unknowns: + got = ak.evaluate(sol[v], {}, mode="complex") + if got.status != "ok": + raise ak.SolverError(f"solution coordinate is not a number: {got.status}") + point[v] = complex(got.value) + for eq in equations: + residual = ak.evaluate(eq, point, mode="complex") + if residual.status != "ok": + raise ak.SolverError(f"residual did not evaluate: {residual.status}") + worst = max(worst, abs(complex(residual.value))) + return worst + + return op + + def universal_holds(poly: ak.Expr, kind: str) -> Callable[[], bool]: """Answer = ``decide``'s verdict on ``forall x. poly 0``.""" @@ -411,6 +485,419 @@ def op() -> int: return op +def _rsolve_residual(equation: ak.Expr, initials: dict[int, ak.Expr]) -> Callable[[], float]: + """Answer = the worst residual of ``rsolve``'s answer *in the given equation*. + + Substituting the closed form back into the very equation that was passed in + is self-certifying: it needs no oracle, and it is the one property a + recurrence solver may never get wrong. A solver that quietly re-indexes the + equation returns the solution of a *different* problem, which is a clean, + plausible, wrong sequence. + """ + + def op() -> float: + closed = ak.rsolve(equation, N, "f", initials) + residual = ak.simplify(_substitute_sequence(equation, closed)).value + return max(abs(float(ak.eval_expr(residual, {N: float(j)}))) for j in range(6)) + + return op + + +#: Shifts the recurrence cases are written with. +_SEQ_SHIFTS = (2, 1, 0, -1, -2) + + +def _seq(shift: int) -> ak.Expr: + """``f(n + shift)`` — the term shape ``rsolve`` reads.""" + return POOL.func("f", [N if shift == 0 else N + _int(shift)]) + + +def _substitute_sequence(equation: ak.Expr, closed: ak.Expr) -> ak.Expr: + """``equation`` with every ``f(n + c)`` replaced by ``closed`` shifted by c. + + Written against the fixed shift set the recurrence cases are built from + (:data:`_SEQ_SHIFTS`, via :func:`_seq`) rather than by walking the expression + tree, so the substitution itself stays obviously correct. + """ + out = equation + for c in _SEQ_SHIFTS: + arg = N if c == 0 else N + _int(c) + shifted = closed if c == 0 else ak.subs(closed, {N: arg}) + out = ak.subs(out, {_seq(c): shifted}) + return out + + +def _basis_independence(equation: ak.Expr) -> Callable[[], bool]: + """Answer = whether ``rsolve``'s *general* solution spans two dimensions. + + The general solution of a second-order linear recurrence is a two-parameter + family. Returning ``C₀·rⁿ + C₁·rⁿ`` for a repeated root looks like one but + is not: both basis elements are the same function, so the family is + one-dimensional and cannot meet two independent initial conditions. + """ + + def op() -> bool: + general = ak.rsolve(equation, N, "f", None) + c0 = POOL.symbol("C0") + c1 = POOL.symbol("C1") + rows = [] + for at in (0.0, 1.0): + first = ak.eval_expr(general, {c0: 1.0, c1: 0.0, N: at}) + second = ak.eval_expr(general, {c0: 0.0, c1: 1.0, N: at}) + rows.append((float(first), float(second))) + det = rows[0][0] * rows[1][1] - rows[0][1] * rows[1][0] + return abs(det) > 1e-9 + + return op + + +def _constant_terms(report: Any) -> list[float]: + """The values of every term of an asymptotic expansion that does not move. + + A term with the same value at ``n = 10`` and ``n = 20`` is a constant, and a + constant claimed for a sum whose closed form is a polynomial with zero + constant term is a fabricated one. + """ + out = [] + for term in report.terms: + lo = float(ak.eval_expr(term, {N: 10.0})) + hi = float(ak.eval_expr(term, {N: 20.0})) + if abs(lo - hi) <= 1e-9 * max(1.0, abs(lo)): + out.append(lo) + return out + + +def _binom(top: ak.Expr, bot: ak.Expr) -> ak.Expr: + """``C(top, bot)`` as a Γ-quotient, the shape ``zeilberger`` parses.""" + return ak.gamma(top + _int(1)) / (ak.gamma(bot + _int(1)) * ak.gamma(top - bot + _int(1))) + + +def _zeilberger_sum_recurrence_defect( + term: ak.Expr, exact_sum: Callable[[int], Fraction], disclosure_counts: bool +) -> Callable[[], float]: + """Answer = how badly the *sum* recurrence read off the certificate fails. + + Zeilberger verifies ``Σ_i a_i(n)·F(n+i,k) = G(n,k+1) − G(n,k)``, an identity + in ``k``. Summing it gives ``Σ_i a_i(n)·S(n+i) = G(n,k_hi+1) − G(n,k_lo)``, + so the familiar homogeneous recurrence needs that boundary difference to + vanish — a hypothesis the algorithm does not establish. + + With *disclosure_counts* the case is satisfied either way an honest library + can behave: prove the hypothesis (residual genuinely zero) or state it as a + side condition on the certificate. Silently omitting it scores the residual, + which is what a caller who trusted the recurrence would inherit. + """ + + def op() -> float: + cert = ak.zeilberger(term, N, K) + if disclosure_counts: + conditions = getattr(cert, "side_conditions", ()) + if any("boundary" in str(c).lower() for c in conditions): + return 0.0 + worst = 0.0 + for ni in range(1, 6): + total = Fraction(0) + for i, a in enumerate(cert.coeffs): + coeff = Fraction(float(ak.eval_expr(a, {N: float(ni)}))).limit_denominator(10**9) + total += coeff * exact_sum(ni + i) + worst = max(worst, abs(float(total))) + return worst + + return op + + +def _sum_binomial_over_k_plus_one(m: int) -> Fraction: + """``Σ_{k=0}^{m} C(m,k)/(k+1) = (2^{m+1} − 1)/(m+1)``, by hand.""" + return sum((Fraction(math.comb(m, j), j + 1) for j in range(m + 1)), Fraction(0)) + + +def _sum_binomial_row(m: int) -> Fraction: + """``Σ_{k=0}^{m} C(m,k) = 2^m``.""" + return Fraction(2**m) + + +def _survives_a_panic(fn: Callable[[], Any]) -> Callable[[], Any]: + """Wrap *fn* so a Rust panic fails this case instead of killing the run. + + PyO3 turns an escaping Rust panic into ``pyo3_runtime.PanicException``, + which inherits ``BaseException``. That is the whole reason the class + matters — a loop's ``except Exception`` does not catch it — but it also + means an unwrapped op would take the gate process down with it and no case + would be reported at all. Re-raising as ``RuntimeError`` keeps the failure + (scored ``no_answer``: neither an answer nor a refusal) while leaving the + rest of the corpus scoreable. + """ + + def op() -> Any: + try: + return fn() + except Exception: + raise + except BaseException as exc: # PanicException is a BaseException — the point + raise RuntimeError( + f"escaping Rust panic: {type(exc).__module__}.{type(exc).__name__}: {exc}" + ) from exc + + return op + + +def _poly(coeffs: list[int]) -> ak.Expr: + """``Σ coeffs[i]·xⁱ`` from ascending-degree integer coefficients.""" + out = _int(0) + for i, c in enumerate(coeffs): + out = out + _int(c) * X ** _int(i) + return out + + +def _subresultant_chain( + f_coeffs: list[int], g_coeffs: list[int], samples: tuple[float, ...] = (2.0, 3.0) +) -> Callable[[], tuple[float, ...]]: + """Answer = every subresultant after ``[p, q]``, sampled at fixed points. + + Two sample points rather than one so the *polynomial* is pinned, not just a + value: a chain element off by a scalar or by a term shows up at both. + """ + + def op() -> tuple[float, ...]: + chain = ak.subresultant_prs(_poly(f_coeffs), _poly(g_coeffs), X)[2:] + return tuple(float(ak.eval_expr(e, {X: s})) for e in chain for s in samples) + + return _survives_a_panic(op) + + +def _lll_rows_stay_in_the_lattice( + rows: list[list[int]], generator: list[int] +) -> Callable[[], bool]: + """Answer = does LLL return a basis of the *same* lattice ``ℤ·generator``? + + Every returned row must be an integer multiple of *generator* (nothing left + the lattice), the generator itself must still be reachable (nothing was + lost), and the row count must be preserved. Exact integer arithmetic, no + reference implementation. + """ + + def op() -> bool: + reduced = ak.lattice.lll_reduce_rows(rows) + if len(reduced) != len(rows): + return False + multiples = [] + for row in reduced: + ratios = {Fraction(v, g) for v, g in zip(row, generator) if g != 0} + leftover = any(v != 0 for v, g in zip(row, generator) if g == 0) + if leftover or len(ratios) != 1: + return False + (r,) = ratios + if r.denominator != 1: + return False + multiples.append(abs(r.numerator)) + return 1 in multiples + + return _survives_a_panic(op) + + +# --------------------------------------------------------------------------- +# Ideal-theory helpers (3.8 silent-error hunt #2, findings 16) +# --------------------------------------------------------------------------- + +#: Third variable, for the three-variable monomial-ideal cases. +_Z = POOL.symbol("z") + + +def _radical_membership( + polys: list[ak.Expr], unknowns: list[ak.Expr], probes: list[ak.Expr] +) -> Callable[[], tuple[bool, ...]]: + """Answer = which *probes* the reported √I contains. + + Membership is the only thing a caller can ask a ``GroebnerBasis``, so it is + what the contract has to be written against: a radical that does not contain + a polynomial whose square it does contain is refuted by its own answers, no + oracle needed. + """ + + def op() -> tuple[bool, ...]: + r = ak.radical(polys, unknowns) + return tuple(bool(r.contains(p)) for p in probes) + + return op + + +def _component_count(polys: list[ak.Expr], unknowns: list[ak.Expr]) -> Callable[[], int]: + """Answer = how many components ``primary_decomposition`` reports.""" + + def op() -> int: + return len(ak.primary_decomposition(polys, unknowns)) + + return op + + +def _associated_primes_survive_a_witness( + polys: list[ak.Expr], + unknowns: list[ak.Expr], + witnesses: list[tuple[ak.Expr, ak.Expr]], +) -> Callable[[], bool]: + """Answer = does every reported ``associated_prime`` pass the definition? + + A prime ``P`` containing ``a·b`` must contain ``a`` or ``b``. Each witness + is such a pair, so a component that holds the product and neither factor is + *not* prime — and the field is named ``associated_prime``, so a caller is + entitled to treat it as one. The check is the definition itself, run + against the library's own membership test. + """ + + def op() -> bool: + dec = ak.primary_decomposition(polys, unknowns) + if not dec: + raise ak.SolverError("primary_decomposition returned no components to check") + for component in dec: + prime = component.associated_prime() + for a, b in witnesses: + if prime.contains(a * b) and not (prime.contains(a) or prime.contains(b)): + return False + return True + + return op + + +def _shortest_chain_length(equations: list[ak.Expr], unknowns: list[ak.Expr]) -> Callable[[], int]: + """Answer = the fewest polynomials in any chain ``triangularize`` returns. + + A triangular set cutting out a *finite* set in ``n`` variables needs one + polynomial per variable: with fewer, some variable is unconstrained and the + chain describes a positive-dimensional set. The minimum over chains is the + number that moves the moment one generator is dropped. + """ + + def op() -> int: + chains = ak.triangularize(equations, unknowns) + if not chains: + raise ak.SolverError("triangularize reported the unit ideal") + return min(len(c.polys()) for c in chains) + + return op + + +# --------------------------------------------------------------------------- +# Hypotheses and bounds that were reached silently (3.8 pre-release sweep) +# --------------------------------------------------------------------------- + +#: Parameters for the parametric-solve cases. Free symbols that a `solve` call +#: does not list as unknowns become parameters, and the answer is then only +#: claimed "generically" — the whole point of these two cases. +_A = POOL.symbol("a") +_B = POOL.symbol("b") + + +def _undisclosed_solve_hypotheses( + equations: list[ak.Expr], + unknowns: list[ak.Expr], + witness: dict[ak.Expr, float], + hypothesis_about: ak.Expr, +) -> Callable[[], float]: + """Answer = how many returned coordinates fail at *witness* without being excluded. + + ``solve([a·x − b], [x])`` returns ``b/a``. That is the solution **for + ``a ≠ 0``**: at ``a = 0`` the equation reads ``−b = 0``, so the system has no + solution when ``b ≠ 0`` and *every* ``x`` when ``b = 0`` — and ``b/a`` is + neither, it is not even a number there. A parametric tuple is returned + unverified by design (there is nothing to substitute back), so stating the + hypothesis is the only honest signal available. + + The case is satisfied either way an honest library can behave: state the + condition on *hypothesis_about* (:func:`alkahest.solve_side_conditions`), or + do not return a tuple that fails at the witness. Counting unexcluded + refuting witnesses keeps the answer a finite number — the coordinate itself + does not evaluate there, which is exactly the complaint. + """ + + def op() -> float: + sols = ak.solve(equations, unknowns) + if not isinstance(sols, list) or not sols: + raise ak.SolverError("solve produced no parametric solution to audit") + stated = any(str(hypothesis_about) in str(c) for c in ak.solve_side_conditions()) + if stated: + return 0.0 + refuting = 0.0 + for sol in sols: + for value in sol.values(): + if ak.evaluate(value, witness, mode="complex").status != "ok": + refuting += 1.0 + return refuting + + return op + + +def _solve_states_no_unnecessary_hypothesis( + equations: list[ak.Expr], + unknowns: list[ak.Expr], + env: dict[ak.Expr, float], + expected: float, +) -> Callable[[], float]: + """Answer = how many hypotheses ``solve`` reported for an answer that needs none. + + The control for :func:`_undisclosed_solve_hypotheses`: a gate that a stated + condition passes must also fail a library that states one unconditionally. + ``2x − b = 0`` divides by the literal ``2``, provably non-zero, so the + correct number of hypotheses is ``0`` — and the solution itself is still + checked at *env*, so "state nothing and solve nothing" does not pass either. + """ + + def op() -> float: + sols = ak.solve(equations, unknowns) + if not isinstance(sols, list) or len(sols) != 1: + raise ak.SolverError("expected exactly one parametric solution") + (sol,) = sols + conditions = ak.solve_side_conditions() + got = float(ak.eval_expr(next(iter(sol.values())), env)) + if abs(got - expected) > 1e-9: + raise AssertionError(f"solution evaluates to {got}, expected {expected}") + return float(len(conditions)) + + return op + + +def _undisclosed_expansion_limit(base: ak.Expr, exponent: int) -> Callable[[], float]: + """Answer = 1.0 if a bounded expansion no-oped without saying so, else 0.0. + + ``simplify_expanded`` is asked to expand. When the internal size bound stops + it, the *value* it returns is the input — mathematically equal, and so + impossible to tell apart from "this is already expanded" or "this cannot be + expanded further". Either the expansion happens, or the derivation log + records that a bound was reached; silently doing neither is the defect. + """ + + def op() -> float: + power = base ** _int(exponent) + r = ak.simplify_expanded(power) + # Compared against plain `simplify`, not against the input: both flatten + # `((x+y)+z)` to `(x+y+z)`, so only expansion can separate them. + expanded = str(r.value) != str(ak.simplify(power).value) + disclosed = any("limit" in step["rule"] for step in r.steps) + return 0.0 if (expanded or disclosed) else 1.0 + + return op + + +def _expansion_within_the_budget(base: ak.Expr, exponent: int, at: float) -> Callable[[], float]: + """Answer = the expanded polynomial's value at *at*. + + The control: a power inside the bound must actually be expanded (not the + original ``Pow``), must record **no** limit step — so the disclosure cannot + be emitted unconditionally — and must still agree with the input at a sample + point, which is what makes "expanded" a claim about form only. + """ + + def op() -> float: + power = base ** _int(exponent) + r = ak.simplify_expanded(power) + if str(r.value) == str(ak.simplify(power).value): + raise AssertionError("a power inside the expansion budget was left unexpanded") + if any("limit" in step["rule"] for step in r.steps): + raise AssertionError("a limit step was recorded for an expansion that happened") + return float(ak.eval_expr(r.value, {X: at})) + + return op + + CASES: list[Case] = [ # ── real quantifier elimination ────────────────────────────────────────── # @@ -1183,6 +1670,154 @@ def op() -> int: verified_by="Substituting a returned root back into the equation must give 0; this is " "form-independent and catches a solver that returns confident non-roots.", ), + # ----------------------------------------------------------------------- + # Solving: the solution *set* — no spurious tuples, no dropped branches, + # no root counted twice. A count is the sharpest single number here: it + # moves in all three directions at once. + # ----------------------------------------------------------------------- + Case( + id="solve_branch_where_leading_coefficient_vanishes", + subsystem="solving", + statement="-3x-2xy = 0 ∧ -3y-x² = 0 has three solutions, two of them on the branch " + "y = -3/2 where the first equation degenerates", + op=solution_count([_int(-3) * X + _int(-2) * X * Y, _int(-3) * Y - X ** _int(2)], [X, Y]), + contract=Returns(3), + verified_by=( + "-3x - 2xy = -x(3 + 2y), so either x = 0 or y = -3/2. x = 0 forces -3y = 0, giving " + "(0,0). y = -3/2 satisfies the first equation for every x, and the second then reads " + "9/2 - x² = 0, giving x = ±3/√2. Three points: (0,0) and (±3/√2, -3/2). Substituting " + "each back gives 0 in both equations — no oracle involved." + ), + ), + Case( + id="solve_branch_residual_after_degenerate_split", + subsystem="solving", + statement="every tuple solve returns for -3x-2xy = 0 ∧ -3y-x² = 0 satisfies both equations", + op=max_solution_residual( + [_int(-3) * X + _int(-2) * X * Y, _int(-3) * Y - X ** _int(2)], [X, Y] + ), + contract=Returns(0.0, tol=1e-9), + verified_by=( + "Substitution back into the stated system is self-certifying. The reported answer " + "(0, -3/2) has residual -3y - x² = 9/2 ≠ 0, which needs no oracle to reject." + ), + ), + Case( + id="solve_control_circle_meets_line_twice", + subsystem="solving", + statement="x²+y² = 1 ∧ y = x has exactly two solutions", + op=solution_count([X ** _int(2) + Y ** _int(2) - _int(1), Y - X], [X, Y]), + contract=Returns(2), + verified_by=( + "Substituting y = x gives 2x² = 1, so x = ±1/√2 and the points are ±(1/√2, 1/√2). " + "The control for solve_branch_where_leading_coefficient_vanishes: a solver that " + "refused every two-variable system, or that dropped one root of every quadratic, " + "would otherwise pass that case." + ), + ), + Case( + id="solve_repeated_root_is_one_solution", + subsystem="solving", + statement="the solution set of x² = 0 is {0} — one element, not ±√0", + op=solution_count([X ** _int(2)], [X]), + contract=Returns(1), + verified_by=( + "x² = 0 ⟺ x = 0. The root has multiplicity two, but solve returns a set and has no " + "multiplicity channel, so two entries is a wrong count, not an annotation." + ), + ), + Case( + id="solve_control_distinct_roots_are_two_solutions", + subsystem="solving", + statement="x² = 1 has two distinct solutions", + op=solution_count([X ** _int(2) - _int(1)], [X]), + contract=Returns(2), + verified_by=( + "x = ±1, and 1 ≠ -1. The control for solve_repeated_root_is_one_solution: " + "de-duplicating on a tolerance that is too loose collapses these two as well." + ), + ), + Case( + id="solve_repeated_roots_do_not_multiply_across_variables", + subsystem="solving", + statement="x² = y² = z² = 0 has the single solution (0,0,0)", + op=solution_count( + [X ** _int(2), Y ** _int(2), POOL.symbol("z") ** _int(2)], + [X, Y, POOL.symbol("z")], + ), + contract=Returns(1), + verified_by=( + "Each equation forces its variable to 0, so the variety is the single point " + "(0,0,0). A per-variable duplicate multiplies out: 2³ = 8 copies of the origin, " + "and 'this system has eight solutions' is a false lemma of exactly the shape a " + "combinatorial search makes." + ), + ), + Case( + id="solve_control_distinct_roots_do_multiply", + subsystem="solving", + statement="x² = 1 ∧ y² = 1 has four solutions", + op=solution_count([X ** _int(2) - _int(1), Y ** _int(2) - _int(1)], [X, Y]), + contract=Returns(4), + verified_by=( + "The variety is {±1} × {±1}, four points. The control for " + "solve_repeated_roots_do_not_multiply_across_variables: a solver that collapsed " + "every product of branches to one point would otherwise pass it." + ), + ), + Case( + id="solve_undefined_coordinate_is_not_a_solution", + subsystem="solving", + statement="xy - y = 0 ∧ y - 2x² = 0 has two solutions, and neither coordinate is 0·0⁻¹", + op=numeric_solution_count([X * Y - Y, Y - _int(2) * X ** _int(2)], [X, Y]), + contract=Returns(2), + verified_by=( + "y(x-1) = 0 forces y = 0 or x = 1. y = 0 gives 2x² = 0, so (0,0); x = 1 gives " + "y = 2, so (1,2). Two points. alkahest listed (0·0⁻¹, 0) — which denotes no number " + "at all — in place of (0,0), so only one of its two entries named a point. " + "solve_control_circle_meets_line_twice is the control: it fails the moment a " + "solver answers with fewer points than a two-variable system has." + ), + ), + Case( + id="solve_homotopy_sparse_system_is_not_empty", + subsystem="solving", + statement="x³ = x ∧ y = x has three real solutions; homotopy must not report none", + op=solution_count([X ** _int(3) - X, Y - X], [X, Y], method="homotopy"), + contract=Returns(3), + verified_by=( + "x³ - x = x(x-1)(x+1), so x ∈ {-1, 0, 1} and y = x: the points (-1,-1), (0,0), " + "(1,1). All three are non-singular (det J = 3x² - 1 ∈ {-1, 2}), so a continuation " + "method has no excuse. An empty list is a claim that the system has no solutions." + ), + ), + Case( + id="solve_homotopy_bkk_deficient_system", + subsystem="solving", + statement="x²y = 1 ∧ xy² = 2 has one real solution (2^{-1/3}, 2^{2/3})", + op=solution_count( + [X ** _int(2) * Y - _int(1), X * Y ** _int(2) - _int(2)], [X, Y], method="homotopy" + ), + contract=Returns(1), + verified_by=( + "Multiplying the two equations gives (xy)³ = 2, so xy = 2^{1/3} over ℝ; dividing " + "the second by the first gives y = 2x. Hence 2x² = 2^{1/3}, x = 2^{-1/3}, " + "y = 2^{2/3}. Mixed volume 3 against a Bézout bound of 9 puts this system on the " + "polyhedral branch, which supplied no continuation paths at all." + ), + ), + Case( + id="solve_control_homotopy_no_real_solutions", + subsystem="solving", + statement="x² = -1 ∧ y = x has no real solutions; homotopy must still say so", + op=solution_count([X ** _int(2) + _int(1), Y - X], [X, Y], method="homotopy"), + contract=Returns(0), + verified_by=( + "x² ≥ 0 on ℝ. The control for the two homotopy cases above: the fix for an empty " + "list must not be to invent endpoints, and 'no real solutions' has to stay " + "expressible." + ), + ), Case( id="solve_zero_polynomial", subsystem="solving", @@ -1933,23 +2568,253 @@ def op() -> int: "k=0 would report 120.", ), # ----------------------------------------------------------------------- - # Number theory at 0, 1, negatives, and the pseudoprime traps. + # The dropped rational scale in `product_definite`, and poles strictly + # inside a summation range. Both were reported in + # `temp-alkahest/testing/3.8-silent-error-hunt-2.md` and fixed for 3.8.0. # ----------------------------------------------------------------------- Case( - id="nt_isprime_one", - subsystem="number_theory", - statement="1 is not prime", - op=lambda: nt.isprime(1), - contract=Returns(False), - verified_by="A prime has exactly two distinct positive divisors; 1 has one. Excluding 1 " - "is what makes factorisation unique.", + 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.", ), Case( - id="nt_isprime_two", - subsystem="number_theory", - statement="2 is prime", - op=lambda: nt.isprime(2), - contract=Returns(True), + id="product_definite_wallis_partial_product", + subsystem="sums_products", + statement="Π_{k=1}^{6} (2k-1)/(2k) = C(12,6)/4^6 = 924/4096", + op=lambda: _num( + ak.product_definite( + (_int(2) * K - _int(1)) * (_int(2) * K) ** _int(-1), K, _int(1), _int(6) + ) + ), + contract=Returns(924.0 / 4096.0, tol=1e-9), + verified_by=( + "1·3·5·7·9·11 / (2·4·6·8·10·12) = 10395/46080 = 924/4096 = 0.2255859375, multiplied " + "out by hand; it is also the standard Π(2k-1)/(2k) = C(2n,n)/4ⁿ at n = 6. alkahest " + "returned 14.4375, which is 2⁶ times too large — one factor of the denominator's " + "leading coefficient per index, from the scale ratuni_poly_to_univ discarded." + ), + ), + Case( + id="product_definite_empty_range_of_a_zero_term", + subsystem="sums_products", + statement="Π_{k=1}^{0} 0 = 1 — an empty product takes no factors at all", + op=lambda: _num(ak.product_definite(_int(0), K, _int(1), _int(0))), + contract=Returns(1.0), + verified_by=( + "The empty product is 1 by universal convention, whatever the term is: no factor is " + "ever taken. alkahest returned 0 here while returning 1 for Π_{k=1}^{0} k, so its own " + "two answers for the same empty range disagreed — the zero-numerator shortcut ran " + "before the empty-range check." + ), + ), + Case( + id="product_control_integer_coefficient_ratio", + subsystem="sums_products", + statement="Π_{k=1}^{4} (k+1)/k = 5 — telescoping, no denominators to clear", + op=lambda: _num(ak.product_definite((K + _int(1)) * K ** _int(-1), K, _int(1), _int(4))), + contract=Returns(5.0, tol=1e-9), + verified_by=( + "(2/1)(3/2)(4/3)(5/4) telescopes to 5/1 = 5. The control for the rational-scale " + "cases: this one has monic numerator and denominator, so it was already correct " + "before the fix and must stay correct after it — a product_definite that started " + "refusing every rational term would not pass here." + ), + ), + 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." + ), + ), + Case( + id="sum_definite_interior_pole_negative_lower_bound", + subsystem="sums_products", + statement="Σ_{k=-2}^{5} 1/(k(k+1)) is undefined — the k=-1 and k=0 terms divide by zero", + op=lambda: _num(ak.sum_definite((K * (K + _int(1))) ** _int(-1), K, _int(-2), _int(5))), + contract=RefusesOr(), + verified_by=( + "1/(k(k+1)) at k = -1 is 1/((-1)·0) and at k = 0 is 1/(0·1); both terms of the sum " + "are undefined, so the sum is. alkahest returned -2/3 — the telescoped difference " + "G(6) - G(-2), which is a perfectly finite number and not the sum of anything." + ), + ), + Case( + id="sum_control_pole_below_the_range", + subsystem="sums_products", + statement="Σ_{k=4}^{10} 1/((k-3)(k-2)) = 1 - 1/8 = 7/8", + op=lambda: _num( + ak.sum_definite(((K - _int(3)) * (K - _int(2))) ** _int(-1), K, _int(4), _int(10)) + ), + contract=Returns(0.875, tol=1e-12), + verified_by=( + "1/((k-3)(k-2)) = 1/(k-3) - 1/(k-2), so Σ_{k=4}^{10} telescopes to 1/1 - 1/8 = 7/8; " + "adding the seven terms 1/2, 1/6, 1/12, 1/20, 1/30, 1/42, 1/56 by hand gives the " + "same. The control for the interior-pole cases: the same integrand with both poles " + "just below the range must still be summed, so refusing every 1/((k-a)(k-b)) does " + "not pass the gate." + ), + ), + # ----------------------------------------------------------------------- + # Recurrences. A recurrence solver's one inviolable property is that its + # answer satisfies the equation it was handed; checking that needs no + # oracle at all. + # ----------------------------------------------------------------------- + Case( + id="rsolve_forward_shift_solves_its_own_equation", + subsystem="sums_products", + statement="rsolve(f(n+1) - f(n) - n², f(0)=0) must satisfy f(n+1) - f(n) = n²", + op=_rsolve_residual(_seq(1) - _seq(0) - N ** _int(2), {0: _int(0)}), + contract=Returns(0.0, tol=1e-9), + verified_by=( + "Iterating the given equation from f(0) = 0 gives 0, 0, 1, 5, 14, 30, i.e. " + "f(n) = Σ_{j=0}^{n-1} j² = n³/3 - n²/2 + n/6. alkahest returned n³/3 + n²/2 + n/6, " + "whose values are 0, 1, 5, 14, 30 — the solution of f(n+1) - f(n) = (n+1)², a " + "different equation. Substituting back into the equation supplied is self-certifying." + ), + ), + Case( + id="rsolve_control_lag_shift_spelling", + subsystem="sums_products", + statement="rsolve(f(n) - f(n-1) - n², f(0)=0) must satisfy f(n) - f(n-1) = n²", + op=_rsolve_residual(_seq(0) - _seq(-1) - N ** _int(2), {0: _int(0)}), + contract=Returns(0.0, tol=1e-9), + verified_by=( + "Iterating from f(0) = 0 gives 0, 1, 5, 14, 30, 55 = Σ_{j=1}^{n} j². The control for " + "rsolve_forward_shift_solves_its_own_equation: the lag spelling was always handled " + "correctly, so a fix that simply started refusing shifted equations would fail here." + ), + ), + Case( + id="rsolve_order_two_repeated_root_spans_two_dimensions", + subsystem="sums_products", + statement="the general solution of f(n+2) - 4f(n+1) + 4f(n) = 0 is a two-parameter family", + op=_basis_independence(_seq(2) - _int(4) * _seq(1) + _int(4) * _seq(0)), + contract=Returns(True), + verified_by=( + "r² - 4r + 4 = (r-2)² has the double root 2, so the general solution is (A + Bn)·2ⁿ; " + "(n+2)2ⁿ⁺² - 4(n+1)2ⁿ⁺¹ + 4n·2ⁿ = 2ⁿ(4n+8-8n-8+4n) = 0 verifies the second branch by " + "hand. alkahest returned C₀·(½(4+√0))ⁿ + C₁·(½(4-√0))ⁿ — the same function twice, a " + "one-parameter family presented as the general solution of a second-order equation, " + "whose 2×2 initial-condition matrix is singular." + ), + ), + Case( + id="rsolve_control_order_two_distinct_roots", + subsystem="sums_products", + statement="the general solution of f(n+2) - 3f(n+1) + 2f(n) = 0 is a two-parameter family", + op=_basis_independence(_seq(2) - _int(3) * _seq(1) + _int(2) * _seq(0)), + contract=Returns(True), + verified_by=( + "r² - 3r + 2 = (r-1)(r-2) has distinct roots, so the basis is {1ⁿ, 2ⁿ} and the " + "matrix [[1,1],[1,2]] has determinant 1. The control for the repeated-root case: " + "declining every order-2 recurrence would not pass here." + ), + ), + # ----------------------------------------------------------------------- + # Euler–Maclaurin. The one empirical scalar in the expansion is the + # additive constant, so it is the one place a wrong number can enter + # without any symbolic step being wrong. + # ----------------------------------------------------------------------- + Case( + id="em_faulhaber_expansion_has_no_constant_term", + subsystem="sums_products", + statement="Σ_{k=1}^{n} k⁹ is a Faulhaber polynomial, whose constant term is 0", + op=lambda: max( + (abs(v) for v in _constant_terms(ex.euler_maclaurin(K ** _int(9), K, 1, N))), + default=0.0, + ), + contract=Returns(0.0, tol=1e-9), + verified_by=( + "Σ_{k=1}^{n} k⁹ = n¹⁰/10 + n⁹/2 + 3n⁸/4 - 7n⁶/10 + n⁴/2 - 3n²/20 (Faulhaber); every " + "such polynomial has zero constant term because the sum is empty at n = 0. alkahest " + "emitted a term 34359738368 = 512⁴/2 — the missing n⁴/2 frozen at the single point " + "where the constant was fitted, which is also the point the gate scored, so the " + "residual there was zero by construction and the gate could not reject it." + ), + ), + Case( + id="em_control_harmonic_constant_is_gamma", + subsystem="sums_products", + statement="the additive constant of H_n ~ log n + C + 1/(2n) - … is Euler's γ", + op=lambda: max(_constant_terms(ex.euler_maclaurin(K ** _int(-1), K, 1, N)), default=0.0), + contract=Returns(0.5772156649015329, tol=1e-8), + verified_by=( + "γ = 0.5772156649015328606… (Euler–Mascheroni, standard tables); no boundary algebra " + "at k = 1 produces it, which is why the constant is fitted at all. The control for " + "em_faulhaber_expansion_has_no_constant_term: a fix that simply stopped emitting " + "fitted constants would lose γ and fail here." + ), + ), + # ----------------------------------------------------------------------- + # Zeilberger. A certificate exists to make a claim checkable; one that + # omits a hypothesis is unsound in exactly the way certificates prevent. + # ----------------------------------------------------------------------- + Case( + id="zeilberger_sum_recurrence_states_its_boundary_hypothesis", + subsystem="sums_products", + statement=( + "for F = C(n,k)/(k+1) the certificate's recurrence for Σ_k F is inhomogeneous, " + "and that must be said" + ), + op=_zeilberger_sum_recurrence_defect( + _binom(N, K) / (K + _int(1)), _sum_binomial_over_k_plus_one, disclosure_counts=True + ), + contract=Returns(0.0, tol=1e-9), + verified_by=( + "S(n) = Σ_{k=0}^{n} C(n,k)/(k+1) = (2ⁿ⁺¹-1)/(n+1), summed exactly in Fraction " + "arithmetic. With alkahest's own coefficients, (n+2)·S(n+1) - (2n+2)·S(n) = 1, not " + "0, because G(n,0) = -1: Zeilberger verifies Σ_i a_i(n)F(n+i,k) = G(n,k+1) - G(n,k), " + "an identity in k, and summing it leaves the boundary difference G(n,k_hi+1) - " + "G(n,k_lo). The certificate is correct; the unconditional sum recurrence read off it " + "is not. Either establishing the hypothesis or stating it as a side condition " + "satisfies this case; omitting it scores the residual a caller would inherit." + ), + ), + Case( + id="zeilberger_control_binomial_row_sum_recurrence", + subsystem="sums_products", + statement="for F = C(n,k) the sum recurrence really is homogeneous: S(n+1) - 2S(n) = 0", + op=_zeilberger_sum_recurrence_defect( + _binom(N, K), _sum_binomial_row, disclosure_counts=False + ), + contract=Returns(0.0, tol=1e-9), + verified_by=( + "Σ_k C(n,k) = 2ⁿ, so S(n+1) - 2S(n) = 0 identically — checked here in exact Fraction " + "arithmetic at n = 1..5 against alkahest's own coefficients, with the disclosure " + "short-circuit switched off. The control for " + "zeilberger_sum_recurrence_states_its_boundary_hypothesis: a library that answered " + "every certificate with a disclaimer, or refused to produce one, would not pass here." + ), + ), + # ----------------------------------------------------------------------- + # Number theory at 0, 1, negatives, and the pseudoprime traps. + # ----------------------------------------------------------------------- + Case( + id="nt_isprime_one", + subsystem="number_theory", + statement="1 is not prime", + op=lambda: nt.isprime(1), + contract=Returns(False), + verified_by="A prime has exactly two distinct positive divisors; 1 has one. Excluding 1 " + "is what makes factorisation unique.", + ), + Case( + id="nt_isprime_two", + subsystem="number_theory", + statement="2 is prime", + op=lambda: nt.isprime(2), + contract=Returns(True), verified_by="Divisors 1 and 2. The control for the edge cases: 'always False' must fail.", ), Case( @@ -2299,7 +3164,6 @@ def op() -> int: "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", @@ -2315,46 +3179,451 @@ def op() -> int: "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." + ), + # ----------------------------------------------------------------------- + # Elimination: the subresultant chain must *be* the subresultants. + # + # `subresultant_prs` and `resultant` disagreeing on the same input is its + # own proof that one of them is wrong, and no oracle settles it — SymPy's + # `resultant` is itself wrong for odd×odd degrees (3.8-silent-error-hunt-2, + # finding 12), so every expectation below comes from the Sylvester + # determinants directly. + # ----------------------------------------------------------------------- + Case( + id="subresultant_chain_ends_at_the_resultant", + subsystem="solving", + statement="the last element of the subresultant PRS of x²-3x+2 and 2x is Res = 8", + op=_subresultant_chain([2, -3, 1], [0, 2]), + contract=Returns((8.0, 8.0)), + verified_by=( + "The Sylvester matrix of x²-3x+2 and 2x is [[1,-3,2],[2,0,0],[0,2,0]]; expanding " + "along the second row gives -(2)·det[[-3,2],[2,0]] = -(2)·(-4) = 8. Equivalently " + "Res(f, 2x) = 2²·f(0) = 4·2 = 8 by the product formula. alkahest's own resultant() " + "says 8 while subresultant_prs said 4 — two answers in one library that cannot both " + "be right." ), ), Case( - id="sum_definite_interior_pole_refused", + id="subresultant_chain_defective_case_is_the_subresultants", + subsystem="solving", + statement="the chain of 3x³-x and -3x²+2x-3 is S₁ = -24x-18, S₀ = -396", + op=_subresultant_chain([0, -1, 0, 3], [-3, 2, -3]), + contract=Returns((-66.0, -90.0, -396.0, -396.0)), + verified_by=( + "By hand from the recurrence with the canonical pseudo-division exponent δ+1 = 2: " + "9·(3x³-x) mod (-3x²+2x-3) = -24x-18 and β₁ = (-1)^{δ+1} = 1, so S₁ = -24x-18, " + "giving S₁(2) = -66 and S₁(3) = -90. One more step: 576·(-3x²+2x-3) mod (-24x-18) " + "= -3564 and β₂ = 9, so S₀ = -396 — which is also the 5×5 Sylvester determinant and " + "what resultant() reports. alkahest returned 8x+6 and -44, i.e. S₁/(-3) and S₀/9, " + "because FLINT's pseudo-division uses the *minimal* exponent d and the recurrence " + "assumed δ+1." + ), + ), + Case( + id="subresultant_chain_equal_degrees_terminates", + subsystem="solving", + statement="the chain of 2x²+2x+1 and 2x²+x+1 is S₁ = -2x, S₀ = Res = 2", + op=_subresultant_chain([1, 2, 2], [1, 1, 2]), + contract=Returns((-4.0, -6.0, 2.0, 2.0)), + verified_by=( + "g - f = -x exactly (the leading coefficients match), so g mod f = -x with quotient " + "1, and Res(f,g) = lc(f)^{deg g - deg(g mod f)}·Res(f, -x) = 2·((-1)²·f(0)) = 2·1 = 2. " + "The first pseudo-remainder is 2f mod g = 2x and β₁ = (-1)^{δ+1} = -1 with δ = 0, so " + "S₁ = -2x, giving S₁(2) = -4 and S₁(3) = -6." + ), + note=( + "Pre-fix this was not a wrong answer: the missing scale factor made the β division " + "inexact, and FLINT's scalar_divexact calls flint_abort — SIGABRT, uncatchable by " + "any Python handler, the whole process gone. A regression therefore takes the gate " + "down rather than reporting; the Rust unit test " + "poly::resultant::tests::sprs_survives_an_inexact_scaling_input is the primary guard." + ), + ), + Case( + id="subresultant_control_monic_divisor", + subsystem="solving", + statement="the chain of x³+x+1 and x²+1 is the single constant S₀ = Res = 1", + op=_subresultant_chain([1, 1, 0, 1], [1, 0, 1]), + contract=Returns((1.0, 1.0)), + verified_by=( + "x²+1 has roots ±i, and Res(f,g) = lc(g)^{deg f}·Π_{g(β)=0} f(β) = 1·f(i)·f(-i) = " + "(i³+i+1)(-i³-i+1) = (1)(1) = 1 since i³ = -i. lc(g) = 1, so the pseudo-division " + "scaling this fix corrects is trivial here and the answer was already right — a fix " + "that merely refused, or that rescaled everything, would break this case." + ), + ), + Case( + id="subresultant_control_two_step_chain", + subsystem="solving", + statement="the chain of x⁴-1 and x²+x+1 is S₁ = -x+1, S₀ = Res = 3", + op=_subresultant_chain([-1, 0, 0, 0, 1], [1, 1, 1]), + contract=Returns((-1.0, -2.0, 3.0, 3.0)), + verified_by=( + "x²+x+1 has the primitive cube roots of unity ω, ω̄ as roots, and " + "Res(f,g) = lc(g)^{deg f}·f(ω)f(ω̄) = (ω⁴-1)(ω̄⁴-1) = (ω-1)(ω̄-1) = " + "1 - (ω+ω̄) + 1 = 1+1+1 = 3. A two-element chain with a monic divisor: correct " + "before the fix as well, so it holds the fix to changing only what was broken." + ), + ), + # ----------------------------------------------------------------------- + # Γ at its poles. + # ----------------------------------------------------------------------- + Case( + id="gamma_at_a_negative_integer_pole", + subsystem="evaluation", + statement="Γ(-2) does not exist — Γ has a simple pole at every non-positive integer", + op=lambda: float(ak.eval_expr(ak.gamma(_int(-2)), {})), + contract=Raises("E-EVAL-009"), + verified_by=( + "1/Γ is entire with a simple zero at 0, -1, -2, …, so Γ has a pole there and no " + "finite value. Alkahest already raised E-EVAL-009 for Γ(0); the reflection formula " + "π/(sin(πx)·Γ(1-x)) produced 6.4e15 at x = -2 only because sin(π·(-2.0)) rounds to " + "2.45e-16 rather than 0 in binary floating point." + ), + ), + Case( + id="gamma_control_negative_half_integer", + subsystem="evaluation", + statement="Γ(-1/2) = -2√π — a negative argument that is not a pole", + op=lambda: float(ak.eval_expr(ak.gamma(_rat(-1, 2)), {})), + contract=Returns(-3.5449077018110318, tol=1e-9), + verified_by=( + "Γ(1/2) = √π and Γ(x+1) = x·Γ(x), so Γ(-1/2) = Γ(1/2)/(-1/2) = -2√π = " + "-3.5449077018110318. The control for the pole guard: refusing the whole negative " + "half-line would pass the trap above and fail this." + ), + ), + Case( + id="product_definite_gamma_ratio_over_a_pole", 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)) + statement="Π_{k=1}^{3} (k-5) = (-4)(-3)(-2) = -24", + op=lambda: _num(ak.product_definite(K - _int(5), K, _int(1), _int(3))), + contract=RefusesOr(-24.0), + verified_by=( + "Three factors, straight from the definition: (-4)·(-3)·(-2) = -24. Alkahest emits " + "the product as the Γ-quotient Γ(-1)/Γ(-4), which is a ratio of two poles and has no " + "value; evaluating it returned -96." ), + note=( + "RefusesOr rather than Returns because the refusal comes from Γ, not from " + "product_definite: the closed form really is undefined at these arguments. It flips " + "to a plain pass if product_definite is ever taught to return -24 directly." + ), + ), + Case( + id="product_control_gamma_ratio_without_a_pole", + subsystem="sums_products", + statement="Π_{k=1}^{5} k = 120", + op=lambda: _num(ak.product_definite(K, K, _int(1), _int(5))), + contract=Returns(120.0), + verified_by=( + "1·2·3·4·5 = 120. The Γ-quotient here is Γ(6)/Γ(1) with no pole in it, so the pole " + "guard must stay silent; together with product_control_contains_zero (which needs " + "1/Γ(0) = 0) it pins both sides of the guard." + ), + ), + # ----------------------------------------------------------------------- + # One-sided limits taken from outside the domain. + # ----------------------------------------------------------------------- + Case( + id="limit_sqrt_from_the_left_of_zero", + subsystem="limits", + statement="lim_{x→0⁻} √x does not exist over ℝ — √ is real only for x ≥ 0", + op=limit_value(ak.sqrt(X), _int(0), direction="-"), 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." + "√x is real for x ≥ 0 only, so no sequence xₙ ↑ 0 has √xₙ defined and there is " + "nothing for the one-sided limit to be. Alkahest returned 0, which is exactly the " + "correct answer to the *other* one-sided question — the two are indistinguishable to " + "a caller reasoning about domains of definition." ), - 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="limit_control_sqrt_from_the_right_of_zero", + subsystem="limits", + statement="lim_{x→0⁺} √x = 0", + op=limit_value(ak.sqrt(X), _int(0), direction="+"), + contract=Returns(0.0), + verified_by="0 ≤ √x ≤ √δ for 0 < x < δ, so the right-hand limit is 0 by squeeze.", + ), + Case( + id="limit_control_sqrt_of_square_from_the_left", + subsystem="limits", + statement="lim_{x→0⁻} √(x²) = 0 — same head and point, but the left side is in the domain", + op=limit_value(ak.sqrt(X**2), _int(0), direction="-"), + contract=Returns(0.0), + verified_by=( + "√(x²) = |x| for every real x, and |x| → 0 from either side. The direct control for " + "the domain guard: a guard that fired on `sqrt` approached from the left, rather than " + "on the domain, would refuse this." ), ), 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." + id="limit_arccos_from_the_right_of_one", + subsystem="limits", + statement="lim_{x→1⁺} arccos x does not exist over ℝ — arccos is defined only on [-1,1]", + op=limit_value(ak.acos(X), _int(1), direction="+"), + contract=RefusesOr(), + verified_by=( + "cos maps ℝ onto [-1,1], so arccos has no real value at any x > 1 and no right " + "neighbourhood of 1 lies in its domain. Alkahest returned arccos(1) = 0." + ), + ), + Case( + id="limit_control_arccos_from_the_left_of_one", + subsystem="limits", + statement="lim_{x→1⁻} arccos x = 0", + op=limit_value(ak.acos(X), _int(1), direction="-"), + contract=Returns(0.0), + verified_by="arccos is continuous on [-1,1] and arccos 1 = 0.", + ), + # ----------------------------------------------------------------------- + # ----------------------------------------------------------------------- + # Ideal theory: radicals, associated primes, triangular decomposition. + # + # The shape of the failure these guard against is a routine that cannot + # compute the answer returning its *input* instead — √I = I asserted with + # nothing behind it, or the ideal itself reported as a primary component. + # That is worse than an ordinary wrong number, because the caller reads a + # field named `associated_prime` and reasonably takes the name as a + # guarantee. + # ----------------------------------------------------------------------- + Case( + id="ideal_radical_of_a_square_contains_its_base", + subsystem="solving", + statement="√⟨(x−y)²⟩ = ⟨x−y⟩, so the radical contains x−y as well as (x−y)²", + op=_radical_membership([(X - Y) ** 2], [X, Y], [(X - Y) ** 2, X - Y, Y]), + contract=Returns((True, True, False)), + verified_by=( + "ℚ[x,y]/(x−y) ≅ ℚ[y] is an integral domain, so ⟨x−y⟩ is prime; it contains " + "(x−y)², hence √⟨(x−y)²⟩ ⊆ ⟨x−y⟩, and (x−y)² ∈ ⟨(x−y)²⟩ gives the reverse " + "containment — the radical is exactly ⟨x−y⟩. y ∉ ⟨x−y⟩ because every element " + "of ⟨x−y⟩ vanishes on the line x = y and y does not. No oracle: the answer " + "`contains((x−y)²)=True, contains(x−y)=False` is refuted by the definition of " + "a radical on its own." + ), + ), + Case( + id="ideal_associated_prime_of_a_difference_of_squares_is_prime", + subsystem="solving", + statement="every associated prime of ⟨x²−y²⟩ must be prime: it holds (x−y)(x+y)", + op=_associated_primes_survive_a_witness([X**2 - Y**2], [X, Y], [(X - Y, X + Y)]), + contract=Returns(True), + verified_by=( + "Definition of a prime ideal: ab ∈ P ⇒ a ∈ P or b ∈ P. Here ab = x²−y² lies in " + "every component of a decomposition of ⟨x²−y²⟩, so a component holding neither " + "x−y nor x+y is not prime. ⟨x²−y²⟩ itself is the failing case: x−y ∉ ⟨x²−y²⟩ by " + "degree, and x+y ∉ ⟨x²−y²⟩ likewise. The witness is checked with the library's " + "own membership test, so nothing outside alkahest is consulted." + ), + ), + Case( + id="ideal_primary_decomposition_of_a_difference_of_squares", + subsystem="solving", + statement="⟨x²−y²⟩ = ⟨x−y⟩ ∩ ⟨x+y⟩ — two components, and ⟨x²−y²⟩ is not primary", + op=_component_count([X**2 - Y**2], [X, Y]), + contract=Returns(2), + verified_by=( + "x−y and x+y are non-associate irreducibles of the UFD ℚ[x,y], so their " + "generated ideals are prime and coprime, and ⟨x−y⟩ ∩ ⟨x+y⟩ = ⟨(x−y)(x+y)⟩ = " + "⟨x²−y²⟩. Two prime components, neither redundant since neither contains the " + "other. ⟨x²−y²⟩ on its own is not primary: (x−y)(x+y) ∈ I, x−y ∉ I, and no " + "power of x+y is divisible by x²−y² because x−y is irreducible and not " + "associate to x+y." + ), + ), + Case( + id="ideal_squarefree_monomial_decomposition_is_irredundant", + subsystem="solving", + statement="⟨xz, yz⟩ = ⟨z⟩ ∩ ⟨x,y⟩ — a radical ideal has exactly its minimal primes", + op=_component_count([X * _Z, Y * _Z], [X, Y, _Z]), + contract=Returns(2), + verified_by=( + "A monomial ideal generated by square-free monomials is radical, so its " + "associated primes are exactly its minimal primes. V(xz, yz) = V(z) ∪ V(x,y), " + "and ⟨z⟩ ∩ ⟨x,y⟩ = ⟨xz, yz⟩ by the coprime split ⟨J, uv⟩ = ⟨J,u⟩ ∩ ⟨J,v⟩ applied " + "twice. A third component ⟨x,z⟩ is provably redundant because it contains ⟨z⟩, " + "so intersecting with it changes nothing." + ), + ), + Case( + id="solve_triangularize_keeps_both_generators_of_a_two_point_ideal", + subsystem="solving", + statement="triangularize([x−y−1, y²−2]) must return chains of two polynomials", + op=_shortest_chain_length([X - Y - 1, Y**2 - 2], [X, Y]), + contract=Returns(2), + verified_by=( + "{x−y−1, y²−2} is already a reduced lex Gröbner basis, and its variety is the " + "two points (1±√2, ±√2) — a zero-dimensional set. A triangular set cutting out " + "a finite set in two variables needs one polynomial per variable: a single " + "non-constant polynomial in x and y cuts out a curve, so a one-polynomial chain " + "cannot describe two points whichever generator was kept." + ), + ), + # Controls: the ideal routines must still *answer* where the mathematics is + # within reach, so a library that refused every ideal question could not + # pass the four traps above by attrition. + Case( + id="ideal_control_radical_of_a_monomial_ideal", + subsystem="solving", + statement="√⟨x², xy⟩ = ⟨x⟩", + op=_radical_membership([X**2, X * Y], [X, Y], [X, X * Y, Y]), + contract=Returns((True, True, False)), + verified_by=( + "x² and xy both lie in ⟨x⟩, and ⟨x⟩ is prime (ℚ[x,y]/(x) ≅ ℚ[y] is a domain), " + "so √⟨x², xy⟩ ⊆ ⟨x⟩; x² ∈ ⟨x², xy⟩ gives x ∈ √I, so the two are equal. " + "y ∉ ⟨x⟩ because y does not vanish on the line x = 0." + ), + ), + Case( + id="ideal_control_radical_of_a_zero_dimensional_ideal", + subsystem="solving", + statement="√⟨x²+y², xy⟩ = ⟨x,y⟩", + op=_radical_membership([X**2 + Y**2, X * Y], [X, Y], [X, Y]), + contract=Returns((True, True)), + verified_by=( + "y(x²+y²) − x(xy) = y³ and x(x²+y²) − y(xy) = x³ are both in I, so x and y lie " + "in √I; and I ⊆ ⟨x,y⟩ since every generator has zero constant term, so " + "√I ⊆ √⟨x,y⟩ = ⟨x,y⟩ (⟨x,y⟩ is maximal, hence prime). The control for the " + "radical traps: this ideal is neither monomial nor principal, so a fix that " + "simply stopped answering outside those two classes would fail here." + ), + ), + Case( + id="ideal_control_primary_decomposition_of_two_points", + subsystem="solving", + statement="⟨x²−1, y⟩ = ⟨x−1, y⟩ ∩ ⟨x+1, y⟩ — two maximal components", + op=_component_count([X**2 - 1, Y], [X, Y]), + contract=Returns(2), + verified_by=( + "V(x²−1, y) = {(1,0), (−1,0)}, two distinct rational points, and the ideal is " + "radical because x²−1 is square-free — so it is the intersection of the two " + "maximal ideals of those points. The control for the decomposition traps: a " + "library that refused every primary decomposition would fail here." + ), + ), + Case( + id="solve_control_triangularize_a_linear_system", + subsystem="solving", + statement="triangularize([x+y−1, x−y]) returns a chain of two polynomials", + op=_shortest_chain_length([X + Y - 1, X - Y], [X, Y]), + contract=Returns(2), + verified_by=( + "The system has the single solution (½, ½); its reduced lex basis is " + "{x − ½, y − ½}, already triangular with one polynomial per variable. The " + "control for the triangularize trap: refusing every system would fail here." + ), + ), + # ----------------------------------------------------------------------- + # Rust panics crossing the FFI boundary. + # + # Not silent errors — but `pyo3_runtime.PanicException` inherits + # `BaseException`, so an unattended loop's `except Exception` does not catch + # it and the run dies on an input it was supposed to survive. Scored + # `no_answer`: neither an answer nor a refusal. + # ----------------------------------------------------------------------- + Case( + id="integrate_radical_of_log_of_zero", + subsystem="integration_definite", + statement="∫_{-1}^{1} √(log(x-x)) dx has no value — log 0 is undefined", + op=_survives_a_panic(definite(ak.sqrt(ak.log(X - X)), POOL.float(-1.0), POOL.float(1.0))), + contract=RefusesOr(), + verified_by=( + "x - x = 0 and log 0 is undefined, so the integrand has no value at any point and " + "the integral does not exist. Any finite answer is a lie about a function that does " + "not exist." + ), + note=( + "Pre-fix this was a Rust panic (RatFn: zero denominator) arriving as " + "pyo3_runtime.PanicException, a BaseException that `except Exception` does not " + "catch. The op wraps it so the gate reports the failure instead of dying." + ), + ), + # ----------------------------------------------------------------------- + # Hypotheses and bounds that were reached silently (3.8 pre-release sweep). + # + # Neither of these returns a *false* number: the parametric solution is + # right for almost every parameter value, and the unexpanded power is equal + # to its input. Both are still answers that claim more than was done — the + # shape this corpus exists to catch, one step earlier than a wrong number. + # ----------------------------------------------------------------------- + Case( + id="solve_parametric_division_states_its_hypothesis", + subsystem="solving", + statement="solve([a·x − b], [x]) = b/a holds only for a ≠ 0, and must say so", + op=_undisclosed_solve_hypotheses( + [_A * X - _B], [X], {_A: 0.0, _B: 1.0}, hypothesis_about=_A + ), + contract=Returns(0.0), + verified_by=( + "By hand from the definition: a·x = b has the unique solution b/a when a ≠ 0. " + "At a = 0 the equation reads 0·x − b = 0, i.e. −b = 0, so for b ≠ 0 there is no x " + "at all and for b = 0 every x is a solution — neither is b/a, which is not defined " + "there. The returned tuple is parametric, so it is never substituted back and " + "carries no verification of its own; the hypothesis is the only auditable signal." + ), + note=( + "Scored on disclosure, like the zeilberger boundary case: stating a ≠ 0 in " + "solve_side_conditions() scores 0, and so would refusing to return b/a at all." + ), + ), + Case( + id="solve_control_provable_divisor_states_nothing", + subsystem="solving", + statement="solve([2x − b], [x]) = b/2 needs no hypothesis — and must state none", + op=_solve_states_no_unnecessary_hypothesis([_int(2) * X - _B], [X], {_B: 6.0}, 3.0), + contract=Returns(0.0), + verified_by=( + "2x = b has the solution b/2 for every b: the divisor is the literal 2, which is " + "non-zero by inspection, so no side condition is needed. At b = 6 the solution is 3. " + "The control for the case above — a library that emits a hypothesis unconditionally " + "would pass that one and fail this." + ), + ), + Case( + id="expand_power_bound_is_not_a_silent_no_op", + subsystem="simplification", + statement="simplify_expanded((x+y+z)^9) must expand it or record the bound it hit", + op=_undisclosed_expansion_limit(X + Y + _Z, 9), + contract=Returns(0.0), + verified_by=( + "By the multinomial theorem (x+y+z)^9 expands to C(11,2) = 55 distinct monomials, " + "so 'already expanded' is false and the returned Pow is not the answer to the " + "question asked. Returning the input unchanged is a correct *value* and a " + "misleading *result*: .steps is documented as a faithful record of what happened, " + "and it recorded nothing at all." + ), + note="Passes by disclosure (a derivation step) or by doing the expansion.", + ), + Case( + id="expand_control_power_inside_the_budget", + subsystem="simplification", + statement="simplify_expanded((x+1)^6) = 729 at x = 2, expanded and unremarked", + op=_expansion_within_the_budget(X + _int(1), 6, 2.0), + contract=Returns(729.0), + verified_by=( + "(2+1)^6 = 3^6 = 729 by hand; the binomial expansion 1 + 6x + 15x² + 20x³ + 15x⁴ " + "+ 6x⁵ + x⁶ at x = 2 gives 1+12+60+160+240+192+64 = 729, so the expanded form must " + "agree. The control for the case above: it fails if expansion stops firing, and " + "also if a limit step is recorded for an expansion that in fact happened." + ), + ), + Case( + id="lll_rank_deficient_basis_is_answerable", + subsystem="linear_algebra", + statement="LLL on [[1,2],[2,4]] must return a basis of ℤ·(1,2), not panic", + op=_lll_rows_stay_in_the_lattice([[1, 2], [2, 4]], [1, 2]), + contract=Returns(True), + verified_by=( + "(2,4) = 2·(1,2), so the two rows span the rank-1 lattice ℤ·(1,2). Every row LLL " + "returns must therefore be an integer multiple of (1,2), and (1,2) itself must still " + "be reachable — checked in exact Fraction arithmetic on the returned rows, with no " + "reference implementation involved." + ), + note=( + "Pre-fix any rank-deficient basis divided by a zero Gram–Schmidt norm and panicked. " + "Scored `no_answer` when that happens, not `silent_error`: the failure mode is a " + "dead run, not a wrong number." ), ), ] diff --git a/tests/test_agent_contract.py b/tests/test_agent_contract.py index 0e9f90fc..0f282986 100644 --- a/tests/test_agent_contract.py +++ b/tests/test_agent_contract.py @@ -1,6 +1,7 @@ """Machine-readable agent contract tests.""" import alkahest +import pytest def test_capabilities_reports_installed_build_features(): @@ -8,7 +9,10 @@ def test_capabilities_reports_installed_build_features(): # v2: `verification` gained a generated `coverage` block and dropped the # never-emitted `lean_checked` status. See tests/test_certificate_ledger.py. - assert caps["contract_version"] == 2 + # v3: `features` dropped `groebner_cuda` and `numpy` — see + # `test_every_advertised_feature_has_an_entry_point` for the rule that + # removed them and the invariant that keeps the next one out. + assert caps["contract_version"] == 3 assert {"groebner", "jit", "egraph", "parallel", "features", "primitives", "verification"} <= ( caps.keys() ) @@ -20,10 +24,10 @@ def test_capabilities_reports_installed_build_features(): "llvm_jit", "cranelift_jit", "parallel", - "numpy", "cuda", - "groebner_cuda", } == caps["features"].keys() + assert "groebner_cuda" not in caps["features"] + assert "numpy" not in caps["features"] assert caps["groebner"] is caps["features"]["groebner"] assert caps["egraph"] is caps["features"]["egraph"] assert caps["parallel"] is caps["features"]["parallel"] @@ -40,7 +44,13 @@ def test_cranelift_jit_enables_session_jit_flag(): assert caps["jit"] is True assert alkahest.jit_is_available() assert features["cranelift"] is True - assert not features["llvm_jit"] + # The two backends are not mutually exclusive. The shipped wheel is + # cranelift-only, but `--features cuda` pulls in `alkahest-core/jit` + # and so links LLVM alongside cranelift — a real configuration, built + # and tested on GPU hardware. Asserting `not llvm_jit` unconditionally + # encoded "cranelift implies no LLVM", which is false there. + if not features["cuda"]: + assert not features["llvm_jit"] primitives = alkahest.capabilities()["primitives"] @@ -148,3 +158,277 @@ def test_derived_result_labels_emitted_lean_source_as_unchecked_evidence(): assert verification["externally_verified"] is False assert isinstance(verification["side_conditions"], list) assert isinstance(result.certificate, str) + + +def test_advertised_cuda_capability_matches_the_public_namespace(): + """A capability bit must not advertise an unreachable entry point. + + On a `--features cuda` build the native module defines `compile_cuda`, + `CudaCompiledFn` and `CudaError`, but `python/alkahest/__init__.py` never + re-exported them: `capabilities()["features"]["cuda"]` said `True` while + `ak.compile_cuda` raised `AttributeError`, and the only route in was the + private `alkahest.alkahest` module. Found by running the CUDA suite on real + hardware, which is the only configuration where the two can disagree. + """ + features = alkahest.capabilities()["features"] + reachable = all( + hasattr(alkahest, name) for name in ("compile_cuda", "CudaCompiledFn", "CudaError") + ) + assert features["cuda"] == reachable, ( + f"capabilities() reports cuda={features['cuda']} but the public " + f"namespace {'exposes' if reachable else 'does not expose'} the CUDA " + "entry points; the contract and the namespace must agree" + ) + + +def _probe_egraph(): + pool = alkahest.ExprPool() + x = pool.symbol("x") + assert alkahest.simplify_egraph(x + pool.integer(0)).value is not None + # The native module's own marker must agree with the reported bit. + assert alkahest.alkahest.HAS_EGRAPH is True + + +def _probe_groebner(): + pool = alkahest.ExprPool() + x = pool.symbol("x") + assert hasattr(alkahest, "GroebnerBasis") + assert alkahest.solve([x * x - pool.integer(1)], [x]) + + +def _probe_parallel(): + # `simplify_par` is *not* a witness: it exists on every build and degrades + # to the sequential path when the feature is off, so it cannot tell the + # bit apart from its negation. These two methods genuinely appear and + # disappear with `--features parallel`. + assert hasattr(alkahest.CompiledFn, "call_batch_raw_par") + assert hasattr(alkahest.CompiledFn, "call_batch_buffer_par") + + +def _probe_native_jit(): + assert alkahest.jit_is_available() is True + pool = alkahest.ExprPool() + x = pool.symbol("x") + fn = alkahest.compile_expr(x * x, [x]) + assert fn([3.0]) == pytest.approx(9.0) + + +def _probe_cuda(): + assert hasattr(alkahest, "compile_cuda") + assert hasattr(alkahest, "CudaCompiledFn") + assert "compile_cuda" in alkahest.__all__ + + +#: The whole point of a capability contract: an agent reads it once and picks +#: an operation without probing. That makes every key a promise, so every key +#: needs a named way to cash it in. `test_every_advertised_feature_has_an_entry_point` +#: below fails if a bit is added without one — which is what `groebner_cuda` +#: and `numpy` both lacked. +_FEATURE_ENTRY_POINTS = { + "egraph": _probe_egraph, + "groebner": _probe_groebner, + "jit": _probe_native_jit, + "llvm_jit": _probe_native_jit, + "cranelift": _probe_native_jit, + "cranelift_jit": _probe_native_jit, + "parallel": _probe_parallel, + "cuda": _probe_cuda, +} + +#: `(owner, attribute)` pairs that exist if and only if the bit is `True`. +#: Checked in *both* directions, so this catches a bit reading `True` with the +#: entry point missing (the `cuda` bug in `d139a46`) *and* a bit reading +#: `False` on a build that really does have it. +_FEATURE_EXCLUSIVE_NAMES = { + "cuda": (("alkahest", "compile_cuda"), ("alkahest", "CudaCompiledFn")), + "parallel": ( + ("alkahest.CompiledFn", "call_batch_raw_par"), + ("alkahest.CompiledFn", "call_batch_buffer_par"), + ), +} + +_EXCLUSIVE_OWNERS = { + "alkahest": lambda: alkahest, + "alkahest.CompiledFn": lambda: alkahest.CompiledFn, +} + + +def test_every_advertised_feature_has_an_entry_point(): + """Every `True` bit in `capabilities()["features"]` must be cashable. + + This is the generalisation of the two bugs that motivated contract v3, and + would have caught both at once: + + * `groebner_cuda` was `True` on a `--features groebner-cuda` build while + the string `groebner_cuda` appeared exactly once anywhere in + `alkahest-py` — the capability line itself. No binding, no `*gpu*` name + in the public or the private module, and `GroebnerBasis` exposing only + CPU methods. Strictly worse than the `cuda` bug fixed in `d139a46`, + which at least had a private route in. + * `numpy` mapped to a Cargo feature gating a crate `lib.rs` never used, + while `ak.numpy_eval` worked perfectly with the bit `False`. It meant + nothing and correlated with nothing. + + Both were removed rather than wired up: a bit that reads `False` honestly + beats one that reads `True` and lies, and a bit that means nothing at all + is better gone than left to be misread. The rule this test enforces is + that the decision has to be made *before* a key ships, because the failure + mode of getting it wrong is a caller trusting something it should not — + the same class of defect as a silent wrong answer. + """ + features = alkahest.capabilities()["features"] + + assert set(_FEATURE_ENTRY_POINTS) == set(features), ( + "every capability bit needs a named entry point a caller can reach. " + f"Undeclared bits: {sorted(set(features) - set(_FEATURE_ENTRY_POINTS))}; " + f"stale probes: {sorted(set(_FEATURE_ENTRY_POINTS) - set(features))}. " + "Add a probe, or drop the bit." + ) + + for name, enabled in sorted(features.items()): + if enabled: + _FEATURE_ENTRY_POINTS[name]() + + for name, exclusive in _FEATURE_EXCLUSIVE_NAMES.items(): + for owner_name, attr in exclusive: + owner = _EXCLUSIVE_OWNERS[owner_name]() + present = hasattr(owner, attr) + assert present is features[name], ( + f"capabilities() reports {name}={features[name]} but " + f"{owner_name}.{attr} {'exists' if present else 'does not exist'}; " + "the contract and the namespace must agree" + ) + + +def test_removed_capability_bits_stay_removed(): + """`groebner_cuda` and `numpy` must not reappear without an entry point. + + Re-adding either is a real decision, not a merge accident: it means a + Python binding now exists, and this test plus `_FEATURE_ENTRY_POINTS` + above must both be updated to say what it is. + """ + features = alkahest.capabilities()["features"] + for gone in ("groebner_cuda", "numpy"): + assert gone not in features + assert features.get(gone, False) is False + + # The GPU Gröbner kernel is still Rust-only, by design: the crossover + # policy in docs/symbolic-gpu-benchmarks.md says production dispatch must + # not prefer the GPU until the benchmark harness says it wins. If that + # changes, the binding lands first and the bit follows it — never the other + # way round, which is the order that produced the overclaim. + gpu_names = sorted( + name for name in set(dir(alkahest)) | set(dir(alkahest.alkahest)) if "gpu" in name.lower() + ) + assert not gpu_names, ( + f"a GPU entry point appeared ({gpu_names}) without a capability bit " + "to advertise it. Add the bit and a probe in _FEATURE_ENTRY_POINTS, or " + "keep the binding private." + ) + + +def test_llvm_jit_bit_tracks_what_is_linked_not_which_flag_was_named(): + """`cuda` implies `alkahest-core/jit`, so a CUDA build links LLVM. + + `alkahest-py`'s own `jit` feature can be off while the core's is on, which + made `llvm_jit` report `False` on a build that demonstrably emits NVPTX. + """ + features = alkahest.capabilities()["features"] + if features["cuda"]: + assert features["llvm_jit"], ( + "a cuda build links the LLVM backend (alkahest-core: " + 'cuda = ["jit", ...]), so llvm_jit cannot be False' + ) + assert features["jit"] + + +def test_alkahest_error_catches_both_halves_of_the_hierarchy(): + """`except alkahest.AlkahestError` must catch Rust *and* Python errors. + + The Rust engines raise the native classes; the pure-Python subsystems + (`ansatz`, `crosscheck`, `smt`, the batch helpers) raise the wrappers in + `alkahest.exceptions`. Those two hierarchies used to be disjoint — the + wrappers subclassed a pure-Python base that was not the native one — so the + documented "catch anything this library raises" idiom silently missed every + Python-layer error, including all three modules added for autoresearch + loops. `exceptions.AlkahestError` is now a subclass of the native base, + which makes the top-level name a true common ancestor. + """ + from alkahest import exceptions + + pool = alkahest.ExprPool() + x = pool.symbol("x") + + caught_native = False + try: + alkahest.integrate(alkahest.exp(x * x), x) + except alkahest.AlkahestError: + caught_native = True + assert caught_native, "alkahest.AlkahestError missed a natively-raised error" + + caught_python = False + try: + raise exceptions.AnsatzError("probe") + except alkahest.AlkahestError: + caught_python = True + assert caught_python, "alkahest.AlkahestError missed a Python-layer error" + + assert issubclass(exceptions.AlkahestError, alkahest.AlkahestError) + + +def test_python_only_errors_keep_their_keyword_constructors(): + """The overlay must not swallow the classes Python code actually raises. + + `AnsatzError`, `CrossCheckError` and `SmtError` are raised from the Python + layer with `code=`/`remediation=` keywords; replacing them with a native + class would break those call sites. + """ + from alkahest import exceptions + + for cls, expected in ( + (exceptions.AnsatzError, "E-ANSATZ-"), + (exceptions.CrossCheckError, "E-XCHECK-"), + (exceptions.SmtError, "E-SMT-"), + ): + err = cls("probe") + assert err.code.startswith(expected) + assert isinstance(err, alkahest.AlkahestError) + + +def test_cuda_kernels_are_reachable_on_every_device_not_just_zero(): + """Device selection must be reachable from Python, not only from Rust. + + `alkahest-core` has always had `CudaCompiledFn::call_batch_on(ordinal, ..)` + — `nvptx_gpu::nvptx_multi_device_both_3090s` drives both cards through it — + but the binding exposed only `call_batch`, hardwired to device 0. On a + multi-GPU host every device but the first was therefore unreachable from + Python, which is the same shape of gap as `cuda` advertising an entry point + the public namespace could not reach. + + Asserts agreement rather than mere reachability: the PTX is + device-independent, so the same kernel on a different card must return the + identical bit pattern, not merely a close one. + """ + if not alkahest.capabilities()["features"]["cuda"]: + import pytest + + pytest.skip("not a cuda build") + + pool = alkahest.ExprPool() + x = pool.symbol("x") + fn = alkahest.compile_cuda(x * x + pool.integer(1), [x]) + + assert hasattr(fn, "call_batch_on"), "no way to select a CUDA device from Python" + + pts = [0.0, 1.0, -2.5, 1e8, 1e-8] + on_zero = fn.call_batch_on(0, [pts]) + assert on_zero == fn.call_batch([pts]), "call_batch must equal call_batch_on(0, ..)" + + # Second device only if the host has one; single-GPU CI must still pass. + try: + on_one = fn.call_batch_on(1, [pts]) + except alkahest.CudaError: + return + assert on_one == on_zero, ( + f"identical PTX on a second device returned different values: {on_one} vs {on_zero}" + ) diff --git a/tests/test_batch_workload.py b/tests/test_batch_workload.py index 98e68064..0516e981 100644 --- a/tests/test_batch_workload.py +++ b/tests/test_batch_workload.py @@ -334,25 +334,29 @@ def test_parallel_batch_reports_a_budget_trip_as_a_budget_trip(pool): 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. + There is deliberately **no** wall-clock assertion. The property under test + is that the budget reaches the workers, and the returned *code* already + proves that: an unbudgeted sweep of these four integrands runs for minutes + and ends in `E-INT-001`, so `E-BUDGET-001` on every item cannot be produced + without the budget having been enforced inside each worker. + + A 20x elapsed bound was tried and removed: it failed under concurrent build + load while the behaviour was perfectly correct, which is the same flaky + pattern removed from the SMT and limit suites this cycle. A timing bound + here adds no information the code does not already carry, and subtracts + reliability. `@pytest.mark.timeout` is the backstop for a genuine hang. """ 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) diff --git a/tests/test_cuda.py b/tests/test_cuda.py new file mode 100644 index 00000000..3f8eb220 --- /dev/null +++ b/tests/test_cuda.py @@ -0,0 +1,426 @@ +"""Python-level coverage for the NVPTX / CUDA backend (``ak.compile_cuda``). + +Until this file existed, ``pytest tests/ -k cuda`` selected **zero** tests: the +whole GPU surface was covered only from Rust (``alkahest-core/tests/nvptx_gpu.rs``), +so the Python binding could have been unreachable — and for three releases it was. +``capabilities()["features"]["cuda"]`` reported ``True`` on a CUDA build while +``ak.compile_cuda`` raised ``AttributeError``, because ``python/alkahest/__init__.py`` +re-exported none of the three names the native module defines under that feature. + +Three tiers, deliberately separated by what each one actually needs: + +1. **Contract** — runs everywhere, including this non-CUDA build. Asserts the + capability bit and the public namespace agree *in both directions*: a build + without the feature must not expose the entry points, and a build with it must. + This is the tier that would have caught the export gap above. +2. **Compile-only** — needs ``--features cuda`` (LLVM 15 with the NVPTX target), + but no device: PTX emission and the ``CudaError`` refusals both happen host-side. +3. **Device** — needs a CUDA build *and* a GPU that answers. Every one of these + compares the kernel's output against a CPU path for the same expression, + because a GPU kernel that returns different numbers from the CPU is the + failure that matters; "it launched" is not the property under test. + +Skip discipline (mirrors ``alkahest-core/tests/nvptx_gpu.rs``): tiers 2 and 3 skip +when the feature or the device is absent — that is honest, and it is what happens +in CI, in the shipped wheel, and on any developer machine. But ``ALKAHEST_GPU_TESTS=1`` +(set by ``.github/workflows/cuda_nightly.yml`` on the ``gpu-3090`` runner) *promises* +hardware, and a promise that silently degrades to a skip is how a job whose entire +purpose is to exercise a GPU reports success without touching one. With that +variable set, an unusable device is a collection error, not a skip. +""" + +from __future__ import annotations + +import math +import os +import warnings + +import alkahest as ak +import pytest + +# The two names the native module defines *under* `--features cuda`. Both must +# be reachable from the public package, or neither. +CUDA_ENTRY_POINTS = ("CudaCompiledFn", "compile_cuda") + +# `CudaError` is not in that list on purpose: the native module registers it +# unconditionally, like every other exception class, so it is bound on every +# build and simply never raised without the feature. It is the type a caller +# writes `except` against, and that code must compile against the default wheel. + + +GPU_PROMISED = os.environ.get("ALKAHEST_GPU_TESTS") == "1" + +CUDA_FEATURE = bool(ak.capabilities()["features"]["cuda"]) + + +def _device_unavailable_reason() -> str | None: + """Return ``None`` when a real device answered, else why it did not. + + Deliberately *not* a bare capability read: the feature flag says what was + linked, not that a GPU is present, and ``libcuda.so.1`` is loaded lazily so + the failure only surfaces at launch. The probe therefore compiles a trivial + kernel and launches it. + + Only a raised exception counts as unavailable; the value that comes back is + deliberately not inspected. A device that answers with the *wrong* number is + a failure for the tests below to report, never grounds for declaring the + hardware absent and skipping them. + """ + if not CUDA_FEATURE: + return "extension built without --features cuda" + missing = [name for name in CUDA_ENTRY_POINTS if not hasattr(ak, name)] + if missing: + # test_capability_bit_and_public_namespace_agree fails loudly on this; + # the device tiers have nothing to call, so they skip. + return f"cuda feature reported but {', '.join(missing)} not exported" + + pool = ak.ExprPool() + x = pool.symbol("x") + try: + fn = ak.compile_cuda(x + pool.integer(1), [x]) + fn.call_batch([[2.0]]) + except Exception as exc: # any failure at all means "no usable device" + return f"no usable CUDA device: {type(exc).__name__}: {exc}" + return None + + +DEVICE_SKIP_REASON = _device_unavailable_reason() + +if GPU_PROMISED and DEVICE_SKIP_REASON is not None: + raise RuntimeError( + "ALKAHEST_GPU_TESTS=1 promises a CUDA build running on GPU hardware, but " + f"{DEVICE_SKIP_REASON}. Refusing to skip: a GPU job that reports success " + "without reaching a GPU is worse than a red one." + ) + +requires_cuda_build = pytest.mark.skipif( + not CUDA_FEATURE, reason="extension built without --features cuda" +) +requires_gpu = pytest.mark.skipif( + DEVICE_SKIP_REASON is not None, + reason=DEVICE_SKIP_REASON or "", +) + + +@pytest.fixture +def pool(): + return ak.ExprPool() + + +def _sample_indices(n: int, k: int) -> list[int]: + """Evenly spaced indices always including the first and the last point. + + The last point is the one an off-by-one in the grid-stride bound misses, so + it is never sampled away. + """ + if n <= k: + return list(range(n)) + step = n // k + idx = list(range(0, n, step)) + if idx[-1] != n - 1: + idx.append(n - 1) + return idx + + +# --------------------------------------------------------------------------- +# 1. Contract — runs on every build, including this one +# --------------------------------------------------------------------------- + + +def test_capability_bit_and_public_namespace_agree(): + """``features["cuda"]`` must be true exactly when the entry points resolve. + + Both directions matter. ``True`` with nothing exported is the overclaim that + shipped for three releases; ``False`` with the names present would mean the + contract under-reports a feature an agent could otherwise select. + """ + reachable = [name for name in CUDA_ENTRY_POINTS if hasattr(ak, name)] + if CUDA_FEATURE: + assert reachable == list(CUDA_ENTRY_POINTS), ( + "capabilities() reports cuda=True but the public namespace is missing " + f"{sorted(set(CUDA_ENTRY_POINTS) - set(reachable))}; a capability bit " + "must not advertise an entry point only reachable via the private " + "alkahest.alkahest module" + ) + else: + assert reachable == [], ( + f"capabilities() reports cuda=False but {reachable} are exported; the " + "contract would under-report a usable feature" + ) + + +def test_cuda_error_is_importable_without_the_feature(): + """``except ak.CudaError`` must be writable against any build. + + The exception class is registered unconditionally by the native module, but + it used to be re-exported only inside the feature-gated import of + ``compile_cuda``/``CudaCompiledFn`` — so on the shipped wheel + ``ak.CudaError`` raised ``AttributeError`` while ``alkahest.exceptions`` + held a *different, non-identical* class of the same name. Code written to + catch the stub would not have caught a native raise on a CUDA build. + """ + assert issubclass(ak.CudaError, ak.AlkahestError) + assert "CudaError" in ak.__all__ + # The re-export must be the class the native module actually raises, not + # the pure-Python stub that shadows it by name. + assert ak.CudaError is ak.alkahest.CudaError + + +def test_cuda_names_are_in_dunder_all_exactly_when_they_exist(): + """``__all__`` is the documented surface; every name in it must resolve. + + The CUDA names are appended at runtime rather than written into the literal, + precisely because they do not exist in a default build — so the invariant to + pin is the equivalence, in both directions. + """ + for name in CUDA_ENTRY_POINTS: + assert (name in ak.__all__) == hasattr(ak, name), ( + f"{name}: __all__ membership and attribute existence disagree" + ) + if name in ak.__all__: + assert getattr(ak, name) is not None + + +@requires_cuda_build +def test_public_names_are_the_native_objects_not_copies(): + """The re-export must be the native object, so ``except ak.CudaError`` catches + what the native module raises. A shadowing pure-Python stub with the same name + would silently fail to catch anything.""" + native = ak.alkahest + for name in CUDA_ENTRY_POINTS: + assert getattr(ak, name) is getattr(native, name) + + +def test_gpu_tests_env_var_promises_a_device(): + """``ALKAHEST_GPU_TESTS=1`` asserts hardware; it must never degrade to a skip.""" + assert not GPU_PROMISED or DEVICE_SKIP_REASON is None + + +# --------------------------------------------------------------------------- +# 2. Compile-only — needs `--features cuda`, does not need a device +# --------------------------------------------------------------------------- + + +@requires_cuda_build +def test_compile_cuda_emits_sm_86_ptx(pool): + x = pool.symbol("x") + y = pool.symbol("y") + expr = ak.sin(x) * ak.cos(y) + x * x + + fn = ak.compile_cuda(expr, [x, y]) + + assert fn.n_inputs == 2 + ptx = fn.ptx + assert isinstance(ptx, str) + assert ptx + # The production target is Ampere; the header is what ptxas/driver reads. + assert ".target sm_86" in ptx + assert ".address_size 64" in ptx + assert ".version" in ptx + # The runtime loads this exact entry point name (nvptx.rs: load_function). + assert "alkahest_eval" in ptx + assert "n_inputs=2" in repr(fn) + + +@requires_cuda_build +def test_unbound_symbol_raises_cuda_error(pool): + """A symbol absent from ``inputs`` has no address in the kernel: E-CUDA-002.""" + x = pool.symbol("x") + y = pool.symbol("y") + + with pytest.raises(ak.CudaError) as excinfo: + ak.compile_cuda(x + y, [x]) # y never passed as an input + + exc = excinfo.value + assert exc.code == "E-CUDA-002" + assert "unbound symbol" in str(exc) + assert exc.remediation + + +@requires_cuda_build +def test_cuda_error_is_catchable_as_alkahest_error(pool): + """``CudaError`` must sit under the common base, or ``except AlkahestError`` + around a mixed CPU/GPU pipeline would let a GPU failure escape.""" + assert issubclass(ak.CudaError, ak.AlkahestError) + + x = pool.symbol("x") + with pytest.raises(ak.AlkahestError) as excinfo: + ak.compile_cuda(x + pool.symbol("z"), [x]) + + assert isinstance(excinfo.value, ak.CudaError) + assert excinfo.value.code.startswith("E-CUDA-") + + +@requires_cuda_build +def test_function_without_nvptx_lowering_is_refused_not_approximated(pool): + """``atan`` has no libdevice mapping in the backend. Refusing is correct; + quietly emitting something else would be a wrong-answer bug.""" + x = pool.symbol("x") + + with pytest.raises(ak.CudaError) as excinfo: + ak.compile_cuda(ak.atan(x), [x]) + + assert excinfo.value.code == "E-CUDA-002" + assert "atan" in str(excinfo.value) + + +@requires_cuda_build +def test_call_batch_rejects_wrong_column_count(pool): + x = pool.symbol("x") + y = pool.symbol("y") + fn = ak.compile_cuda(x + y, [x, y]) + + with pytest.raises(ValueError, match="expected 2 input columns"): + fn.call_batch([[1.0, 2.0]]) + + +@requires_cuda_build +def test_call_batch_rejects_ragged_columns(pool): + x = pool.symbol("x") + y = pool.symbol("y") + fn = ak.compile_cuda(x + y, [x, y]) + + with pytest.raises(ValueError, match="same length"): + fn.call_batch([[1.0, 2.0, 3.0], [1.0]]) + + +# --------------------------------------------------------------------------- +# 3. Device — needs a CUDA build and a GPU that answers +# +# Every test here cross-checks the GPU against a CPU evaluation of the *same* +# expression. Agreement is the property; a launch that returns is not. +# --------------------------------------------------------------------------- + + +@requires_gpu +def test_polynomial_batch_matches_the_interpreter(pool): + """65536 points spans many blocks, so a grid-stride bound error shows up — + which is why index ``n - 1`` is always checked.""" + x = pool.symbol("x") + expr = x**3 + (x * pool.integer(2)) * pool.integer(-1) + pool.integer(1) + + n = 1 << 16 + xs = [i * 1e-4 for i in range(n)] + + fn = ak.compile_cuda(expr, [x]) + got = fn.call_batch([xs]) + + assert len(got) == n + for i in _sample_indices(n, 128): + want = ak.eval_expr(expr, {x: xs[i]}) + assert math.isclose(got[i], want, rel_tol=1e-12, abs_tol=1e-12), ( + f"GPU/CPU mismatch at i={i}: gpu={got[i]!r} cpu={want!r}" + ) + + +@requires_gpu +def test_transcendental_batch_matches_the_interpreter(pool): + """libdevice ``__nv_sin``/``__nv_cos`` against the host libm: not bit-identical, + but any disagreement beyond a few ulps is a lowering bug, not rounding.""" + x = pool.symbol("x") + y = pool.symbol("y") + expr = ak.sin(x) * ak.cos(y) + (x * x + y * y) * pool.rational(1, 100) + + n = 4096 + xs = [(i % 617) * 1e-2 for i in range(n)] + ys = [(i % 331) * 3e-2 for i in range(n)] + + fn = ak.compile_cuda(expr, [x, y]) + got = fn.call_batch([xs, ys]) + + assert len(got) == n + for i in _sample_indices(n, 128): + want = ak.eval_expr(expr, {x: xs[i], y: ys[i]}) + assert math.isclose(got[i], want, rel_tol=1e-10, abs_tol=1e-10), ( + f"GPU/CPU mismatch at i={i}: gpu={got[i]!r} cpu={want!r}" + ) + + +@requires_gpu +def test_matches_the_jit_compiled_cpu_function(pool): + """Cross-check against the *compiled* CPU path, not only the interpreter: + the two backends lower the same expression independently, so agreement + between them is evidence neither one drifted.""" + x = pool.symbol("x") + expr = ak.exp(x * pool.rational(-1, 4)) * ak.sin(x) + ak.sqrt(x + pool.integer(1)) + + n = 1024 + xs = [i * 2e-3 for i in range(n)] + + with warnings.catch_warnings(): + # A build without a CPU JIT falls back to the interpreter with a warning; + # the comparison is still valid, so do not let it turn into an error. + warnings.simplefilter("ignore", RuntimeWarning) + cpu = ak.compile_expr(expr, [x]) + + gpu = ak.compile_cuda(expr, [x]) + got = gpu.call_batch([xs]) + + for i in _sample_indices(n, 64): + want = cpu([xs[i]]) + assert math.isclose(got[i], want, rel_tol=1e-10, abs_tol=1e-10), ( + f"GPU/CPU-JIT mismatch at i={i}: gpu={got[i]!r} cpu={want!r}" + ) + + +@requires_gpu +def test_matches_numpy_eval_over_the_whole_batch(pool): + """The vectorised CPU path, compared element-for-element rather than sampled.""" + np = pytest.importorskip("numpy") + + x = pool.symbol("x") + y = pool.symbol("y") + expr = x * x + y * y * pool.integer(3) + + n = 8192 + rng = np.random.default_rng(0) + xs = rng.standard_normal(n, dtype=np.float64) + ys = rng.standard_normal(n, dtype=np.float64) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + cpu = ak.compile_expr(expr, [x, y]) + want = np.asarray(ak.numpy_eval(cpu, xs, ys), dtype=np.float64) + + gpu = ak.compile_cuda(expr, [x, y]) + got = np.asarray(gpu.call_batch([xs.tolist(), ys.tolist()]), dtype=np.float64) + + assert got.shape == want.shape + assert np.allclose(got, want, rtol=1e-12, atol=1e-12), ( + f"max |GPU - CPU| = {float(np.max(np.abs(got - want)))}" + ) + + +@requires_gpu +def test_repeated_launches_are_deterministic(pool): + """Same kernel, same inputs, same numbers — twice. A difference would mean a + race or an uninitialised read that ``compute-sanitizer --tool racecheck`` + might or might not have provoked on the run that produced the green tick.""" + x = pool.symbol("x") + expr = ak.sin(x) * ak.sin(x) + ak.cos(x) * ak.cos(x) + + xs = [i * 1e-3 for i in range(4096)] + fn = ak.compile_cuda(expr, [x]) + + first = fn.call_batch([xs]) + second = fn.call_batch([xs]) + + assert first == second + # sin² + cos² = 1 pointwise; the identity is a check on the kernel itself + # that needs no CPU reference at all. + for i in _sample_indices(len(xs), 64): + assert abs(first[i] - 1.0) < 1e-12 + + +@requires_gpu +def test_single_point_batch(pool): + """One point is the degenerate grid: a block-size assumption that only holds + for large N breaks here.""" + x = pool.symbol("x") + expr = x * x + pool.integer(1) + + fn = ak.compile_cuda(expr, [x]) + got = fn.call_batch([[3.0]]) + + assert len(got) == 1 + assert math.isclose(got[0], 10.0, rel_tol=1e-15) diff --git a/tests/test_parametric_solve.py b/tests/test_parametric_solve.py index 192b6bac..74e7c2ca 100644 --- a/tests/test_parametric_solve.py +++ b/tests/test_parametric_solve.py @@ -75,3 +75,47 @@ def test_nonparametric_solve_still_works(pool): assert len(sols) == 1 assert float(ak.eval_expr(sols[0][x], {})) == pytest.approx(0.5) assert float(ak.eval_expr(sols[0][y], {})) == pytest.approx(0.5) + + +# --------------------------------------------------------------------------- +# The hypothesis behind a parametric answer. +# +# `b/a` is the solution of `a·x = b` **for a ≠ 0**. At `a = 0` the equation +# reads `−b = 0`: no solution when `b ≠ 0`, every `x` when `b = 0`. A parametric +# tuple is not a number, so nothing substitutes it back and it is returned +# unverified — which makes the stated hypothesis the only auditable signal. +# --------------------------------------------------------------------------- + + +def test_parametric_division_reports_its_non_vanishing_hypothesis(pool): + x = pool.symbol("x") + a = pool.symbol("a") + b = pool.symbol("b") + ak.solve([a * x - b], [x]) + conditions = ak.solve_side_conditions() + assert conditions == ["a ≠ 0"], conditions + # Reading it again describes the same call, not an empty list. + assert ak.solve_side_conditions() == ["a ≠ 0"] + + +def test_a_provable_divisor_reports_no_hypothesis(pool): + """The control: `2x − b = 0` divides by the literal 2, so there is nothing + to assume. A solver that emitted a condition unconditionally would say as + little as one that never emits any.""" + x = pool.symbol("x") + b = pool.symbol("b") + sols = ak.solve([2 * x - b], [x]) + assert float(ak.eval_expr(sols[0][x], {b: 6.0})) == pytest.approx(3.0) + assert ak.solve_side_conditions() == [] + + +def test_hypotheses_do_not_leak_from_an_earlier_solve(pool): + """Each call resets the channel, including on paths that never reach the + symbolic solver.""" + x = pool.symbol("x") + a = pool.symbol("a") + b = pool.symbol("b") + ak.solve([a * x - b], [x]) + assert ak.solve_side_conditions() == ["a ≠ 0"] + ak.solve([x**2 - 4], [x]) + assert ak.solve_side_conditions() == [] diff --git a/tests/test_primary_decomposition_v212.py b/tests/test_primary_decomposition_v212.py index 1c7f5fed..1b668be7 100644 --- a/tests/test_primary_decomposition_v212.py +++ b/tests/test_primary_decomposition_v212.py @@ -29,3 +29,48 @@ def test_radical_x2_xy(): y = pool.symbol("y") r = alkahest.radical([x**2, x * y], [x, y]) assert r.contains(x) + + +# --------------------------------------------------------------------------- +# Refusals must reach Python with their own stable code. +# +# `radical` / `primary_decomposition` report "I cannot certify this" through +# `PrimaryDecompositionError::Factorization` and record the real reason out of +# band (the enum is public and exhaustive, so it cannot grow a variant without a +# major semver break). If the binding forgets to consult `take_ideal_refusal`, +# the refusal still *happens* but arrives as an uncoded `ValueError` — honest, +# but not machine-readable, which is what an autoresearch loop branches on. +# --------------------------------------------------------------------------- + + +def _codes_of(exc): + return getattr(exc, "code", None) + + +def test_radical_refusal_carries_e_ideal_005(): + pool = alkahest.ExprPool() + x, y, z = pool.symbol("x"), pool.symbol("y"), pool.symbol("z") + # The twisted cubic is prime and *is* its own radical — the old code returned + # the input unchanged and was right here by accident. Refusing is correct; + # the point of this test is that the refusal is coded. + with pytest.raises(ValueError) as ei: + alkahest.radical([y - x**2, z - x**3], [x, y, z]) + assert _codes_of(ei.value) == "E-IDEAL-005" + assert ei.value.remediation + + +def test_primary_decomposition_refusal_carries_e_ideal_006(): + pool = alkahest.ExprPool() + x, y, z = pool.symbol("x"), pool.symbol("y"), pool.symbol("z") + with pytest.raises(ValueError) as ei: + alkahest.primary_decomposition([y - x**2, z - x**3], [x, y, z]) + assert _codes_of(ei.value) == "E-IDEAL-006" + + +def test_certified_cases_still_answer_and_are_not_refused(): + """The refusal path must not swallow the cases that *are* certifiable.""" + pool = alkahest.ExprPool() + x, y, z = pool.symbol("x"), pool.symbol("y"), pool.symbol("z") + assert len(alkahest.primary_decomposition([x**2 - y**2], [x, y, z])) == 2 + assert len(alkahest.primary_decomposition([x * z, y * z], [x, y, z])) == 2 + assert alkahest.radical([(x - y) ** 2], [x, y, z]).contains(x - y) diff --git a/tests/test_regular_chains_v211.py b/tests/test_regular_chains_v211.py index 3f8e5121..4b1a833c 100644 --- a/tests/test_regular_chains_v211.py +++ b/tests/test_regular_chains_v211.py @@ -42,3 +42,36 @@ def test_groebner_basis_compute_still_importable(): from alkahest import GroebnerBasis _ = GroebnerBasis.compute([x - y, x**2 - pool.integer(1)], [x, y]) + + +def test_triangularize_refusal_carries_e_solve_004(): + """A chain that would under-determine the system refuses, with its own code.""" + pool = ExprPool() + x, y, z = pool.symbol("x"), pool.symbol("y"), pool.symbol("z") + # needs a splitting decomposition into [x] and [y, z]; extraction + # alone would return a chain cutting out a larger variety than the input. + with pytest.raises(ValueError) as ei: + alkahest.triangularize([x * y, x * z], [x, y, z]) + assert getattr(ei.value, "code", None) == "E-SOLVE-004" + + +def test_genuine_non_polynomial_is_not_reattributed_to_the_refusal(): + """The out-of-band code must not leak onto an unrelated `NotPolynomial`. + + Both travel through the same enum variant, so a stale or unconditionally + read refusal would relabel this as E-SOLVE-004. + """ + pool = ExprPool() + x, y = pool.symbol("x"), pool.symbol("y") + with pytest.raises(ValueError) as ei: + alkahest.triangularize([alkahest.sin(x) - y, y - pool.integer(1)], [x, y]) + assert getattr(ei.value, "code", None) == "E-SOLVE-001" + + +def test_triangularize_keeps_both_generators(): + """Regression: the main-variable pick was inverted and discarded generators.""" + pool = ExprPool() + x, y = pool.symbol("x"), pool.symbol("y") + chains = alkahest.triangularize([x - y - pool.integer(1), y**2 - pool.integer(2)], [x, y]) + assert len(chains) == 1 + assert len(chains[0].polys()) == 2 diff --git a/tests/test_residue.py b/tests/test_residue.py index e72386fc..c96ac204 100644 --- a/tests/test_residue.py +++ b/tests/test_residue.py @@ -44,3 +44,67 @@ def test_non_rational_declines(): with pytest.raises(ValueError, match="E-RESIDUE-001"): residue(ak.sin(z), z, 0) + + +def test_refusals_are_coded_alkahest_errors(): + """`E-RESIDUE-*` must arrive as an `AlkahestError` carrying `.code`. + + They used to be bare `ValueError`s with the code glued into the message, + so the only way to branch on one was to string-match. `AlkahestError` + subclasses `ValueError`, so the older idiom keeps working. + """ + pool = ak.ExprPool() + z = pool.symbol("z", ak.Domain.Complex) + + with pytest.raises(ak.AlkahestError) as excinfo: + residue(ak.sin(z), z, 0) + assert excinfo.value.code == "E-RESIDUE-001" + assert excinfo.value.remediation + assert isinstance(excinfo.value, ValueError) + + +@pytest.mark.parametrize("bad_point", ["symbol", "expr", "string", "object"]) +def test_non_constant_point_raises_coded_error_not_attributeerror(bad_point): + """A point that is not an exact constant must be refused with a code. + + Found by the crash sweep on a deeply nested polynomial, but the depth was + incidental: `residue(f, z, point)` parsed `point` through `exact_binding`, + which reached straight for `point.numerator` and let the resulting bare + `AttributeError: 'Expr' object has no attribute 'numerator'` escape. That + named our implementation rather than the caller's mistake, was not an + `AlkahestError` (so `except ak.AlkahestError` missed it entirely), and + carried no code to branch on. Passing an `Expr` as the point is the + natural mistake here — `residue(f, z, a)` reads perfectly well — so it has + to be a refusal, not a crash. + """ + pool = ak.ExprPool() + z = pool.symbol("z", ak.Domain.Complex) + point = { + "symbol": pool.symbol("a"), + "expr": pool.integer(0), + "string": "0", + "object": object(), + }[bad_point] + + with pytest.raises(ak.AlkahestError) as excinfo: + residue(z**-1, z, point) + assert excinfo.value.code == "E-RESIDUE-005" + assert excinfo.value.remediation + assert not isinstance(excinfo.value, AttributeError) + + +def test_deeply_nested_polynomial_point_still_refused_cleanly(): + """The crash-sweep repro verbatim: a deep input plus a non-constant point.""" + pool = ak.ExprPool() + z = pool.symbol("z", ak.Domain.Complex) + deep = z + for _ in range(300): + deep = deep * z + pool.integer(1) + + with pytest.raises(ak.AlkahestError) as excinfo: + residue(deep, z, pool.integer(0)) + assert excinfo.value.code == "E-RESIDUE-005" + + # ...and the same expression with a *valid* point is not refused for the + # point's sake, so the fix did not simply reject more inputs. + assert ak.residue(deep, z, 0) is not None diff --git a/tests/test_series_v215.py b/tests/test_series_v215.py index 2c96650b..a9d21037 100644 --- a/tests/test_series_v215.py +++ b/tests/test_series_v215.py @@ -62,3 +62,71 @@ def test_series_accepts_bare_int_point(): s_int = alkahest.series(cx, x, 0, 6) s_expr = alkahest.series(cx, x, p.integer(0), 6) assert str(s_int.expr) == str(s_expr.expr) + + +# --------------------------------------------------------------------------- +# Termination: a runaway expansion refuses instead of running forever, and +# never returns a shorter series wearing the requested order's O(.) label. +# +# `series` builds coefficients by differentiating without re-simplifying, so an +# expression whose derivatives do not close grows by a constant factor per +# coefficient: `sqrt(t**-2 + t**-1)` costs 0.15 s at order 13 and doubles per +# order, i.e. order 32 is not slow but unfinishable. Before the work ceiling +# this call never returned. +# --------------------------------------------------------------------------- + + +def _runaway_radical(p: alkahest.ExprPool) -> tuple[alkahest.Expr, alkahest.Expr]: + t = p.symbol("t") + return alkahest.sqrt(t ** (-2) + t ** (-1)), t + + +def test_series_refuses_a_runaway_expansion_rather_than_truncating(): + """The refusal *is* the assertion: this returning at all is the fix. + + No wall-clock bound is asserted — a regression hangs the test rather than + failing a timing assertion, and timing bounds are flaky under the sanitizer + jobs. What is asserted is the shape of the answer: a coded refusal, not a + nine-term series labelled `O(t^32)`, which would be a false statement about + the remainder that no caller could audit. + """ + p = alkahest.ExprPool() + expr, t = _runaway_radical(p) + with pytest.raises(alkahest.SeriesError) as excinfo: + alkahest.series(expr, t, p.integer(0), 32) + assert excinfo.value.code == "E-SERIES-003" + + +def test_series_order_zero_keeps_its_own_code(): + """The refusal above is carried on the same variant as `order == 0`, so the + user error must keep reporting `E-SERIES-002` and not be re-attributed.""" + p = alkahest.ExprPool() + x = p.symbol("x") + with pytest.raises(alkahest.SeriesError) as excinfo: + alkahest.series(x, x, p.integer(0), 0) + assert excinfo.value.code == "E-SERIES-002" + + +def test_series_honours_an_active_budget(): + """`series` joins `integrate` and `limit` in honouring `Budget`, and a + budget trip is reported as one — `E-BUDGET-*`, not "this order is + unreachable".""" + p = alkahest.ExprPool() + expr, t = _runaway_radical(p) + with ( + alkahest.context(pool=p, budget=alkahest.Budget(max_steps=3)), + pytest.raises(alkahest.BudgetExceededError) as excinfo, + ): + alkahest.series(expr, t, p.integer(0), 32) + assert excinfo.value.code.startswith("E-BUDGET-") + + +def test_ordinary_high_order_series_still_expands(): + """The control: the ceiling costs no coverage. `sin` at order 24 interns a + couple of hundred nodes against a ceiling of 50 000.""" + p = alkahest.ExprPool() + x = p.symbol("x") + s = alkahest.series(alkahest.sin(x), x, p.integer(0), 24) + assert _has_big_o(s.expr) + # sin's expansion runs to x^23: the last odd power below order 24. + assert "x^23" in str(s.expr) diff --git a/tests/textbook_gate/test_tg_solve.py b/tests/textbook_gate/test_tg_solve.py index b32b8fd2..858cf967 100644 --- a/tests/textbook_gate/test_tg_solve.py +++ b/tests/textbook_gate/test_tg_solve.py @@ -99,12 +99,15 @@ def test_solve_quadratic_via_factoring(pool, x): def test_solve_quadratic_repeated_root(pool, x): - """x^2 - 4x + 4 = 0 -> x = 2 (double root); the solver returns two - syntactically distinct-but-equal-valued entries (both simplify to 2), - so we check the count it actually produces rather than assume dedup.""" + """x^2 - 4x + 4 = 0 -> x = 2, a double root but a one-element solution set. + + ``solve`` returns a set and has no multiplicity channel, so two entries here + would be a wrong *count* rather than an annotation — the same reading under + which ``real_roots((x-1)**2)`` reports one isolating interval. + """ eqs = [x**2 - pool.integer(4) * x + pool.integer(4)] sols = ak.solve(eqs, [x]) - assert_solutions_satisfy(eqs, [x], sols, expected_count=2) + assert_solutions_satisfy(eqs, [x], sols, expected_count=1) for sol in sols: val = ak.eval_expr(sol[x], {}) assert abs(val - 2.0) < 1e-9