Skip to content

release: 3.8.0 - #296

Merged
AregGevorgyan merged 16 commits into
mainfrom
release/3.8.0
Aug 12, 2026
Merged

release: 3.8.0#296
AregGevorgyan merged 16 commits into
mainfrom
release/3.8.0

Conversation

@AregGevorgyan

@AregGevorgyan AregGevorgyan commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Release branch for 3.8.0. 15 commits, 0 behind main.

Theme

Most of this release is one defect family: a routine that could not answer returned something anyway. The fixes either compute where the answer is provable, or refuse with a stable code — never assert a result with nothing behind it.

Correctness

  • radical / primary_decompositionradical returned its input unchanged whenever the zero-dimensional Seidenberg trick didn't apply, asserting √I = I; primary_decomposition then stamped that onto every component's associated_prime, so a field whose name is a guarantee could name a non-prime. Now certified for monomial, principal, zero-dimensional and shape-position cases; E-IDEAL-005/006 otherwise.
  • triangularize — the main-variable pick was inverted (largest index, where the bottom-univariate split assumes index 0 is lex-greatest), so generators collided in one slot and the tie-break discarded the rest. Also verifies <chain> ⊇ I and refuses (E-SOLVE-004) rather than return an under-determined chain.
  • series — an unreachable order ran away; now refuses (E-SERIES-003) rather than assemble a truncated prefix whose O(h^n) term would be a false statement about the remainder.
  • solvesolve([a*x-b],[x]) returned b/a without stating a ≠ 0; hypotheses are readable via solve_side_conditions().
  • residue — bare AttributeError on a non-numeric point → E-RESIDUE-005.

Honesty of the capability contract

capabilities() advertised groebner_cuda and numpy, neither of which named anything a Python caller could reach — no observation distinguished True from False. Both removed (contract v3), with the dead numpy dependency. compute_groebner_basis_gpu's silent CPU fallback now reports through GpuBackendReport. A new contract test walks every advertised bit and requires a reachable entry point.

Performance

Matrix::det O(n!) → n^3.13 (1408ms → 0.09ms) · poly_gcd 272,882ms → 0.40ms · discarded-RootSum guard 869s → 154ms · real_roots 1.568× → 0.9975×. expand gains a 4096-product budget replacing an incoherent flat exponent cap, and records expand_pow_limit_reached when it declines.

CI that was inspecting nothing

Eight gates were found passing while checking nothing, and fixed: the error-code checker referenced by no workflow (53 accumulated errors), a valgrind shard building no test binaries, both AFL fuzz shards (5 stacked faults, never ran once), an ASan job never running Python, compute-sanitizer instrumenting cargo, and others. Fuzzing now demonstrably runs (66.7M + 43.2M execs); valgrind is scoped to the FFI boundary (121 tests, was 2h49m) with a guard that fails if zero tests execute.

Design note

Refusals travel out of band (take_ideal_refusal / take_triangularize_refusal / take_series_refusal), following matrix::take_zero_test_refusal: the error enums are public and exhaustive, so a correctness fix in a minor release cannot spend a major version on a new variant. A regression test pins that a genuine non-polynomial still reports E-SOLVE-001 rather than being re-attributed to the refusal code.

Gates

All five workflows green on the branch: CI (12m37s), CodSpeed, semver-check (no semver update required), ci-cross, docs. Locally: 2026 lib + 26 doctests, 2689 pytest, silent-error corpus 0 / 238, cargo doc -D warnings, ruff, check_error_codes, check_api_freeze.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added structured side-condition reporting for solver results.
    • Added CUDA execution reporting, device selection, and Python CUDA interfaces.
    • Added clearer refusal details and recovery guidance for unsupported calculations.
    • Zeilberger certificates now include boundary terms and side conditions.
  • Bug Fixes

    • Improved correctness for limits, series, sums, products, recurrences, ideals, roots, integration, and numerical solving.
    • Prevented several crashes and silent incorrect results.
  • Documentation

    • Updated version 3.8.0 documentation, CUDA guidance, capabilities, and error references.

AregGevorgyan and others added 15 commits August 12, 2026 03:33
Version in Cargo.toml, Cargo.lock and pyproject.toml; release-pinned wheel URLs
and install examples in README.md and the packaged skill; CHANGELOG's Unreleased
section closed as 3.8.0.

Note what is deliberately *not* bumped: docs/mdbook/src/rl.md states the Hub
package depends on `alkahest>=3.7.0`, which is a minimum-version constraint —
alkahest.rl shipped in 3.7.0 and the Hub still works against it, so bumping it
would narrow the requirement for no reason. Historical references in the
CHANGELOG body ("shipped in 3.7") are likewise left alone: they say which
release carried a defect, and rewriting them would destroy exactly the
information the recheck table exists to convey.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by running the CUDA suite on real dual-3090 hardware — the only
configuration where any of these can be observed, since the `cuda` feature is
off in every build CI and the shipped wheel produce. The suite itself passes:
17 CUDA-gated tests green, memcheck and racecheck both clean.

The nightly sanitizer steps were checking nothing. `compute-sanitizer --tool
memcheck cargo test ...` instruments `cargo`, a process that makes no CUDA calls,
because the default is `--target-processes application-only`. It emitted a banner,
no ERROR SUMMARY, and a green tick. `--target-processes all` makes it follow into
the test binaries, which is what produced the clean results above. This is the
fourth gate found this cycle that passed while inspecting nothing, after
`check_error_codes.py` (unreferenced by any workflow), the valgrind loop globbing
a package name that matches zero binaries, and an ASan job that never runs Python.

`capabilities()` said `llvm_jit: false` on a build that demonstrably emits NVPTX.
`alkahest-core`'s `cuda = ["jit", ...]`, so `--features cuda` links the LLVM
backend even though *alkahest-py*'s own `jit` feature is off, and the bit read
`cfg!(feature = "jit")` on the wrong crate. A capability has to describe what is
linked, not which flag the caller named.

`capabilities()["features"]["cuda"]` said `true` while `ak.compile_cuda` raised
AttributeError: the native module defines `compile_cuda`, `CudaCompiledFn` and
`CudaError` under that feature and `__init__.py` re-exported none of them, so the
only way in was the private `alkahest.alkahest`. They are now appended to `__all__`
at runtime when present — not listed in the literal, because the names genuinely
do not exist in a default build and every name in `__all__` must resolve.

Two tests pin the invariants. A third assertion had to be relaxed: it required
`not llvm_jit` whenever cranelift was on, encoding "cranelift implies no LLVM",
which is false on a cuda build where both are linked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nted nothing

Verified on dual RTX 3090 hardware against release/3.8.0 @ d139a46, the
configuration none of this is observable in otherwise: CI builds the `cuda`
feature nowhere and the shipped wheel does not carry it.

The racecheck step still checked nothing. d139a46 added `--target-processes
all`, which fixed memcheck: it now emits a real `ERROR SUMMARY: 0 errors`, and
a control run with the GPU tests filtered out proves the line is evidence of
instrumentation rather than decoration -- with no kernel launched the sanitizer
says "Target application terminated before first instrumented API call" and
prints no summary at all. Racecheck did not benefit, because wrapping the whole
`cargo test` pulls rustdoc's doc-test runner under the sanitizer, where it
segfaults (exit 139) after the CUDA suites pass but before the summary prints.
`continue-on-error: true` then rendered that as a green tick with no RACECHECK
SUMMARY anywhere in the log -- the same failure mode d139a46 set out to remove,
one step further down the file. Scoping both steps to the two integration
targets that launch kernels yields `RACECHECK SUMMARY: 0 hazards displayed` and
costs no coverage: the in-`src` unit tests only generate PTX, and
`compute_groebner_basis_gpu(.., None)` takes the `reduce_cpu` path, so neither
issues a CUDA API call.

`CudaCompiledFn` could only ever run on device 0 from Python. The core has had
`call_batch_on(ordinal, ..)` all along -- `nvptx_gpu::nvptx_multi_device_both_3090s`
drives both cards through it -- but the binding exposed only `call_batch`, so
on a multi-GPU host every device but the first was unreachable, which is the
same shape as `cuda` advertising an entry point the public namespace could not
reach. `call_batch_on` is now bound, `call_batch` delegates to it with ordinal
0, and the two share one input-validation body. Both cards return bit-identical
results across 6028 points on six expressions; an out-of-range ordinal raises a
structured `CudaError [E-CUDA-003]` rather than aborting.

GPU/CPU numerical agreement was checked and is not a problem. Against numpy's
ufuncs -- an oracle sharing no code with the codegen, unlike `numpy_eval`, which
delegates to the same Cranelift `CompiledFn` -- every lowered primitive agrees
to 0-2 ulp over 5011 points. The largest discrepancy anywhere, 6.17e-15 on
`x**3 - 2*x`, is exactly one ulp of `x**3` divided by a difference that cancels
40.8x; the 1.13e-13 on `tan(x)*exp(x**2)` is `exp`'s condition number of |u|=585
amplifying a last-place difference in its argument. Both are what floating point
does, not what the backend got wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second sweep over everything the hunts had written up but not fixed. The
silent-error corpus goes 166 -> 225 scored cases, still at 0 silent errors.

solve
-----
Two independent defects. It picked one Lex generator per variable and never
re-checked the finished tuple against the rest of the ideal, so `[x^2-xy, xy-y]`
reported `(-1,1)`, whose residual is 2. And generator selection ignored whether
the leading coefficient vanishes under the partial assignment: for
`<x^2+3y, 2xy+3x, 2y^2+3y>` that coefficient is `2y+3`, exactly zero on the
branch `y = -3/2`, so the quadratic formula divided by zero and *both* real
solutions disappeared. Step selection is now per-branch and adaptive, and every
parameter-free tuple is substituted back into the input equations under complex
ball arithmetic — one-sided, so a genuine solution can never be filtered out.
A repeated root also reported twice, because the pool folds no arithmetic on
literals and `0^2 - 4*1*0` never looked like zero.

Two 700-system differential fuzzes, ground truth from resultant elimination plus
60-digit root-finding with every point re-substituted: missing solutions 11 -> 0
and 8 -> 0, spurious 19 -> 0, duplicates 46 -> 0, non-number coordinates 7 -> 0.
Zero previously-answered systems now refuse, and one corpus *gains* 7 answers
because elimination order is no longer pinned to the monomial order.

`method="homotopy"` returned `[]` for a whole branch: the polyhedral cell
iterator keeps exactly the edge pairs its binomial solver cannot use, so it never
produced a start point and zero paths were reported as zero solutions. It now
falls back to the Bezout tracker, and reports E-HOMOTOPY-004 rather than `[]`
when no path completes — `[]` cannot mean both "no solutions" and "the tracker
failed everywhere".

sums, products, telescoping
---------------------------
`product_definite` dropped one rational scale factor per index, so answers were
off by c^(hi-lo+1); it cancelled only when numerator and denominator scales were
equal, which is why integer coefficients always looked right. `sum_definite`
checked only the telescoped difference, which never mentions the interior
indices, and so summed straight through a pole. `rsolve` mis-shifted the RHS and
sold `C0*2^n + C1*2^n` as a general solution for a repeated root.

