against the Rubi corpus
Companion to RISCH_PLAN.md (the transcendental Risch plan) and
BRONSTEIN_ERRATA.md. This file covers the experimental algebraic
track on branch risch-algebraic and is written to be picked up by a
fresh session.
Status: research and prototyping done, nothing implemented. Two work items are queued, in this order:
- Correctness testing against the Rubi corpus' expected antiderivatives (Aaron: "Correctness is the most important thing, so let's be thorough").
- Branch-correct radicand normalization via the signum algorithm.
Item 1 first: it is the instrument that measures item 2, and it will also re-audit everything already landed.
risch-gaps-- Phase 0, PR #30180. Tip at time of writingd01ca185c7.risch-rde-cancellation-- Phases 1-4 plus fixes; stacked onrisch-gaps. Tipbf88623d8e("Rewrite all computable residue terms via log_to_real() in the Risch code"). The last three commits are Aaron's RiobooLogToRealwork (real arc-tangents for complex-conjugate residue pairs) -- relevant here, since output form and branch behavior interact.risch-algebraic-- the experimental exp-log-tower representation of radicals; stacked onrisch-rde-cancellation. Tipa1c2226052. Nine commits: Aaron's two proof-of-concept commits, repairs, thealgebraic=Falsegate, the acceptance filter, the radicand normalization, and regression tests.
No PRs opened for the latter two branches. Aaron's rule: keep the branches sharing identical commits so merged work disappears from the others; rebase only when necessary.
risch_integrate(f, x, algebraic=True) represents radicals as exp-log
towers (sqrt(x) as exp(log(x)/2)), runs the transcendental
machinery over the (non-transcendental) tower, and degrades every
nonelementary conclusion to a plain Integral, since those proofs
assume transcendence. Off by default: ungated tower building sent the
integrals test suite from 90 s to a 10-minute timeout, because
integrate() attempts Risch automatically.
Results over the Rubi corpus (~16,800 attemptable radical cases):
about 1,357 integrals solved that non-Risch integrate() cannot do,
2,771 solved by both, zero false nonelementary claims. Timing: solved
cases have a 0.09-0.26 s median; roughly 2% of cases exceed 300 s, all
in bounded-arithmetic sites (no loops). Full details, per-case tables
and the run log-book:
https://gist.github.com/asmeurer/b4b8ceb7c364566f5e7a3d07ce133300
_nontrans_accept() in sympy/integrals/risch.py, called from
integrate_primitive() and integrate_hyperexponential() when
not DE.transcendental. It accepts a candidate only if
f == D(elem) + D(residue part) + iholds as a formal rational-function identity in the tower generators (decided bycancel(), on the polynomial representation -- never on the final Expr, per Aaron: sympy's Expr-level simplification cannot be trusted or afforded for algebraic expressions), and- no denominator (of
elem,i, or the residue terms' root polynomials and logarithm arguments) lies in the kernel of the evaluation map, decided by reduction modulo the tower's power relationst**q == u**p.
Check 2 was added after a backward-constructed test exposed a real hole; the corpus never triggers either check (400-case instrumented sample), so both are covered by constructed tests only.
Known limitation, and the reason for item 2 below: the filter certifies the candidate against the tower image of the integrand as the tower builder rewrote it. Any rewriting applied before the tower is built is outside its guarantee.
5779866b43 ("Factor radicands before erecting algebraic tower
generators") factors each radicand and distributes the fractional
power over the factors, so that content like a perfect square does not
become a spurious generator. Measured benefit: +387 SOLVED-NEW
cases corpus-wide (23.2% -> 25.6% overall solve rate).
This is an algebraic equivalence only where the factors are
positive. sqrt((x+1)**2*(x+2)) -> (x+1)*sqrt(x+2) drops an
absolute value and has the wrong sign for x < -1. Confirmed
numerically on the current branch:
risch_integrate(sqrt(x**2 + 2*x + 1)/x, x, algebraic=True)
-> sqrt(x**2 + 2*x + 1) + log(x) - 1
at x = -3: D(answer) = -1.3333 but f(-3) = -0.6667
and Timofeev's (x**3 - 5*x**2 + 3*x + 9)**(-2/3) (radicand
(x-3)**2*(x+1), one of the nine chapter-0 headline solves) is wrong
on all of x < 3, returning complex values where the integrand is
real.
Scope of the damage:
- The core machinery is not affected.
exp(log(u)/2)is identically the principalsqrt(u)on the cut plane, so the tower representation substitutes nothing; core-path answers spot-checked at negative and complex points are faithful. Only the pre-tower rewriting is at fault. - The regression test that pins the normalization uses
Symbol('y', positive=True), where the collapse is sound. The corpus failures are all on assumption-less symbols. - Continuity is a separate, milder matter: the core path inherits
sympy's principal-branch conventions (
logjumps), the same as stockintegrate(). Aaron's new Rioboo work improves output form here.
Interim options considered and rejected: gating the split on provable nonnegativity is sound but loses essentially all corpus gains (Rubi's symbols carry no assumptions); keeping generic-only validity contradicts sympy's assumption discipline. The signum algorithm below is strictly better than both.
The corpus is Upabjojr/rubi-integration-test-suite, cloned at
~/Documents/Python/sympy/rubi-integration-test-suite (a sibling of
the sympy checkout; not to be checked into sympy). Each
RubiTestSuiteCase carries integrand, variable, num_steps and
integral -- the expected antiderivative, which we have never
used. The runner is risch_test_suite_runner.py in that clone,
also proposed upstream as PR #1 there (opened as Claude, per Aaron).
Goal: use integral as an oracle to find answers that are potentially
wrong, then investigate each. Our answers legitimately differ in form,
so a mismatch is a signal to investigate, not a verdict.
Apply in order; stop at the first conclusive outcome.
- Difference is constant.
ours - expectedshould be constant. Test by differentiating and simplifying to zero, on the polynomial representation where possible. - Derivative check.
D(ours) - f == 0. Note this is what the internal filter already does at tower level; at Expr level it is unreliable for radicals (during this session a correct answer was flagged "WRONG" purely because Expr-level zero-testing of a radical identity failed -- exactly the reason Aaron insisted the internal check stay on the polynomial representation). - Numerical evaluation at many points -- the decisive test for
this class of bug, and the one that catches branch errors that
every symbolic check misses. Requirements:
- sample both signs of every factor that appears under a radical, and points on either side of each real root of those factors (the branch bug is invisible if you only sample where everything is positive);
- sample complex points as well as real ones;
- compare
D(ours)againstfpointwise, and separately checkours - expectedis constant across points within a connected region; - freeze
sign()factors locally before differentiating (they are locally constant; naive differentiation producesDiracDelta).
Rubi's a, b, c, ... are pattern variables with no assumptions,
whereas real sympy usage substitutes actual numbers. Both directions
matter and both must be tested:
- Instantiation testing. Replace the symbolic constants with random concrete values -- rational and irrational, positive and negative, and some complex -- then re-run the comparison ladder. This is the strongest available oracle: it turns a generically-valid formula into a checkable one, and it is precisely how a domain-dependent error (a dropped absolute value, a division by a constant that vanishes) becomes visible. Skip instantiations that make the integrand degenerate (a denominator identically zero).
- Assumption sensitivity. Run each case with the constants
assumption-free and again with
positive=True, and diff the outcomes. Divergence is informative in both directions: it flags results that silently depend on assumptions (our normalization is sound underpositive=Trueand wrong without), and it flags cases where sympy refuses to proceed without assumptions. - Special values. Related to the
Piecewise/condswork already documented inRISCH_PLAN.mditem 6: a result obtained by dividing by a symbolic constant is valid only generically. Instantiating at the vanishing values of those divisors is the test for it.
Every mismatch should be classified, not just counted:
- our answer genuinely wrong (branch, sign, dropped condition);
- our answer correct, different form (constant of integration,
algebraically equal, or
logvsatanforms -- especially now that Rioboo rewriting is in); - both correct on different domains (Rubi's conventions are real- oriented; sympy's are principal-branch);
- the corpus' own answer questionable (Rubi answers are not immune to
branch issues, and Rubi's
sqrtconventions differ from sympy's); - integrand degenerate under the chosen instantiation.
- Extend the runner with an
expected-comparison mode (keep it environment-variable driven, as withRISCH_RESULTS,RISCH_MODE,RISCH_HANDLE_FIRST, so the CLI stays compatible with the upstream runner) and push it to the same PR branch. - Run it over every case the algebraic mode currently solves
(~4,100
SOLVED-NEW+SOLVED-both), and over the transcendental chapters as a control -- this re-audits everything already landed, including the pre-normalization solves. - Publish the mismatch table (SymPy expression, our answer, expected answer, classification) as a new page in the run gist.
- File a regression test in sympy for every confirmed wrong answer.
- Jeffrey 1993, Integration to obtain expressions valid on domains
of maximum extent, ISSAC 93. §2 has the log-combining theory,
including the rule for fractional coefficients
a*ln f1 + b*ln f2 -> (m/n)*ln(f1**p * f2**q)(needed because combining under a fractional power reintroduces discontinuities). §5 works∫sqrt(x**(2/3) + x**(4/3)), whose correct antiderivative carriessgn**(5/3)factors -- our failure shape. - Jeffrey, Labahn, von Mohrenschildt & Rich, Integration of the signum, piecewise and related functions. §5 gives the complete algorithm; Theorem 5 gives the jump-correction formula; Example 1 is the case reproduced by the prototype below.
- Background:
numerical-analysis/Kahan 1986 - Branch Cuts for Complex Elementary Functions;theses/von Mohrenschildt 1994 - Symbolic Solutions of Discontinuous Differential Equations.
Rewrite sqrt(w**2 * v) as s*w*sqrt(v) where s = sgn(w) is
introduced as a symbolic constant, integrate as usual, then
- substitute
s -> sgn(w)in the result, and - add a jump correction
-J_k * sgn(x - x_k)at each breakpointx_k(each real root or pole ofw), whereJ_k = (G(x_k, s=+1) - G(x_k, s=-1))/2.
The corrections restore continuity across the breakpoints, giving answers on Jeffrey's "domain of maximum extent" -- better than merely correct. The approach fits this machinery unusually well because the signs ride through as ordinary symbolic constants, which the corpus runs already showed the towers handle at rates comparable to concrete coefficients.
signum_proto2.py, attached to the run gist. Results:
- Jeffrey's Example 1 reproduced exactly:
3*x**2*sqrt(1 + 1/x**2)->sgn(x)*((1 + x**2)**(3/2) - 1), withJ = 1. sqrt((x+1)**2*(x+2)):J = -4/15; derivative correct atx = -1.9, -1.5, -0.5, 3(the first two are where the current code is wrong), and the correction takes the discontinuity atx = -1from 0.533 to 0.sqrt(x**2 + 2*x + 1)/x: correct atx = -3and-1.5, the exact failing points.
- Replace the current unconditional split in
_rewrite_exps_pows()with the sign-carrying rewrite; record(s, w)pairs on theDifferentialExtension(a new slot, likebacksubs). - After integration, in
risch_integrate(), substitute the signs back and apply the jump corrections. Keep this outside the tower machinery -- it is a post-processing pass on the final result. - Breakpoints: real roots of numerator and denominator of each
w. - Where the sign of
wis provable (w.is_nonnegative), skip the whole apparatus and split unconditionally, as now.
- Complex jumps. When a breakpoint coincides with a singularity of
the integrand,
Jcomes out complex (oursqrt(x**2+2*x+1)/xcase givesJ = -1 + I*pi). Theorem 7 of the signum paper covers the integrable-singularity case; decide what to return when the jump is genuinely infinite. - Odd-order radicals. Cube roots need the
sgn**(2/3)treatment of Jeffrey 1993 §5 -- this is the Timofeev case, and sympy's principal-branch cube root of a negative real is complex while Rubi's is real. Decide the convention explicitly. - Non-polynomial sign arguments. Breakpoint detection needs the
real roots of arbitrary
w; restrict to whatroots/real_rootscan do and fall back to leaving the case unsplit. - Interaction with the acceptance filter. With signs as symbolic
constants, the filter's kernel test must not treat
sas an ordinary constant that could vanish --s**2 == 1should be part of the relation set. - Output size.
sgnfactors multiply through; consider collecting them (sgn(w)*Grather than distributing) for readable answers.
- Corpus clone:
~/Documents/Python/sympy/rubi-integration-test-suite(MIT; Rubi-derived content under Albert Rich's MIT license). Runner:risch_test_suite_runner.pythere; upstream PR Upabjojr/rubi-integration-test-suite#1 (opened as Claude at Aaron's direction; Aaron meets Francesco Bonazzi regularly if questions come up). - Run log-book gist (append each run as a new section, attach raw logs): https://gist.github.com/asmeurer/b4b8ceb7c364566f5e7a3d07ce133300
RISCH_PLAN.mdgist: https://gist.github.com/asmeurer/bed00aa257ef69b2688bbc9333da9a0dBRONSTEIN_ERRATA.mdgist: https://gist.github.com/asmeurer/eb4c9d3e6253372d2b3e1bd4438721d3- This file's gist: https://gist.github.com/asmeurer/5ad380f4ed1cdd573e6328ffd0d8a947
- All four files are untracked in the repo and mirrored to gists; update the gist in the same turn as any edit. The session scratchpad was wiped once mid-session and only the gist copies survived, so the gists are the durable location, not the working tree.
- Never point long-running jobs at the live checkout. Aaron works
in it and switches branches; a mid-run checkout silently invalidated
a whole timing study (every call raised
TypeErrorin 0.00 s, which looked like instant success). Usegit worktree add <scratchpad>/wt-<name> <branch>and run against that. Remove worktrees when done so Aaron can check the branch out. - Timing claims come from serial runs only; parallel sweeps inflate timeouts through contention.
- Sanity-check measurements: 0.00 s medians mean the code did not run.
- Tests:
python -m pytest -p no:pudb <files> -q(the pytest-pudb plugin is broken). Lint:ruff check sympy/integrals/. - Commit style: no comments about past code state or session context
(that belongs in the commit message); known sympy idioms need no
comment; keep fixes minimal; fix bugs at their source rather than
working around them in a caller. No
Claude-Sessiontrailers; keepCo-Authored-By. - Commits are SSH-signed through Secretive and need Aaron's approval touch; a persistent monitor that retries commit-and-push is the smoothest way to handle it.
Five in sympy master: the spde() infinite loop for SymPy Integer
degree bounds; malformed Polys from
is_log_deriv_k_t_radical_in_field(); ratint_logpart()'s
PolynomialError for radical coefficients (issue #26502, partially
fixed -- octic cases still crash); PolyMatrix scalar multiplication
with a ground-domain fallback; plus the bound_degree Poly-vs-int
family from Phase 0. Two in the algebraic branch itself, and one hole
in the acceptance filter. Expect item 1 to find more.