`euler_maclaurin`'s gate could not fail: the constant was fitted at n = 512, the
largest check point the gate scored, so the residual there was zero by
construction and no fabricated value could be rejected. The fit moved outside the
gate and the constant must now reproduce at a second point; genuine constants
drift <= 3.2e-3 against >= 0.93 for fabricated ones.

`zeilberger` emitted a certificate without the boundary hypothesis WZ requires.
Stated as a side condition rather than verified, deliberately: no summation range
is supplied, and the hypothesis is a statement about a range, so "verifying" it
would mean inventing one.

elimination, special functions, panics
--------------------------------------
`subresultant_prs` disagreed with `resultant` (4 against 8 on a case whose
Sylvester determinant is 8). The reported cause — a discarded pseudo-division
multiplier — was real but not sufficient: defective sequences and equal-degree
sign errors survived it, 30 of 300 random pairs. Rewritten as Ducos' chain and
checked against the determinantal definition implemented from scratch, twice,
sharing no code with the module. Not against SymPy, whose `resultant` the hunt
had already found wrong for odd-by-odd degrees.

Gamma used the reflection formula, and `sin(pi * -2.0)` is 2.45e-16 rather than
zero, so `Gamma(-2)` returned 6.4e15; `product_definite(k-5, k, 1, 3)` inherited
it as -96 where the truth is -24. Now refuses with the same code as `0^-1`,
returning infinity rather than NaN so that `1/Gamma` stays usable at its zeros.
`limit(sqrt(x), x, 0, dir="-")` returned 0 for a limit that does not exist over
the reals; it now refuses on positive evidence only, and a 1890-limit sweep
changed 45 verdicts, all of them correct.

Three more panics crossing the FFI boundary as BaseException, plus one that was
a flint_abort — a SIGABRT no Python handler can catch.

contracts
---------
`except alkahest.AlkahestError` missed every Python-layer error: the wrappers
subclassed a pure-Python base that was not the native one, so the two hierarchies
were disjoint and the documented catch-all silently skipped AnsatzError,
CrossCheckError and SmtError — all three modules added this cycle for the loops
this release is for.

CUDA had no Python-level coverage at all (`pytest -k cuda` selected nothing) and
no CI job had ever built the extension with the feature, which is how
`ak.compile_cuda` came to raise AttributeError on a build whose own
capabilities() advertised `cuda: true`. Now 17 tests in three tiers, and the
nightly builds and exercises the Python surface. `compute-sanitizer` was
instrumenting `cargo` — a process that makes no CUDA calls — and so passed while
checking nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lease/3.8.0

# Conflicts:
#	.github/workflows/cuda_nightly.yml
#	tests/test_agent_contract.py
Checked the dispatched nightly rather than trusting the green ticks, and three
of the eight shards were inspecting nothing.

valgrind built with `cargo build`, which does not produce test binaries, so
`target/.../deps/alkahest_cas-*` matched only .rlib/.d artifacts, `[ -x ]` was
false for every one, and the loop body never executed. The whole shard emitted
no HEAP SUMMARY, no ERROR SUMMARY, no Memcheck banner — and reported success.
It now builds with `cargo test --no-run`, skips non-executables explicitly, and
**fails when it matches nothing**, which is what would have caught this.

fuzz-expr and fuzz-simplifier never fuzzed. AFL refuses to start while the
kernel core_pattern is a pipe; it printed its own remedy, exited non-zero, and
`|| true` rendered that as a pass. Both shards ran zero executions for however
long they have been "green". They now run `cargo afl system-config` first,
distinguish `timeout`'s 124 (the 2 h cap doing its job) from a real failure
instead of swallowing every status, and assert `execs_done > 0` from
fuzzer_stats afterwards.

That makes eight gates found this cycle that passed while checking nothing:
scripts/check_error_codes.py referenced by no workflow (53 accumulated errors),
TESTING.md's valgrind loop naming a package that does not exist, an ASan job
scoped so that pytest never runs under it, compute-sanitizer instrumenting
`cargo` rather than the test binaries, the same again for racecheck one step
further down that file, euler_maclaurin's convergence gate fitted at the very
point it scored, and now these three.

Tier 1b is genuine: 2 slow tests selected and passed. It completes in under a
second because the sparse_interp roadmap case it exists to cover was fixed in
#292, not because it skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…llbacks

`system-config` runs sudo internally — AFL's own message says so — and cargo
lives in ~/.cargo/bin, which sudo's secure_path does not include, so the sudo
prefix would have failed with "cargo: command not found" and wasted a two-hour
shard proving nothing.

Also sets AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES and AFL_SKIP_CPUFREQ, so the
fuzzer proceeds even where core_pattern cannot be changed or no CPU governor is
exposed, rather than refusing to start. The `execs_done > 0` assertion is what
actually proves the shard did work, so the fallbacks are safe: if they are not
enough, the shard now fails instead of passing quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps, both found by checking rather than by anything failing.

`release/**` now gets push CI. `ci.yml` fired on push only for `main`, and the
`pull_request` runs did not fire for a PR based on `release/3.8.0` either — PR
#295 got no checks at all beyond a CodeRabbit note that reviews are disabled for
that base. So every merge into the release branch since the version bump,
including a merge commit resolving conflicts across three files, reached the
branch with no automated verification whatsoever; local gate runs were the only
thing standing behind them. A branch that a release tag is cut from should not
be less covered than main. Extended to the semver, cross-compile, CodSpeed and
docs workflows for the same reason.

CUDA Nightly is no longer scheduled. It targets `[self-hosted, gpu-3090]` and no
such runner is registered, and an unrunnable scheduled job does not fail — it
queues. Every night for at least five days it sat pending for 24 hours and was
auto-cancelled, producing no signal in either direction and one permanently
pending job on main. `workflow_dispatch` is retained so it runs the moment a
runner exists, and the file is kept because it is the specification an agent on
a GPU box follows step for step. The header now records that the CUDA surface is
verified by hand instead, and what that verification covered for 3.8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guards added in 4225654 did their job — all three shards went from green to
red, which is the correct direction for gates that were inspecting nothing. This
fixes the underlying causes they exposed.

valgrind now takes the executable paths from `cargo test --no-run
--message-format=json` rather than globbing `target/<triple>/debug/deps/`. The
build succeeded and the glob still matched nothing, because `-Z build-std` does
not put the binaries where that path assumed. Asking cargo which executables it
produced removes the assumption entirely, and the failure message now dumps
every executable cargo did report, so a future mismatch is diagnosable from the
log instead of from a bare exit code.

Both fuzz shards got past `core_pattern` — `cargo afl system-config` works, once
it is not wrongly prefixed with sudo — and then died on
`SYSTEM ERROR : Unable to create 'fuzz/out/expr_builder'`. `fuzz/out/` is
gitignored, so it does not exist in a fresh checkout, and AFL will not create a
nested path under a missing parent. `mkdir -p fuzz/out` first. The seed corpora
are tracked and present, but an explicit check makes an empty one say so rather
than surfacing as a cryptic zero-execution failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third layer in the same shard. `fuzz/` is excluded from the root workspace, so
`cargo afl build --manifest-path fuzz/Cargo.toml` writes to `fuzz/target/`,
while the fuzz invocation pointed at `target/debug/<bin>` — AFL got all the way
past core_pattern and the output directory, then aborted with "Program not found
or not executable".

Same class as the valgrind glob: a hardcoded path standing in for something the
build system already knows. The binary is now located with `find` across both
candidate target directories, and if it is missing the step prints the
executables that *were* produced rather than failing with a bare status.

Each of these was invisible while `|| true` swallowed the exit code. Removing it
surfaced three stacked breakages one at a time, in dependency order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tier 1a was ~16.6 minutes of step time and ASan was 467s of it — nearly half the
wait on every pull request, for a check whose value is depth rather than
immediacy. Measured per-step rather than guessed: ASan 467s, pytest 173s,
cargo test 86s, cargo test (egraph) 79s, maturin 45s, the four clippy feature
runs 104s combined, everything else under 15s.

Moving ASan (and the nightly toolchain install it alone needed) takes Tier 1a to
about 520s, or 8.7 minutes. Nothing else is worth moving: the clippy feature
combinations are cheap and catch feature-gated build breaks that are exactly a
PR's business, and the egraph test run is a correctness gate.

The nightly shard gets a better version than the one it replaces. Tier 1a ran
`-p alkahest-cas` because full-workspace ASan under -Z build-std risks the
runner cap; nightly has the budget for `--workspace`.

Note the nightly job installs the nightly toolchain only for the sanitizer
shards, and that condition did not list `asan` — the new shard would have run
`cargo +nightly` without it. Fixed here rather than discovered by a red run.

The fuzz and valgrind shards discussed alongside this were already nightly-only
and never contributed to PR latency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First genuine execution of this shard since it was written. It ran the
alkahest_cas test binary for 2h49m and reported:

  definitely lost: 0 bytes in 0 blocks
  indirectly lost: 0 bytes in 0 blocks
  ERROR SUMMARY: 4 errors from 4 contexts (suppressed: 315 from 315)

No memory-safety errors and no real leaks. The four failures were all
"possibly lost", 10,280 bytes across 20 blocks: a thread-local std::thread
Thread handle, MPFR's const_log2 cache reached through mpfr_cache, and two
hashbrown table allocations. Those are interior pointers into intentional
caches and process-lifetime globals — the category valgrind reports and does
not consider a defect.

So the gate was failing on the wrong thing. `--errors-for-leak-kinds=definite,
indirect` keeps an invalid read or write, and any definitely- or
indirectly-lost block, as a failure, while a possible leak is reported but does
not fail the build. Preferred over four more suppressions matched on stack
shape, which would rot the first time an inlining decision changes and which
would also hide a genuine leak arriving through the same frames.

The existing valgrind.supp is doing its job — 315 records suppressed — and is
unchanged.

Worth noting for later: 2h49m of a 6h budget, because it valgrinds every unit
test in a -Z build-std debug build. The step is named for the Rust/C FFI
boundary, and scoping it there would cost little coverage for most of the time.
Left alone for now because it completes and is nightly-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With a 14-minute budget the earlier trade is unnecessary. CI wall-clock is the
slowest *job*, not the sum of steps, so ASan does not need to sit inside Tier 1a
where its 467s was half the wait. As its own job it runs concurrently: Tier 1a
finishes in roughly six minutes without it, the ASan job takes about nine and a
half including setup, and the critical path is the larger of the two — about ten
minutes, inside the budget, with the PR-time coverage restored.

Scoped to `-p alkahest-cas` here, matching what Tier 1a ran. The nightly `asan`
shard keeps `--workspace`, so the tiering is deliberate: fast targeted coverage
on every PR, the full workspace overnight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tier 1b no longer waits for 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 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 that
a regression would otherwise silently undo for a day.

valgrind now runs the tests that actually cross into GMP/MPFR/FLINT rather than
all 1984 unit tests: 121 selected, 1863 filtered out. Its first real run took
2h49m of a 6h budget to re-check pure-Rust logic the other shards already cover.
ALKAHEST_VALGRIND_FILTER overrides it, empty for a full sweep.

Two things about that scoping were nearly a new vacuous gate, which is worth
recording given the eight found this cycle. libtest takes filters as separate
positional arguments and matches them as substrings — a single "a|b" argument
matches nothing, runs zero tests, and lets valgrind report a clean bill. And
even correctly passed, a filter that stops matching after a rename would do the
same silently. So the step now asserts the binary actually executed tests and
fails when it did not. Verified locally: the filter list selects 121 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defect clusters, all of the same family: a routine that could not
answer returned something anyway, with nothing behind it.

ideal/: `radical` implemented only the zero-dimensional Seidenberg trick
over univariate generators appearing verbatim in the basis, and returned
the input unchanged otherwise — asserting √I = I. `primary_decomposition`
then overwrote every component's `associated_prime` with that, so a field
whose name is a guarantee could name a non-prime. Both now compute where
provable (monomial, principal, zero-dimensional, and certified leaves) and
refuse with E-IDEAL-005/006 otherwise. `triangularize`'s main-variable pick
was inverted — it returned the largest index while the bottom-univariate
split assumes index 0 is lex-greatest — so generators collided in one slot
and the tie-break discarded the rest; it now also verifies <chain> ⊇ I and
refuses with E-SOLVE-004 rather than return an under-determined chain.

calculus/series: an unreachable order ran away instead of stopping. It now
refuses (E-SERIES-003) rather than assemble a truncated prefix, whose O(h^n)
term would be a false statement about the remainder.

simplify: the flat MAX_EXPAND_POW_EXP=4 cap was incoherent (it allowed
160k products while refusing (x+y)^5) and declined silently. Replaced by a
4096-product budget, with declines recorded as an `expand_pow_limit_reached`
step in the derivation log.

solver: `solve([a*x-b],[x])` returned b/a without stating a ≠ 0; hypotheses
are now recorded and readable via `solve_side_conditions()`.

capabilities(): `groebner_cuda` and `numpy` advertised features that named
nothing a Python caller could reach — no observation distinguished True from
False. Both removed (contract v3), along with the dead numpy dependency;
`compute_groebner_basis_gpu`'s silent CPU fallback is now reported through
`GpuBackendReport`. A new contract test walks every advertised bit and
requires a reachable entry point, so this cannot recur.

All refusals travel out of band (take_ideal_refusal / take_triangularize_-
refusal / take_series_refusal), following matrix::take_zero_test_refusal:
the enums are public and exhaustive, so a correctness fix cannot spend a
major version on a new variant. The bindings consult the takers, and a
regression test pins that a genuine non-polynomial still reports E-SOLVE-001
rather than being re-attributed to the refusal code.

Also fixes groebner_cuda tests panicking instead of skipping when libcuda
is absent, and residue() raising a bare AttributeError on a non-numeric
point (now E-RESIDUE-005).

Gates: fmt, clippy -D warnings, 2026 lib + 26 doctests, cargo doc -D
warnings, semver-checks (no update required), 2689 pytest, silent-error
corpus 0/238, ruff, check_error_codes, check_api_freeze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AregGevorgyan, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bef6ee8e-64e7-4279-b5c9-381da8ee326d

📥 Commits

Reviewing files that changed from the base of the PR and between 4130506 and 591626e.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • alkahest-core/src/simplify/parallel.rs
  • docs/mdbook/src/errors.md
📝 Walkthrough

Walkthrough

Version 3.8.0 adds correctness checks, structured refusal APIs, solver verification, CUDA reporting, Python binding updates, expanded regression coverage, release-branch CI, and updated documentation.

Changes

Release and CI

Layer / File(s) Summary
Workflow coverage and validation
.github/workflows/*
Release branches now trigger CI-related workflows. Sanitizer, Valgrind, AFL, slow Python, and CUDA jobs now use expanded validation and scoped execution.
Release metadata and documentation
Cargo.toml, pyproject.toml, CHANGELOG.md, README.md, docs/*, alkahest-skill/*
Project metadata and documentation now describe version 3.8.0, updated capability flags, CUDA support, and new error behavior.

Core computation

Layer / File(s) Summary
Calculus, sums, products, and recurrences
alkahest-core/src/calculus/*, alkahest-core/src/sum/*, alkahest-core/src/holonomic/*
Limits, Euler–Maclaurin fitting, sums, products, recurrences, and Zeilberger certificates now reject invalid results or expose required boundary conditions.
Bounded expansion and simplification logs
alkahest-core/src/calculus/series.rs, alkahest-core/src/simplify/*
Series and power expansion now enforce work limits and record declined rewrites in derivation logs.
Verified solving and ideal operations
alkahest-core/src/solver/*, alkahest-core/src/ideal/*
Solver candidates are verified and deduplicated. Triangularization and ideal operations validate supported results and report structured refusals otherwise.
Algebraic and numerical edge cases
alkahest-core/src/poly/resultant.rs, alkahest-core/src/primitive/mod.rs, alkahest-core/src/integrate/*, alkahest-core/src/lattice/*
Subresultants, gamma poles, logarithmic integration, and rank-deficient LLL processing now handle previously unsafe or incorrect cases.

Public interfaces

Layer / File(s) Summary
CUDA and Python APIs
alkahest-core/src/poly/groebner/*, alkahest-py/*, python/alkahest/*
CUDA execution reports GPU and CPU fallback activity. Python exposes device selection, CUDA errors, solver side conditions, and structured refusal handling.
Regression and contract tests
tests/*, alkahest-core/tests/*
Tests cover capability reachability, CUDA execution, solver verification, refusal codes, series budgets, algebraic edge cases, and silent-error regressions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies this pull request as the 3.8.0 release, matching the version metadata and release-focused changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch release/3.8.0

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Aug 12, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 32.18%

❌ 2 regressed benchmarks
✅ 33 untouched benchmarks
⏩ 49 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_solve_circle_line_size5 2.3 ms 3.9 ms -40.2%
test_solve_6r_ik_size2 755.6 µs 982.6 µs -23.09%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing release/3.8.0 (591626e) with main (9e3eabb)

Open in CodSpeed

Footnotes

  1. 49 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@AregGevorgyan

Copy link
Copy Markdown
Collaborator Author

CodSpeed: 3 regressions, all measured and accounted for

CodSpeed flags 3 regressed benchmarks against main. I investigated each rather than acknowledging them blind. All three are the cost of returning a correct answer where the old code returned a cheaper wrong one. Note the comparison is the whole 15-commit branch vs main, not any single commit.

Benchmark Change Cause
test_solve_circle_line_size5 2.3 ms → 3.9 ms solve now ball-verifies every candidate solution (solver/verify.rs). This is the fix that took solve's spurious/missing counts from 19/8/46/7 to 0.
test_solve_6r_ik_size2 756 µs → 985 µs triangularize returns the complete chain. The old main_variable_recursive returned the largest variable index where the bottom-univariate split assumes index 0 is lex-greatest, so generators collided in one slot and the tie-break discarded the rest — [x−y−1, y²−2] came back as [x−y−1], a curve where the answer is two points. It was faster because it was computing less.
test_subresultant_chain_deg8 567 µs → 631 µs The Ducos subresultant PRS rewrite (917a4b5), itself a silent-error fix.

On the triangularize number specifically

I suspected my new ⟨chain⟩ ⊇ I containment check was the cost, so I measured it directly with an A/B build: 2.1 µs of 34.7 µs, ~6% — not the 23%. I also tried a structural-identity fast path to skip the second Gröbner basis; an A/B measurement showed exactly 32.6 µs either way, i.e. it never fired (chain polynomials go through make_monic, so they aren't structurally equal to the basis generators). Rather than ship an unproven optimisation, I removed it. The remaining cost is the correct, larger computation.

Recommendation

Accept all three. They are sub-4 ms operations, and this release's entire premise is that a confident wrong answer is worse than a slower right one. Acknowledging them on CodSpeed is an in-app action I can't perform from here.

Offsetting wins in this branch: Matrix::det O(n!) → n^3.13 (1408 ms → 0.09 ms), poly_gcd 272,882 ms → 0.40 ms, discarded-RootSum guard 869 s → 154 ms.

(The earlier Verify Lean 4 proofs failure was a GitHub 503 fetching elan — infrastructure, re-run.)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
alkahest-core/src/simplify/engine.rs (1)

376-405: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use an invocation-scoped expansion-limit collector

  • alkahest-core/src/simplify/redex.rs#L146-L167: ExpandPow records events in worker-local storage, but expand_limit_log() drains only the caller thread. Merge worker events into the pass log.
  • alkahest-core/src/simplify/engine.rs#L376-L405: prevent nested calls on the same thread from clearing or draining the outer invocation’s events.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/simplify/engine.rs` around lines 376 - 405, Use an
invocation-scoped expansion-limit collector: update
alkahest-core/src/simplify/engine.rs lines 376-405 to isolate each top-level
simplify invocation while preserving outer events across nested calls, and
update alkahest-core/src/simplify/redex.rs lines 110-129 so ExpandPow worker
events are transferred into the current pass log rather than relying only on
caller-thread expand_limit_log() draining. Ensure outer invocation events are
not cleared or drained by nested simplify calls.
🟡 Minor comments (17)
python/alkahest/exceptions.py-60-63 (1)

60-63: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use ValueError for the pure-Python fallback base.

The native AlkahestError subclasses ValueError. The binding relies on that: alkahest-py/src/lib.rs (lines 3583-3630) states that AlkahestError subclasses ValueError so except ValueError keeps working, and tests/test_residue.py line 63 asserts isinstance(excinfo.value, ValueError). When the extension is missing, _NativeAlkahestError = Exception drops that guarantee, so except ValueError no longer catches Python-raised wrappers. Align the fallback with the native hierarchy.

🛠️ Proposed fallback change
 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
+    _NativeAlkahestError = ValueError
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/alkahest/exceptions.py` around lines 60 - 63, Update the pure-Python
fallback assignment for _NativeAlkahestError in exceptions.py to use ValueError
instead of Exception, preserving the native AlkahestError inheritance contract
so Python-raised wrappers remain catchable by except ValueError.
tests/silent_errors/corpus.py-631-639 (1)

631-639: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Keep KeyboardInterrupt and SystemExit uncaught.

The second clause catches every BaseException, so KeyboardInterrupt and SystemExit also become RuntimeError. A user who interrupts a corpus run then sees a scored no_answer case instead of a stop, and the run continues. Re-raise those two before the conversion.

🛠️ Proposed fix
     def op() -> Any:
         try:
             return fn()
         except Exception:
             raise
+        except (KeyboardInterrupt, SystemExit):
+            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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/silent_errors/corpus.py` around lines 631 - 639, Update the exception
handling in op so KeyboardInterrupt and SystemExit are re-raised before the
broad BaseException conversion; preserve converting other BaseException
subclasses, including PanicException, into RuntimeError.
tests/silent_errors/corpus.py-812-824 (1)

812-824: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Match the reported condition more precisely.

Line 816 tests str(hypothesis_about) in str(c). str(_A) is the single character "a", so any side condition whose text contains the letter a scores 0.0. A condition such as "b ≠ 0" would not match, but a condition mentioning any other identifier or word containing a would. That lets an unrelated disclosure pass the gate the case exists to enforce. Use a word-boundary match.

🛠️ Proposed fix
-        stated = any(str(hypothesis_about) in str(c) for c in ak.solve_side_conditions())
+        pattern = re.compile(rf"(?<!\w){re.escape(str(hypothesis_about))}(?!\w)")
+        stated = any(pattern.search(str(c)) for c in ak.solve_side_conditions())

Add import re to the module imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/silent_errors/corpus.py` around lines 812 - 824, Update the
side-condition check in op to use a regular-expression word-boundary match for
hypothesis_about rather than substring containment, and add the required re
import. Preserve the stated path only when a complete identifier/word match is
present.
docs/sphinx/api/errors.rst-170-182 (2)

170-182: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the new refusal codes to the refusal index.

Lines 50-51 list the refusal codes, but they omit E-SOLVE-004, E-IDEAL-005, and E-IDEAL-006. This section classifies all three as refusals. Add them to the index so users do not miss the new cases.

Proposed refusal-list update
-Refusals: ``E-CAD-001``, ``E-LINALG-010``, ``E-MAT-004``, ``E-SOS-002``,
-``E-ANSATZ-003``, ``E-SMT-003``, ``E-INT-001``, ``E-BUDGET-001..003``.
+Refusals: ``E-CAD-001``, ``E-LINALG-010``, ``E-MAT-004``, ``E-SOS-002``,
+``E-ANSATZ-003``, ``E-SMT-003``, ``E-INT-001``, ``E-BUDGET-001..003``,
+``E-SOLVE-004``, ``E-IDEAL-005``, ``E-IDEAL-006``.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/sphinx/api/errors.rst` around lines 170 - 182, Update the refusal-code
index at the section around the existing lines 50–51 to include E-SOLVE-004,
E-IDEAL-005, and E-IDEAL-006. Keep the existing index structure and descriptions
consistent with the refusal entries documented near triangularize, radical, and
primary_decomposition.

170-182: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Synchronize refusal documentation with the binding behavior.

The bindings consume the refusal records and tests assert the specific codes. Update the Sphinx note and the stale mdBook paragraph, and add E-IDEAL-005, E-IDEAL-006, and E-SOLVE-004 to the Sphinx refusal list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/sphinx/api/errors.rst` around lines 170 - 182, Synchronize the refusal
documentation with binding behavior: add E-IDEAL-005, E-IDEAL-006, and
E-SOLVE-004 to the Sphinx refusal list, update the note describing radical and
primary_decomposition to match the binding-exposed refusal records, and revise
the corresponding stale mdBook paragraph. Preserve the documented code meanings
and reference the existing take_ideal_refusal() and take_triangularize_refusal()
symbols.

Source: Coding guidelines

docs/mdbook/src/telescoping.md-62-70 (1)

62-70: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Complete the boundary-check example.

The prose asks the reader to check G(n, k_hi+1) - G(n, k_lo), but the example computes only g_at_lo. Add the k_hi + 1 evaluation and compare both values before concluding that the boundary term is zero.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/mdbook/src/telescoping.md` around lines 62 - 70, Complete the
boundary-check example after the existing g_at_lo calculation by evaluating
cert.boundary_term at k_hi + 1, then compare the upper and lower boundary values
to verify their difference is zero. Keep the example aligned with the documented
G(n, k_hi+1) - G(n, k_lo) check and use the existing symbols cert.boundary_term,
k_hi, and k_lo.
docs/mdbook/src/gpu.md-100-115 (1)

100-115: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not treat every E-CUDA-003 as an invalid device ordinal.

Line 112 maps any CudaError from call_batch_on to “no such device”. Line 148 defines E-CUDA-003 to include context creation, module loading, and memory-copy failures. The helper can report a false device count when a device exists but another CUDA operation fails.

Restrict the probe to an ordinal-specific error, or describe it as a best-effort probe that does not establish the device count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/mdbook/src/gpu.md` around lines 100 - 115, Update the device_count
helper to avoid interpreting every ak.CudaError from call_batch_on as an invalid
ordinal; restrict termination to an ordinal-specific error if available, or
revise the documentation and helper semantics to clearly identify the result as
a best-effort probe rather than an authoritative device count.
docs/mdbook/src/codegen.md-164-176 (1)

164-176: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Bind alkahest before calling alkahest.capabilities().

The example imports only compile_cuda, so the capability check raises NameError. Add import alkahest or import and call capabilities directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/mdbook/src/codegen.md` around lines 164 - 176, Update the CUDA example
around compile_cuda to bind the alkahest module before calling
alkahest.capabilities(), either by importing alkahest or importing capabilities
directly. Preserve the existing feature-guard behavior and CUDA compilation
flow.
docs/mdbook/src/gpu.md-53-58 (1)

53-58: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not promise PTX emission from the cuda capability bit.

cuda == True guarantees linked CUDA entry points, but PTX compilation also requires libdevice.10.bc; its absence raises E-CUDA-005. Limit this statement to the linked entry points.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/mdbook/src/gpu.md` around lines 53 - 58, Update the CUDA capability
description near the `cuda == True` statement to remove the promise that PTX can
be emitted on the host. Keep the guarantee limited to the presence of
`ak.compile_cuda` and `ak.CudaCompiledFn`, and preserve the existing distinction
between capability linkage and runtime GPU availability.
alkahest-py/src/lib.rs-10614-10622 (1)

10614-10622: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

solve_side_conditions() can report conditions from the abandoned polynomial attempt.

capture_solve_side_conditions runs unconditionally after solve_polynomial_system. When numeric=true and the result is SolverError::HighDegree, the code falls through to homotopy at Line 10626 and returns numeric points. The captured conditions then describe the failed symbolic attempt, not the returned answer. Clear the captured state on that fallback path.

Proposed fix in the numeric fallback branch
     if numeric {
         if let Err(alkahest_core::SolverError::HighDegree(_)) = &result {
+            // The returned points come from homotopy, so the hypotheses the
+            // abandoned symbolic attempt assumed do not describe them.
+            reset_solve_side_conditions();
             let opts = HomotopyOpts::default();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-py/src/lib.rs` around lines 10614 - 10622, Clear the captured
solve-side-condition state when the numeric fallback handles
SolverError::HighDegree and proceeds to homotopy, before returning the numeric
points. Update the fallback logic surrounding solve_polynomial_system and
preserve captured conditions for successful symbolic results and non-fallback
errors.
tests/test_agent_contract.py-174-177 (1)

174-177: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the CUDA entry-point set with tests/test_cuda.py.

This tuple includes CudaError. tests/test_cuda.py Line 43 excludes it and documents at Lines 45-48 that the native module registers it unconditionally, so it resolves on every build. The two files now define the CUDA entry-point set differently. Because all(...) requires every name, a regression that dropped CudaError from a non-CUDA build would still satisfy this assertion while test_cuda.py fails. Drop CudaError here and keep the exception coverage in the dedicated test.

Proposed alignment
     features = alkahest.capabilities()["features"]
-    reachable = all(
-        hasattr(alkahest, name) for name in ("compile_cuda", "CudaCompiledFn", "CudaError")
-    )
+    # `CudaError` is deliberately excluded: the native module registers it
+    # unconditionally, so it resolves on every build. See tests/test_cuda.py.
+    reachable = all(hasattr(alkahest, name) for name in ("compile_cuda", "CudaCompiledFn"))
     assert features["cuda"] == reachable, (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_agent_contract.py` around lines 174 - 177, Update the CUDA
reachability check in the features assertion to consider only compile_cuda and
CudaCompiledFn, matching the entry-point set used by tests/test_cuda.py. Remove
CudaError from this tuple and leave its validation to the dedicated CUDA test.
alkahest-py/src/lib.rs-3611-3629 (1)

3611-3629: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add E-RESIDUE-005 to docs/sphinx/api/errors.rst.

docs/mdbook/src/errors.md already documents the code. The Sphinx error documentation does not reference it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-py/src/lib.rs` around lines 3611 - 3629, Update the Sphinx error
reference in errors.rst to document E-RESIDUE-005, matching the existing
description and guidance for residue point validation already present in the
other error documentation. Keep the entry aligned with the surrounding
error-code entries.

Source: Coding guidelines

alkahest-core/tests/groebner_cuda.rs-75-84 (1)

75-84: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove the fell_back_to_cpu() assertion

A CPU-only run can perform zero reductions, so fell_back_to_cpu() can be false. The existing requested_device == None and reductions_on_gpu == 0 checks already enforce the CPU-only path. Do not replace the assertion with a condition that is always true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/tests/groebner_cuda.rs` around lines 75 - 84, Remove the
assert!(backend.fell_back_to_cpu(), ...) check from assert_no_gpu. Keep the
requested_device, reductions_on_gpu, first_gpu_error, and ran_on_gpu assertions
unchanged; do not replace the removed assertion with another unconditional
condition.
python/alkahest/__init__.py-384-388 (1)

384-388: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Rebind CudaError in alkahest.exceptions to the native class. The overlay updates only package globals, so alkahest.exceptions.CudaError remains distinct and does not catch native CUDA raises.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/alkahest/__init__.py` around lines 384 - 388, Update the native
overlay logic that handles the unconditionally registered CudaError to also
rebind alkahest.exceptions.CudaError to the native exception class, keeping the
package-level CudaError binding synchronized across CPU-only and CUDA builds.
alkahest-core/src/sum/mod.rs-172-184 (1)

172-184: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the comment with the range the guard actually checks.

The comment says a pole "strictly between the bounds" and names the function interior_undefined_index. interior_undefined_index scans lo_i..=hi_i inclusive (line 327) and integer_roots_in accepts root >= lo && root <= hi (line 280). Endpoint poles are therefore refused too. Including the endpoints is the correct behavior, because Σ_{k=0}^{5} 1/k has no value either. Update the wording so a later reader does not narrow the range to match the word "interior".

📝 Proposed comment change
-    // 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
+    // A pole of the *summand* at any index in `[lo, hi]` — endpoints included —
+    // is invisible in the telescoped difference, which never mentions the
+    // individual indices, so it has to be looked for in the summand itself, the same way
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/sum/mod.rs` around lines 172 - 184, Update the explanatory
comment above interior_undefined_index to describe poles at any index within the
inclusive summation bounds, including lo and hi, rather than only poles strictly
between them. Keep the guard and error behavior unchanged, and retain the
distinction that the summand itself must be checked because telescoping can hide
undefined terms.
alkahest-core/src/solver/verify.rs-117-124 (1)

117-124: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the exponent negation against i64::MIN.

Line 289 converts the exponent with to_i64(), so n can be i64::MIN. -n at Line 122 then overflows: a debug build panics, and a release build wraps to i64::MIN and recurses until the stack is exhausted. Use checked_neg and report the exponent as unrepresentable.

🛡️ Proposed fix
     fn powi(&self, n: i64) -> Option<CBall> {
         if n == 0 {
             return Some(CBall::one());
         }
         if n < 0 {
-            let pos = self.powi(-n)?;
+            let pos = self.powi(n.checked_neg()?)?;
             return pos.recip();
         }

checked_neg returns None for i64::MIN, which eval_uncached already maps to VerifyGap::Undefined. If Unsupported is the better report for an unrepresentable exponent, reject the exponent at Line 289 instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/solver/verify.rs` around lines 117 - 124, Update powi to
use checked_neg when handling negative exponents, returning None when negation
is unavailable for i64::MIN so eval_uncached can report VerifyGap::Undefined
instead of recursing. Preserve the existing reciprocal flow for representable
negative exponents.
alkahest-core/src/solver/mod.rs-72-75 (1)

72-75: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Qualify the Finite post-condition for parametric tuples.

Line 73 states that every tuple survived substitution into the input equations. refine_solutions keeps tuples that fail to evaluate with VerifyGap::Unsupported without any check (Line 849), and solve_polynomial_system's own documentation says a parametric tuple "is returned unverified" (Line 919). Parametric results are returned inside Finite — see the tests at Lines 1295-1340. Restrict the claim to parameter-free tuples so callers do not rely on a guarantee that the parametric path does not provide.

📝 Proposed documentation fix
     /// Finitely many solutions (each is a `Vec<ExprId>` 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.
+    /// Every **parameter-free** tuple has survived substitution back into the
+    /// input equations, so it is never one the solver can itself refute.  A
+    /// tuple that mentions a free parameter cannot be substituted numerically
+    /// and is returned unverified, under the hypotheses reported by
+    /// [`take_solve_side_conditions`].
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/solver/mod.rs` around lines 72 - 75, Update the
documentation for the Finite variant to qualify its substitution and refutation
guarantee as applying only to parameter-free tuples. Make clear that tuples
containing parameters may be returned unverified, while preserving the existing
guarantee for fully concrete solutions.
🧹 Nitpick comments (15)
tests/test_series_v215.py (1)

124-132: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer a structural assertion over the printed form.

Line 132 matches "x^23" in str(s.expr). That couples the test to the printer output. This file already walks the node tree in _has_big_o, so the highest power can be checked structurally and stays stable if the printer changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_series_v215.py` around lines 124 - 132, Replace the string-based
`"x^23"` assertion in test_ordinary_high_order_series_still_expands with a
structural node-tree assertion, reusing the existing traversal approach from
_has_big_o to verify that the expansion contains the highest odd power x^23
without depending on printer formatting.
tests/test_parametric_solve.py (1)

112-121: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider marking the side-condition tests as non-parallel-safe.

ak.solve_side_conditions() reads state that the last solve call wrote. These three tests assert that state across separate calls. If the suite ever runs with thread-level parallelism inside one process, an interleaved solve from another test overwrites the channel and makes these assertions flaky. A marker or a short comment that records the requirement keeps that constraint explicit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_parametric_solve.py` around lines 112 - 121, The side-condition
tests rely on process-global state written by solve, so mark the affected tests
as non-parallel-safe or add a concise comment documenting that requirement.
Apply this to test_hypotheses_do_not_leak_from_an_earlier_solve and the other
tests asserting ak.solve_side_conditions() across separate solve calls.
tests/silent_errors/corpus.py (1)

1744-1747: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the shared _Z symbol.

_Z is defined at line 703 for the third variable. This case creates POOL.symbol("z") twice inline. Reusing _Z keeps one name for one symbol.

♻️ Proposed change
-        op=solution_count(
-            [X ** _int(2), Y ** _int(2), POOL.symbol("z") ** _int(2)],
-            [X, Y, POOL.symbol("z")],
-        ),
+        op=solution_count(
+            [X ** _int(2), Y ** _int(2), _Z ** _int(2)],
+            [X, Y, _Z],
+        ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/silent_errors/corpus.py` around lines 1744 - 1747, Update the
solution_count call to reuse the shared _Z symbol for the third variable and its
squared expression, replacing both inline POOL.symbol("z") constructions while
leaving the existing X and Y arguments unchanged.
tests/test_residue.py (1)

96-110: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use one call form, and consider a slow marker.

Lines 105 and 110 call the same function through two names: the module-level residue import and ak.residue. Use one form for consistency. The test also builds a 300-level nested expression and then runs a successful residue over it. If that path takes noticeable time, mark the test with @pytest.mark.slow, which the default pytest tests/ run excludes.

♻️ Proposed consistency change
-    assert ak.residue(deep, z, 0) is not None
+    assert residue(deep, z, 0) is not None

As per coding guidelines: "Run pytest tests/ for Python test suite (default excludes @pytest.mark.slow)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_residue.py` around lines 96 - 110, Use a single consistent call
form in test_deeply_nested_polynomial_point_still_refused_cleanly, replacing
either the imported residue call or ak.residue call so both assertions invoke
the same symbol. If the successful residue evaluation over the 300-level
expression is noticeably slow, add the existing pytest slow marker to this test
so the default test suite excludes it.

Source: Coding guidelines

alkahest-py/src/lib.rs (1)

9589-9623: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Consider releasing the GIL around the CUDA launch.

eval_on_device calls call_batch_on, which loads a module and launches a kernel while holding the GIL. A large batch therefore blocks every other Python thread. py_series at Line 3745 already uses py.allow_threads for the same reason. The input columns are already copied into owned Vec<f64> before the call, so the launch section can move outside the GIL.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-py/src/lib.rs` around lines 9589 - 9623, Update
PyCudaCompiledFn::eval_on_device to wrap the CUDA module-loading and
kernel-launch call to self.inner.call_batch_on in Python::allow_threads, keeping
the owned input and output buffers outside the closure as needed and preserving
existing PyCudaError conversion after the call.
tests/test_cuda.py (1)

79-84: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The broad except Exception can hide a binding regression as "no device".

Any exception from compile_cuda or call_batch marks the device tier as unavailable. On a CUDA build without ALKAHEST_GPU_TESTS=1, a defect in the binding then silently skips all of tier 3 instead of failing. Narrow the probe so only driver and device errors count as unavailable, and let other exception types propagate.

Proposed narrowing of the availability probe
     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"
+    except ak.CudaError as exc:
+        # Only a CUDA-layer failure means "no usable device". A TypeError or a
+        # ValueError from the binding is a defect for the tests to report.
         return f"no usable CUDA device: {type(exc).__name__}: {exc}"
     return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cuda.py` around lines 79 - 84, Update the CUDA availability probe
around ak.compile_cuda and fn.call_batch to catch only the established
driver/device-related exception types and return the unavailable-device message
for those cases. Allow all other exceptions, including binding regressions, to
propagate instead of classifying them as an unavailable CUDA device.
alkahest-core/src/sum/mod.rs (1)

326-338: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Make the brute-force candidate scan honour the budget.

When any negative-power base fails to parse as a polynomial in k, unparsed becomes true and the candidate list grows to the whole range, up to 2048 indices. Each candidate then runs subs plus a full simp. A summand such as 1/Γ(k) reaches this path, so up to 2048 simplifications run before sum_definite returns. MAX_POLE_SCAN bounds the work but does not make it cheap, and this crate already has a cooperative budget (crate::budget::check, used in alkahest-core/src/calculus/series.rs). Add a budget check inside the loop so a caller with an active budget can stop the scan.

♻️ Proposed refactor
     for j in candidates {
+        if crate::budget::check().is_err() {
+            // No opinion rather than a slow answer; the caller's budget owns the
+            // decision, and `None` never claims "no pole".
+            return None;
+        }
         let mut m = HashMap::new();
         m.insert(k, pool.integer(j));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/sum/mod.rs` around lines 326 - 338, Add a cooperative
budget check inside the candidate loop over `candidates`, using the existing
`crate::budget::check` mechanism as used by the series code, so each expensive
`subs`/`simp` iteration can stop when the caller’s budget is exhausted. Preserve
candidate ordering, deduplication, and the existing `Some(j)` result behavior.
alkahest-core/src/calculus/series.rs (1)

381-393: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the E-SERIES-003 remediation to the error reference. The entry describes the refusal but does not state the user action returned by SeriesRefusal::remediation(): ask for a lower order, raise the budget, or rewrite the expression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/calculus/series.rs` around lines 381 - 393, Add an
E-SERIES-003 entry to the error reference documenting the remediation returned
by SeriesRefusal::remediation(): ask for a lower order, raise the budget, or
rewrite the expression so repeated derivatives close. Keep the existing refusal
description and align the wording with the remediation string.

Source: Coding guidelines

alkahest-core/src/simplify/rules.rs (1)

1420-1423: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Preserve expansion-limit details in the derivation step.

take_expand_limits records (node, exponent, summands), but expand_limit_log discards the latter two values. Extend the derivation-step representation and pass both values through so .steps identifies the bound that stopped expansion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/simplify/rules.rs` around lines 1420 - 1423, Update the
derivation-step representation and the expand-limit handling around
take_expand_limits and expand_limit_log to retain and propagate both exponent
and summands instead of discarding them. Ensure generated .steps entries
identify the expansion bound that prevented expansion.
alkahest-core/src/solver/verify.rs (2)

219-230: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Assert the non-negativity precondition of clamp_nonneg.

If b.hi() is negative, this function returns an ArbBall with a negative rad. Every current call site passes a quantity that is non-negative by construction, so the case does not occur. A negative radius would silently break every later separation test, so enforce the stated precondition in debug builds.

♻️ Proposed refactor
 fn clamp_nonneg(b: ArbBall) -> ArbBall {
     if b.lo() >= 0 {
         return b;
     }
     let hi = b.hi();
+    debug_assert!(hi >= 0, "clamp_nonneg requires a ball whose upper end is non-negative");
     let mid = rug::Float::with_val(VERIFY_PREC, &hi / 2u32);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/solver/verify.rs` around lines 219 - 230, Add a debug-build
assertion at the start of clamp_nonneg validating that b.hi() is non-negative,
while preserving the existing return and clamping behavior for valid inputs.

155-181: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Compute im_is_exact_zero once.

Lines 155 and 174 evaluate the same predicate on the same fields. Hoist it above the fast path.

♻️ Proposed refactor
     fn sqrt(&self) -> Option<CBall> {
+        // `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;
         // A real argument keeps the result on one axis *exactly*, which is what
         // preserves the distinction between the two roots further up.
-        let im_is_exact_zero = self.im.is_exact() && self.im.mid_f64() == 0.0;
         if im_is_exact_zero {
@@
         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 {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/solver/verify.rs` around lines 155 - 181, Compute the
im_is_exact_zero predicate once before the fast-path condition in the
square-root logic, then reuse that binding for the later imaginary-part
selection. Remove the duplicate declaration while preserving the existing
branches and behavior.
alkahest-core/src/solver/polyhedral.rs (1)

395-403: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Track the dead mixed-cell enumeration explicitly.

The documentation now states that polyhedral_cell_iter returns an empty list for every input. The whole polyhedral branch in solve_numerical is therefore unreachable work: it computes hulls and edges on every call and always falls back. The refusal-and-fallback behavior is correct, so this is not a defect. Recording it as a tracked limitation keeps the dead branch from being read as working code.

Do you want me to open an issue for the mixed-cell criterion so the fallback can be removed later?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/solver/polyhedral.rs` around lines 395 - 403, Track the
known limitation in the polyhedral solver by adding an explicit TODO or issue
reference near polyhedral_cell_iter and its use in solve_numerical. Document
that mixed-cell enumeration currently yields no starts, so solve_numerical
intentionally retains the Bézout fallback; do not alter the refusal-and-fallback
behavior.
alkahest-core/src/poly/resultant.rs (2)

411-429: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Enforce the documented degree precondition.

Line 426 subtracts qc.len() from pc.len(). The precondition deg(p) >= deg(q) holds today because subresultant_prs swaps first, so this is not a current defect. If a later caller skips the swap, the subtraction underflows: a debug build panics, and a release build wraps to a huge u32 that makes rug_pow allocate without bound. Make the precondition executable.

🛡️ Proposed guard
     // `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);
     }
+    // Documented precondition, made executable: a caller that skips the
+    // canonical swap would otherwise underflow the exponent below.
+    let deg_gap = pc.len().checked_sub(qc.len())?;
 
     // 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 s = rug_pow(&qc[qc.len() - 1], deg_gap as u32);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/poly/resultant.rs` around lines 411 - 429, Enforce the
deg(p) >= deg(q) precondition at the start of sprs_inner before computing
pc.len() - qc.len(). Return None for inputs where p has lower degree than q,
while preserving the existing zero/constant-q handling and valid resultant
sequence behavior.

321-331: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use rug::Integer::pow for integer exponentiation.

Import rug::ops::Pow and replace the loop with base.clone().pow(exp). This uses rug 1.30.0’s Pow<u32> implementation and avoids exp - 1 sequential multiplications.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/poly/resultant.rs` around lines 321 - 331, Update rug_pow
to import rug::ops::Pow and replace the manual exponentiation loop with
base.clone().pow(exp), preserving the existing non-negative u32 exponent
behavior and zero-exponent result.
alkahest-core/src/ideal/primary.rs (1)

776-797: 🚀 Performance & Scalability | 🔵 Trivial

Measure the Gröbner basis cost of the certified radical path.

radical_zero_dimensional computes one GRevLex basis at Line 787 and up to n further Lex bases through eliminant_in_var at Line 810. decompose_recursive calls radical_direct at Line 564 on every leaf that reaches the maximal-radical attempt, and that recursion runs to MAX_SPLIT_DEPTH = 48 through the saturation split, which computes several bases per variable itself.

The cost is inherent to the algorithm, and the refusal contract is the right trade. Two operational suggestions:

  • Record the basis-computation count or wall time for the certified paths, so a regression in the CI gate is attributable.
  • Consider threading the GRevLex basis and the per-variable eliminants through the recursion, so a repeated leaf does not recompute them.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/ideal/primary.rs` around lines 776 - 797, Instrument the
certified path in radical_zero_dimensional to record Gröbner-basis computation
count or wall time, including the initial GRevLex computation and each
eliminant_in_var computation, using the project’s existing measurement
mechanism. Ensure measurements cover calls reached through radical_direct and
decompose_recursive so CI can attribute regressions to repeated leaf work. Reuse
already computed bases or eliminants across recursion only if the existing
interfaces support it without changing the refusal contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 169-176: Restrict workflow permissions by adding a top-level
permissions block granting only contents: read, and update the checkout step in
the asan job to disable persisted credentials via persist-credentials: false.
Apply the checkout setting to any other checkout steps that do not require
authenticated Git commands.
- Around line 532-535: Update the Valgrind pipeline in the CI workflow to
preserve the analyzer’s exit status instead of allowing tee to mask failures.
Enable pipefail before the valgrind/tee command, or capture PIPESTATUS[0]
immediately afterward and exit with that status; keep the existing output
capture and Valgrind arguments unchanged.

In @.github/workflows/cuda_nightly.yml:
- Around line 98-104: Ensure the “Run compute-sanitizer racecheck” workflow step
explicitly sets continue-on-error to false so racecheck and sanitizer startup
failures fail the workflow.

In `@alkahest-core/src/simplify/rules.rs`:
- Around line 1473-1475: Update the expansion guard around expansion_products
and MAX_EXPAND_POW_EXP so the product budget is enforced independently of the
compatibility exponent floor. Preserve acceptance for exponents at or below
MAX_EXPAND_POW_EXP only when expansion_products(summands.len(), n_u32) remains
within MAX_EXPAND_POW_PRODUCTS, preventing wide bases from triggering unbounded
distribute_once work.

In `@docs/mdbook/src/errors.md`:
- Around line 94-101: Update the errors documentation section to identify the
Python-facing exception as AlkahestError rather than the Rust-side
PrimaryDecompositionError or IdealRefusal, and document codes E-IDEAL-005 and
E-IDEAL-006 with their .code, .remediation, and .span fields. Remove any claim
that these calls raise an uncoded ValueError, while noting that AlkahestError
remains catchable as ValueError.

In `@docs/mdbook/src/telescoping.md`:
- Around line 25-32: In the telescoping derivation, define the summation
endpoints k_lo and k_hi before introducing the recurrence, including any
dependence on n. Update the shifted-sum argument to account for n-dependent
endpoints via zero-extension or endpoint corrections, and evaluate both boundary
terms G(n, k_hi+1) and G(n, k_lo) before concluding that their difference
vanishes.

---

Outside diff comments:
In `@alkahest-core/src/simplify/engine.rs`:
- Around line 376-405: Use an invocation-scoped expansion-limit collector:
update alkahest-core/src/simplify/engine.rs lines 376-405 to isolate each
top-level simplify invocation while preserving outer events across nested calls,
and update alkahest-core/src/simplify/redex.rs lines 110-129 so ExpandPow worker
events are transferred into the current pass log rather than relying only on
caller-thread expand_limit_log() draining. Ensure outer invocation events are
not cleared or drained by nested simplify calls.

---

Minor comments:
In `@alkahest-core/src/solver/mod.rs`:
- Around line 72-75: Update the documentation for the Finite variant to qualify
its substitution and refutation guarantee as applying only to parameter-free
tuples. Make clear that tuples containing parameters may be returned unverified,
while preserving the existing guarantee for fully concrete solutions.

In `@alkahest-core/src/solver/verify.rs`:
- Around line 117-124: Update powi to use checked_neg when handling negative
exponents, returning None when negation is unavailable for i64::MIN so
eval_uncached can report VerifyGap::Undefined instead of recursing. Preserve the
existing reciprocal flow for representable negative exponents.

In `@alkahest-core/src/sum/mod.rs`:
- Around line 172-184: Update the explanatory comment above
interior_undefined_index to describe poles at any index within the inclusive
summation bounds, including lo and hi, rather than only poles strictly between
them. Keep the guard and error behavior unchanged, and retain the distinction
that the summand itself must be checked because telescoping can hide undefined
terms.

In `@alkahest-core/tests/groebner_cuda.rs`:
- Around line 75-84: Remove the assert!(backend.fell_back_to_cpu(), ...) check
from assert_no_gpu. Keep the requested_device, reductions_on_gpu,
first_gpu_error, and ran_on_gpu assertions unchanged; do not replace the removed
assertion with another unconditional condition.

In `@alkahest-py/src/lib.rs`:
- Around line 10614-10622: Clear the captured solve-side-condition state when
the numeric fallback handles SolverError::HighDegree and proceeds to homotopy,
before returning the numeric points. Update the fallback logic surrounding
solve_polynomial_system and preserve captured conditions for successful symbolic
results and non-fallback errors.
- Around line 3611-3629: Update the Sphinx error reference in errors.rst to
document E-RESIDUE-005, matching the existing description and guidance for
residue point validation already present in the other error documentation. Keep
the entry aligned with the surrounding error-code entries.

In `@docs/mdbook/src/codegen.md`:
- Around line 164-176: Update the CUDA example around compile_cuda to bind the
alkahest module before calling alkahest.capabilities(), either by importing
alkahest or importing capabilities directly. Preserve the existing feature-guard
behavior and CUDA compilation flow.

In `@docs/mdbook/src/gpu.md`:
- Around line 100-115: Update the device_count helper to avoid interpreting
every ak.CudaError from call_batch_on as an invalid ordinal; restrict
termination to an ordinal-specific error if available, or revise the
documentation and helper semantics to clearly identify the result as a
best-effort probe rather than an authoritative device count.
- Around line 53-58: Update the CUDA capability description near the `cuda ==
True` statement to remove the promise that PTX can be emitted on the host. Keep
the guarantee limited to the presence of `ak.compile_cuda` and
`ak.CudaCompiledFn`, and preserve the existing distinction between capability
linkage and runtime GPU availability.

In `@docs/mdbook/src/telescoping.md`:
- Around line 62-70: Complete the boundary-check example after the existing
g_at_lo calculation by evaluating cert.boundary_term at k_hi + 1, then compare
the upper and lower boundary values to verify their difference is zero. Keep the
example aligned with the documented G(n, k_hi+1) - G(n, k_lo) check and use the
existing symbols cert.boundary_term, k_hi, and k_lo.

In `@docs/sphinx/api/errors.rst`:
- Around line 170-182: Update the refusal-code index at the section around the
existing lines 50–51 to include E-SOLVE-004, E-IDEAL-005, and E-IDEAL-006. Keep
the existing index structure and descriptions consistent with the refusal
entries documented near triangularize, radical, and primary_decomposition.
- Around line 170-182: Synchronize the refusal documentation with binding
behavior: add E-IDEAL-005, E-IDEAL-006, and E-SOLVE-004 to the Sphinx refusal
list, update the note describing radical and primary_decomposition to match the
binding-exposed refusal records, and revise the corresponding stale mdBook
paragraph. Preserve the documented code meanings and reference the existing
take_ideal_refusal() and take_triangularize_refusal() symbols.

In `@python/alkahest/__init__.py`:
- Around line 384-388: Update the native overlay logic that handles the
unconditionally registered CudaError to also rebind
alkahest.exceptions.CudaError to the native exception class, keeping the
package-level CudaError binding synchronized across CPU-only and CUDA builds.

In `@python/alkahest/exceptions.py`:
- Around line 60-63: Update the pure-Python fallback assignment for
_NativeAlkahestError in exceptions.py to use ValueError instead of Exception,
preserving the native AlkahestError inheritance contract so Python-raised
wrappers remain catchable by except ValueError.

In `@tests/silent_errors/corpus.py`:
- Around line 631-639: Update the exception handling in op so KeyboardInterrupt
and SystemExit are re-raised before the broad BaseException conversion; preserve
converting other BaseException subclasses, including PanicException, into
RuntimeError.
- Around line 812-824: Update the side-condition check in op to use a
regular-expression word-boundary match for hypothesis_about rather than
substring containment, and add the required re import. Preserve the stated path
only when a complete identifier/word match is present.

In `@tests/test_agent_contract.py`:
- Around line 174-177: Update the CUDA reachability check in the features
assertion to consider only compile_cuda and CudaCompiledFn, matching the
entry-point set used by tests/test_cuda.py. Remove CudaError from this tuple and
leave its validation to the dedicated CUDA test.

---

Nitpick comments:
In `@alkahest-core/src/calculus/series.rs`:
- Around line 381-393: Add an E-SERIES-003 entry to the error reference
documenting the remediation returned by SeriesRefusal::remediation(): ask for a
lower order, raise the budget, or rewrite the expression so repeated derivatives
close. Keep the existing refusal description and align the wording with the
remediation string.

In `@alkahest-core/src/ideal/primary.rs`:
- Around line 776-797: Instrument the certified path in radical_zero_dimensional
to record Gröbner-basis computation count or wall time, including the initial
GRevLex computation and each eliminant_in_var computation, using the project’s
existing measurement mechanism. Ensure measurements cover calls reached through
radical_direct and decompose_recursive so CI can attribute regressions to
repeated leaf work. Reuse already computed bases or eliminants across recursion
only if the existing interfaces support it without changing the refusal
contract.

In `@alkahest-core/src/poly/resultant.rs`:
- Around line 411-429: Enforce the deg(p) >= deg(q) precondition at the start of
sprs_inner before computing pc.len() - qc.len(). Return None for inputs where p
has lower degree than q, while preserving the existing zero/constant-q handling
and valid resultant sequence behavior.
- Around line 321-331: Update rug_pow to import rug::ops::Pow and replace the
manual exponentiation loop with base.clone().pow(exp), preserving the existing
non-negative u32 exponent behavior and zero-exponent result.

In `@alkahest-core/src/simplify/rules.rs`:
- Around line 1420-1423: Update the derivation-step representation and the
expand-limit handling around take_expand_limits and expand_limit_log to retain
and propagate both exponent and summands instead of discarding them. Ensure
generated .steps entries identify the expansion bound that prevented expansion.

In `@alkahest-core/src/solver/polyhedral.rs`:
- Around line 395-403: Track the known limitation in the polyhedral solver by
adding an explicit TODO or issue reference near polyhedral_cell_iter and its use
in solve_numerical. Document that mixed-cell enumeration currently yields no
starts, so solve_numerical intentionally retains the Bézout fallback; do not
alter the refusal-and-fallback behavior.

In `@alkahest-core/src/solver/verify.rs`:
- Around line 219-230: Add a debug-build assertion at the start of clamp_nonneg
validating that b.hi() is non-negative, while preserving the existing return and
clamping behavior for valid inputs.
- Around line 155-181: Compute the im_is_exact_zero predicate once before the
fast-path condition in the square-root logic, then reuse that binding for the
later imaginary-part selection. Remove the duplicate declaration while
preserving the existing branches and behavior.

In `@alkahest-core/src/sum/mod.rs`:
- Around line 326-338: Add a cooperative budget check inside the candidate loop
over `candidates`, using the existing `crate::budget::check` mechanism as used
by the series code, so each expensive `subs`/`simp` iteration can stop when the
caller’s budget is exhausted. Preserve candidate ordering, deduplication, and
the existing `Some(j)` result behavior.

In `@alkahest-py/src/lib.rs`:
- Around line 9589-9623: Update PyCudaCompiledFn::eval_on_device to wrap the
CUDA module-loading and kernel-launch call to self.inner.call_batch_on in
Python::allow_threads, keeping the owned input and output buffers outside the
closure as needed and preserving existing PyCudaError conversion after the call.

In `@tests/silent_errors/corpus.py`:
- Around line 1744-1747: Update the solution_count call to reuse the shared _Z
symbol for the third variable and its squared expression, replacing both inline
POOL.symbol("z") constructions while leaving the existing X and Y arguments
unchanged.

In `@tests/test_cuda.py`:
- Around line 79-84: Update the CUDA availability probe around ak.compile_cuda
and fn.call_batch to catch only the established driver/device-related exception
types and return the unavailable-device message for those cases. Allow all other
exceptions, including binding regressions, to propagate instead of classifying
them as an unavailable CUDA device.

In `@tests/test_parametric_solve.py`:
- Around line 112-121: The side-condition tests rely on process-global state
written by solve, so mark the affected tests as non-parallel-safe or add a
concise comment documenting that requirement. Apply this to
test_hypotheses_do_not_leak_from_an_earlier_solve and the other tests asserting
ak.solve_side_conditions() across separate solve calls.

In `@tests/test_residue.py`:
- Around line 96-110: Use a single consistent call form in
test_deeply_nested_polynomial_point_still_refused_cleanly, replacing either the
imported residue call or ak.residue call so both assertions invoke the same
symbol. If the successful residue evaluation over the 300-level expression is
noticeably slow, add the existing pytest slow marker to this test so the default
test suite excludes it.

In `@tests/test_series_v215.py`:
- Around line 124-132: Replace the string-based `"x^23"` assertion in
test_ordinary_high_order_series_still_expands with a structural node-tree
assertion, reusing the existing traversal approach from _has_big_o to verify
that the expansion contains the highest odd power x^23 without depending on
printer formatting.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c4c1926b-8ab2-46b9-a2e0-32befda8238c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e3eabb and 4130506.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (66)
  • .github/workflows/alkahest-semver-check.yml
  • .github/workflows/ci-cross.yml
  • .github/workflows/ci.yml
  • .github/workflows/codspeed.yml
  • .github/workflows/cuda_nightly.yml
  • .github/workflows/docs.yml
  • .github/workflows/release-build.yml
  • CHANGELOG.md
  • Cargo.toml
  • README.md
  • alkahest-core/src/calculus/euler_maclaurin.rs
  • alkahest-core/src/calculus/limits.rs
  • alkahest-core/src/calculus/mod.rs
  • alkahest-core/src/calculus/series.rs
  • alkahest-core/src/errors/codes.rs
  • alkahest-core/src/holonomic/mod.rs
  • alkahest-core/src/holonomic/zeilberger.rs
  • alkahest-core/src/ideal/mod.rs
  • alkahest-core/src/ideal/primary.rs
  • alkahest-core/src/integrate/risch/tower_integrate.rs
  • alkahest-core/src/lattice/lll.rs
  • alkahest-core/src/lib.rs
  • alkahest-core/src/poly/groebner/cuda.rs
  • alkahest-core/src/poly/groebner/mod.rs
  • alkahest-core/src/poly/resultant.rs
  • alkahest-core/src/primitive/mod.rs
  • alkahest-core/src/simplify/engine.rs
  • alkahest-core/src/simplify/parallel.rs
  • alkahest-core/src/simplify/redex.rs
  • alkahest-core/src/simplify/rules.rs
  • alkahest-core/src/solver/homotopy.rs
  • alkahest-core/src/solver/mod.rs
  • alkahest-core/src/solver/polyhedral.rs
  • alkahest-core/src/solver/regular_chains.rs
  • alkahest-core/src/solver/verify.rs
  • alkahest-core/src/sum/mod.rs
  • alkahest-core/src/sum/product.rs
  • alkahest-core/src/sum/recurrence.rs
  • alkahest-core/src/sum/rsolve.rs
  • alkahest-core/tests/groebner_cuda.rs
  • alkahest-py/Cargo.toml
  • alkahest-py/src/lib.rs
  • alkahest-skill/alkahest.md
  • docs/features.md
  • docs/mdbook/src/SUMMARY.md
  • docs/mdbook/src/codegen.md
  • docs/mdbook/src/errors.md
  • docs/mdbook/src/getting-started.md
  • docs/mdbook/src/gpu.md
  • docs/mdbook/src/interop.md
  • docs/mdbook/src/solving.md
  • docs/mdbook/src/telescoping.md
  • docs/sphinx/api/errors.rst
  • pyproject.toml
  • python/alkahest/__init__.py
  • python/alkahest/exceptions.py
  • tests/silent_errors/corpus.py
  • tests/test_agent_contract.py
  • tests/test_batch_workload.py
  • tests/test_cuda.py
  • tests/test_parametric_solve.py
  • tests/test_primary_decomposition_v212.py
  • tests/test_regular_chains_v211.py
  • tests/test_residue.py
  • tests/test_series_v215.py
  • tests/textbook_gate/test_tg_solve.py

Comment thread .github/workflows/ci.yml
Comment on lines +169 to +176
asan:
name: AddressSanitizer — alkahest-cas
runs-on: ubuntu-latest
if: github.event_name != 'schedule'
timeout-minutes: 60

steps:
- uses: actions/checkout@v6

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict token access in the ASan job.

This job executes PR-controlled Cargo build scripts after checkout. The workflow has no explicit permissions block, and actions/checkout persists its token by default. An internal PR can read and use that credential.

Add workflow-level permissions: contents: read. Set persist-credentials: false for checkout steps that do not need authenticated Git commands.

🧰 Tools
🪛 zizmor (1.29.0)

[warning] 176-176: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[warning] 169-219: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 169 - 176, Restrict workflow
permissions by adding a top-level permissions block granting only contents:
read, and update the checkout step in the asan job to disable persisted
credentials via persist-credentials: false. Apply the checkout setting to any
other checkout steps that do not require authenticated Git commands.

Source: Linters/SAST tools

Comment thread .github/workflows/ci.yml
Comment on lines 532 to +535
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- shell declarations ---'
rg -n '(^|[[:space:]])shell:|pipefail|valgrind|vg-out' .github/workflows/ci.yml
printf '%s\n' '--- workflow lines 480-550 ---'
sed -n '480,550p' .github/workflows/ci.yml
printf '%s\n' '--- workflow/job headers ---'
sed -n '1,230p' .github/workflows/ci.yml

Repository: alkahest-cas/alkahest

Length of output: 14680


🌐 Web query:

GitHub Actions default shell run step Ubuntu bash pipefail official documentation

💡 Result:

In GitHub Actions, the shell behavior for run steps on Ubuntu (and other non-Windows runners) depends on whether you explicitly specify the shell [1][2]. When the shell is not explicitly specified (unspecified), GitHub Actions uses a default shell that is functionally equivalent to bash -e {0} [1][2][3]. This command includes the -e flag (errexit), which ensures the step fails if any command returns a non-zero exit code [3], but it does not include pipefail by default [3][4]. When you explicitly specify shell: bash, GitHub Actions executes the step using the command bash --noprofile --norc -eo pipefail {0} [1][2]. This explicitly includes the pipefail option, which ensures that the exit status of a pipeline is the value of the last (rightmost) command to exit with a non-zero status, or zero if all commands in the pipeline exit successfully [5]. Summary of behavior for Ubuntu/Linux runners: 1. Unspecified shell: Uses bash -e {0}. It has fail-fast behavior (-e), but does not have pipefail enabled [1][3][4]. 2. Explicit shell: bash: Uses bash --noprofile --norc -eo pipefail {0}. This includes both -e and pipefail [1][2][5]. If you require pipefail behavior in your workflow, you should explicitly set shell: bash in your step or job defaults [6][4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- bash pipeline status without pipefail ---'
bash -c 'set -e; printf "valgrind failure\n" >&2; false | tee /tmp/coderabbit-vg-out.txt; printf "step continued\n"' 2>&1
printf 'exit=%s\n' "$?"

printf '%s\n' '--- bash pipeline status with pipefail ---'
bash -c 'set -e -o pipefail; printf "valgrind failure\n" >&2; false | tee /tmp/coderabbit-vg-out.txt; printf "step continued\n"' 2>&1
printf 'exit=%s\n' "$?"

printf '%s\n' '--- explicit Bash PIPESTATUS handling ---'
bash -c 'set -e; false | tee /tmp/coderabbit-vg-out.txt; status=${PIPESTATUS[0]}; printf "valgrind_status=%s\n" "$status"; exit "$status"' 2>&1
printf 'exit=%s\n' "$?"

Repository: alkahest-cas/alkahest

Length of output: 462


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- available sink ---'
command -v cat || true

printf '%s\n' '--- bash -e, no pipefail ---'
bash -c 'set -e; printf "test result: ok. 1 passed\n"; false | cat; printf "step continued\n"'
printf 'exit=%s\n' "$?"

printf '%s\n' '--- bash -e -o pipefail ---'
bash -c 'set -e -o pipefail; printf "test result: ok. 1 passed\n"; false | cat; printf "step continued\n"'
printf 'exit=%s\n' "$?"

printf '%s\n' '--- PIPESTATUS capture ---'
bash -c 'set -e; false | cat; status=${PIPESTATUS[0]}; printf "valgrind_status=%s\n" "$status"; exit "$status"'
printf 'exit=%s\n' "$?"

Repository: alkahest-cas/alkahest

Length of output: 385


Preserve the Valgrind failure status.

The default Ubuntu shell uses bash -e without pipefail, so tee can mask Valgrind’s non-zero exit status. Enable set -o pipefail before the pipeline, or capture ${PIPESTATUS[0]} immediately after it and exit with that status.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 532 - 535, Update the Valgrind
pipeline in the CI workflow to preserve the analyzer’s exit status instead of
allowing tee to mask failures. Enable pipefail before the valgrind/tee command,
or capture PIPESTATUS[0] immediately afterward and exit with that status; keep
the existing output capture and Valgrind arguments unchanged.

Comment on lines 98 to +104
- 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

file=".github/workflows/cuda_nightly.yml"
printf '%s\n' '--- workflow context ---'
sed -n '1,125p' "$file"

printf '%s\n' '--- all continue-on-error entries ---'
rg -n -C 3 'continue-on-error|racecheck|compute-sanitizer|ALKAHEST_GPU_TESTS' "$file"

printf '%s\n' '--- workflow metadata ---'
git diff --stat
git status --short

Repository: alkahest-cas/alkahest

Length of output: 8942


Make racecheck failures fail the workflow.

Set continue-on-error: false. Otherwise, racecheck failures and sanitizer startup failures can pass silently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cuda_nightly.yml around lines 98 - 104, Ensure the “Run
compute-sanitizer racecheck” workflow step explicitly sets continue-on-error to
false so racecheck and sanitizer startup failures fail the workflow.

Comment on lines +1473 to +1475
if n_u32 > MAX_EXPAND_POW_EXP
&& expansion_products(summands.len(), n_u32) > MAX_EXPAND_POW_PRODUCTS
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The exponent floor bypasses the product budget, so a wide base can still blow up.

The decline requires both conditions. When n_u32 <= MAX_EXPAND_POW_EXP, expansion_products is never consulted, so the product budget does not apply at all. The doc comment at lines 1369-1373 states this openly: (a₁+…+a₂₀)⁴ expands to 160 000 products. The same path accepts far worse. (a₁+…+a₁₀₀₀)⁴ is 10¹² products, and distribute_once pre-allocates acc.len() * summands.len() and interns every product into the pool, so the process exhausts memory before it returns. The exponent is bounded by 4 here, but the base width is not bounded at all.

The old bound had the same hole, so this is not a regression. It is worth closing on the line that introduces a product budget, because the stated purpose at lines 1360-1361 is that "the work is capped so a stray large literal exponent cannot trigger combinatorial blow-up". Keep the compatibility floor, and add a hard product ceiling above it so the floor cannot be used to request unbounded work.

🛡️ Proposed fix
+/// Product count above which [`ExpandPow`] declines whatever the exponent is.
+///
+/// The `MAX_EXPAND_POW_EXP` floor exists for backward compatibility, not because
+/// exponent ≤ 4 is cheap: `(a₁+…+a₁₀₀₀)⁴` is 10¹² products, and
+/// `distribute_once` pre-allocates and interns every one of them. This ceiling
+/// is set far above every shape the floor was kept for (a twenty-term sum to the
+/// fourth power is 160 000) so nothing that expanded before stops expanding.
+const HARD_EXPAND_POW_PRODUCTS: u64 = 1 << 21;
+
@@
-        if n_u32 > MAX_EXPAND_POW_EXP
-            && expansion_products(summands.len(), n_u32) > MAX_EXPAND_POW_PRODUCTS
-        {
+        let products = expansion_products(summands.len(), n_u32);
+        if (n_u32 > MAX_EXPAND_POW_EXP && products > MAX_EXPAND_POW_PRODUCTS)
+            || products > HARD_EXPAND_POW_PRODUCTS
+        {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if n_u32 > MAX_EXPAND_POW_EXP
&& expansion_products(summands.len(), n_u32) > MAX_EXPAND_POW_PRODUCTS
{
/// Product count above which [`ExpandPow`] declines whatever the exponent is.
///
/// The `MAX_EXPAND_POW_EXP` floor exists for backward compatibility, not because
/// exponent ≤ 4 is cheap: `(a₁+…+a₁₀₀₀)⁴` is 10¹² products, and
/// `distribute_once` pre-allocates and interns every one of them. This ceiling
/// is set far above every shape the floor was kept for (a twenty-term sum to the
/// fourth power is 160 000) so nothing that expanded before stops expanding.
const HARD_EXPAND_POW_PRODUCTS: u64 = 1 << 21;
let products = expansion_products(summands.len(), n_u32);
if (n_u32 > MAX_EXPAND_POW_EXP && products > MAX_EXPAND_POW_PRODUCTS)
|| products > HARD_EXPAND_POW_PRODUCTS
{
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alkahest-core/src/simplify/rules.rs` around lines 1473 - 1475, Update the
expansion guard around expansion_products and MAX_EXPAND_POW_EXP so the product
budget is enforced independently of the compatibility exponent floor. Preserve
acceptance for exponents at or below MAX_EXPAND_POW_EXP only when
expansion_products(summands.len(), n_u32) remains within
MAX_EXPAND_POW_PRODUCTS, preventing wide bases from triggering unbounded
distribute_once work.

Comment thread docs/mdbook/src/errors.md
Comment on lines +25 to +32
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`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

file="docs/mdbook/src/telescoping.md"
wc -l "$file"
ast-grep outline "$file" --lang markdown || true
sed -n '1,90p' "$file"
printf '\n--- related references ---\n'
rg -n -C 3 'k_lo|k_hi|boundary|zero|extension|correction|binomial|S\(n' docs/mdbook/src "$file"

Repository: alkahest-cas/alkahest

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- telescoping document, focused range ---'
sed -n '1,75p' docs/mdbook/src/telescoping.md

printf '%s\n' '--- zeilberger definitions and call sites ---'
rg -n -C 5 'def zeilberger|fn zeilberger|zeilberger\(' --glob '!docs/**' --glob '!target/**' .

Repository: alkahest-cas/alkahest

Length of output: 23707


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- tracked files mentioning the API ---'
git ls-files | rg '(^|/)(zeilberger|telescop|hyper|binomial|combin)' | head -80

printf '%s\n' '--- all relevant symbols, excluding documentation ---'
rg -n -C 4 'side_conditions|boundary_term|class .*Result|zeilberger' --glob '!docs/**' --glob '!target/**' --glob '!*.lock' . | head -300

Repository: alkahest-cas/alkahest

Length of output: 21777


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- core endpoint and boundary semantics ---'
sed -n '1,35p' alkahest-core/src/holonomic/zeilberger.rs
sed -n '118,145p' alkahest-core/src/holonomic/zeilberger.rs
sed -n '4550,4635p' alkahest-py/src/lib.rs

printf '%s\n' '--- exact boundary example test ---'
sed -n '510,575p' alkahest-core/src/holonomic/zeilberger.rs

Repository: alkahest-cas/alkahest

Length of output: 9670


Define the summation endpoints before deriving the recurrence. If an endpoint depends on n, the shifted sums require zero-extension or endpoint corrections. The boundary example must evaluate both G(n, k_hi+1) and G(n, k_lo), not only the lower endpoint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/mdbook/src/telescoping.md` around lines 25 - 32, In the telescoping
derivation, define the summation endpoints k_lo and k_hi before introducing the
recurrence, including any dependence on n. Update the shifted-sum argument to
account for n-dependent endpoints via zero-extension or endpoint corrections,
and evaluate both boundary terms G(n, k_hi+1) and G(n, k_lo) before concluding
that their difference vanishes.

`simplify_par_with_config` never drained the bounded-expansion record, so a
declined `ExpandPow` left no step in the log: the power came back unchanged
with nothing saying why. That is precisely the silent no-op the product
budget was added to prevent, reintroduced on the parallel path.

The record is thread-local and `apply_rules` runs on a rayon worker, so the
sequential trick of draining once per pass in the caller collects nothing —
the drain has to happen on whichever thread ran the rules. Reachable from
the public `simplify_par_with_config`; not from Python, whose `simplify_par`
uses the default config with `expand: false`.

The new test fails without the drain (verified by reverting it) and passes
with it, so it is not another gate that cannot fail.

Also: point the errors.md refusal section at what the bindings now do — it
still said `radical` / `primary_decomposition` raise an uncoded `ValueError`,
which stopped being true when the takers were wired up — and drop the CI
token to `contents: read`, since no job there touches repository state
while every one of them runs build scripts from the head of a PR.

Found by CodeRabbit on #296.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AregGevorgyan
AregGevorgyan merged commit a7b854d into main Aug 12, 2026
26 of 27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant