Skip to content

Return exact roots from the rule-based backend - #141

Open
s-celles wants to merge 17 commits into
JuliaSymbolics:mainfrom
s-celles:fix/exact-roots-in-rules
Open

s-celles wants to merge 17 commits into
JuliaSymbolics:mainfrom
s-celles:fix/exact-roots-in-rules

Conversation

@s-celles

@s-celles s-celles commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Finishes #135: the rule-based half, which is what the default two-argument integrate uses and what produced the output quoted in that issue.

Important

Depends on #136. This branch merges it, so the diff here includes that PR's commits; review or merge #136 first. The dependency is rubi_sqrt, which builds its radical with the same simplify(term(sqrt, y)) construction that #136 introduces in to_symb(::QQBarFieldElem), and which therefore needs the SymbolicUtils = "4.46.8" compat bound #136 sets. Once #136 lands, this PR's own diff is the two src/methods/rule_based/ files, its test file, the Apostol expected values and two baseline entries.

Result

integrate(1/(x^2 - 2), x)             # before: -0.7071067811865475atanh(x / 1.4142135623730951)
                                      # after:  (-atanh(x / sqrt(2))) / sqrt(2)
integrate(1/(x^2 + 2), x)             # after:  atan(x / sqrt(2)) / sqrt(2)
integrate(1/(cos(x) + sin(x)), x)     # after:  (-atanh((cos(x) - sin(x)) / sqrt(2))) / sqrt(2)
integrate(sqrt(3 - x^2), x)           # after:  (3//2)*asin(x / sqrt(3)) + (1//2)*x*((3 - x^2)^(1//2))

TEST_GROUP=difficult: RuleBased goes from 92 to 93 successes with no regressions, and the [Rule Based] Integration of 177 functions testset now passes. Risch is untouched (57 succeeded / 77 failed / 40 maybe failed / 3 errored, the same as main; those 3 errors are the pre-existing #139 failures). TEST_GROUP=easy passes.

Three pieces, needed together

1. Rewrite sqrt on the replacement side of each rule. Rules are a Pair{Expr, Expr}, and rule2.jl evals the replacement once a rule fires — so a literal sqrt(3) there is Julia's sqrt on an Int and returns a float. The AST of rule.second is rewritten to rubi_sqrt, and rt(u, 2) routed through it.

Only rule.second is rewritten, and that matters: sqrt also appears in match sides such as 1/sqrt((~a) + (~b)*(~x)^2), and check_expr_r compares those by name (rule.args[1] === :sqrt, operation(data) === sqrt). Renaming sqrt across the rule files would silently stop every integrand containing a square root from matching. sqrt(1 + x) and 1/sqrt(x^2 + 1) still integrate correctly here.

2. Let the sign predicates decide on a constant. pos, gt, ge, lt and le gave up as soon as an argument was symbolic. An exact sqrt(2) is symbolic but still denotes a number, so pos fell through to its return true default and rules took the branch for the wrong sign:

pos(sqrt(2)^2 - 4)   # true before (wrong), false now
neg(sqrt(2)^2 - 4)   # false before (wrong), true now

constant_value evaluates an expression that denotes a number, which keeps the form exact while restoring the decisions that were taken when these constants were floats. pos(x) and friends are unchanged for genuinely symbolic arguments.

3. Let the matcher bind a defslot to a product of constants. This was the real blocker. A numeric coefficient folds into a single factor; an exact radical does not:

arguments(-1.4142135623730951x)  # [-1.4142135623730951, x]   2 args
arguments(-x)                    # [-1, x]                    2 args
arguments(-sqrt(2)*x)            # [-1, x, sqrt(2)]           3 args

So a pattern like 1/((~!a) + (~!b)*(~x) + (~!c)*(~x)^2) failed on length alone, and 1/(1 - sqrt(2)*x + x^2) matched no rule at all — which is why 1/(x^4 + 1) came back with an unresolved integral inside it. The retry collapses the constant factors into one, so a slot can bind the whole coefficient. It is reached only after the equal-length match has failed, so it can create a match but never break one — the same shape as the existing fallbacks in check_expr_r.

Test changes

Four expected values in Apostol Problems.jl were floats, because sqrt(3) written there is Julia's sqrt on an Int. A result that is now exact could not compare equal to them, so they are written with the exact value, wrapped in Num so that the harness comparison against 0 holds. The integrands are untouched.

test/difficult_baseline.jl is tightened for the one entry this improves, (x^4)/(4 + 5x^2 + x^4), which now verifies symbolically. Entry 132 is left at its tolerant value — its comment explains that is deliberate for platform variation — and the Risch entries are left alone, since they belong to #139.

Worth knowing

Symbolics.simplify folds exact radicals back into floats inconsistently (SymbolicUtils#1079), to the point where simplify(a - b) can report a nonzero residual for two structurally identical expressions containing sqrt(2). That is why the expected values had to become exact rather than the comparison being loosened. Results here are exact in the cases above, but a few still mix an exact radical with a float elsewhere in the expression; that is bounded by the same upstream behaviour.


Disclosure: this change was written with AI assistance, and tested locally before opening the PR. TEST_GROUP=easy and TEST_GROUP=difficult were run on this branch and against unmodified main for comparison, the per-integral baseline diff was inspected case by case, and the four expected-value changes were each verified by differentiating the new antiderivative and checking the residual numerically. Every figure quoted above is copied from an actual run.

Changes since the first review

Rebuilt on the current #136

#136 no longer introduces exact_sqrt: it builds the radical as an unevaluated
SymbolicUtils.term(sqrt, y) put through simplify, and the perfect-square extraction now
lives upstream in SymbolicUtils 4.46.8. This branch was still calling exact_sqrt, so it
would not have compiled once #136 landed. rubi_sqrt now uses the same construction as
to_symb(::QQBarFieldElem), and the four Apostol expected values reference rubi_sqrt
instead of the removed helper. Verified: rubi_sqrt(12//49) gives (2//7)*sqrt(3),
rubi_sqrt(4) gives 2, rubi_sqrt(-2) gives (0+1im)*sqrt(2), and the integrands in the
table above still produce the exact forms.

Finding 1: the sign of a constant is now decided exactly

constant_value folds the tree in floating point, so nearly equal radicals cancel and the
predicates could take a definite, wrong decision. The reviewer's example, with exact_sqrt
replaced by rubi_sqrt:

u = rubi_sqrt(10^16 + 1) - 10^8   # exact value about 5.0e-9, so positive
constant_value(u)   # 0.0
pos(u)              # false  <- wrong
neg(u)              # true   <- wrong
le(u, 0)            # true   <- wrong

pos, gt, ge, lt and le now go through constant_sign / compare_constants, which
keep the floating point verdict while it is far enough from zero (sqrt(eps(Float64)),
relative to the operands for a comparison) and otherwise decide over the algebraic numbers
via algebraic_constant_value, using the QQBarField this package already depends on. That
path is exact, so the answer is certified rather than tolerance-based; it is also much more
expensive, which is why it is only reached when the cheap value is inconclusive.

algebraic_constant_value covers rational literals, +, -, *, /, integer powers and
square roots, which is what the rule table produces. Anything outside that, or a nonreal
value, yields nothing and leaves the predicate exactly as undecided as it is for a
genuinely symbolic argument. Nonreal values are filtered with is_real before any
comparison, so this cannot hit the DomainError of #13.

Finding 2: reload_rules no longer bypasses the rewrite

load_rules rewrote the replacement side while reload_rules installed it raw at its three
call sites, so reloading a shipped file during rule development brought floating point roots
back:

integrate(1/sqrt(2 + x^2), x)                          # asinh(x / sqrt(2))
reload_rules(".../1.1.2.1 (a+b x^2)^p.jl")
integrate(1/sqrt(2 + x^2), x)                          # asinh(x / 1.4142135623730951)

Both paths now go through one exact_roots_in_rule helper, so they cannot drift apart again.

Finding 3: formatting

The new test file is Runic-clean (--check exits 0), and the lines this PR adds or touches
in rules_utility_functions.jl and rules_loader.jl no longer appear in Runic's diff. The
rest of those two files was already non-clean on main, verified by running Runic against
main's copies, and is left alone.

Tests

test/methods/rule_based/test_exact_roots.jl, wired into the easy group, covers
rubi_sqrt on exact, negative and symbolic arguments; that the default integrate returns
no floating point literal for four integrands; the cancellation case above in both
directions plus an exact zero; that ordinary constants keep their decisions; and exactness
before and after reload_rules.

Failing before, passing after: on this branch without the two fixes the file reports 5
failures in the sign testset and 2 in the reload testset; with them it is 37 passed, 0
failed.

TEST_GROUP=easy: 278 passed, 1 broken (pre-existing), exit 0. Julia 1.13.0, SymbolicUtils
4.48.0, Symbolics 7.41.1, Nemo 0.56.1, AbstractAlgebra 0.50.2.

TEST_GROUP=difficult was not re-run locally for this revision: the machine I am on
could not keep the run alive. It matters here, because widening the sign predicates can
change which rule branch fires anywhere in the corpus, so the baseline gate in CI is the
check to read before merging this. For the same reason, the "92 to 93 successes" figure in
the description above was measured on the pre-review revision, before rubi_sqrt was
rebuilt on the new #136 and before the sign predicates changed; treat CI's numbers on this
head as authoritative.

Two red checks on this PR are not caused by it: the difficult jobs fail on baseline entry
15, sin(sqrt(1+x))/sqrt(1+x), now filed as #145; and the Maxima qa jobs fail to resolve
because of the stale 0.2 bound fixed by #146.

Assisted by: AI

CI result on this head

TEST_GROUP=difficult is the check I could not complete locally, and CI answers it:

  • RuleBased: 93 succeeded, up from 91 on main-like heads (measured on Stop calling the deprecated AbstractAlgebra zeros #138, which does
    not touch rule selection), 0 errored.
  • Risch: 58 succeeded, 77 failed, 42 maybe, 0 errored — unchanged, as intended.
  • The RuleBased regression list contains exactly one entry on all nine jobs (1.10 / 1 /
    pre × ubuntu / macos / windows): [15] sin(sqrt(1+x))/sqrt(1+x), which is [RuleBased] change of variables loses the domain of the substituted variable, breaking ∫sin(sqrt(1+x))/sqrt(1+x)dx #145 and fails
    the same way on every other open pull request. There are no Risch regressions at all.
    So widening the sign predicates did not change any rule decision for the worse anywhere in
    the 177-integrand corpus.
  • One further improvement appeared, [158] sqrt(2 - x - x^2)/x^2, consistent across all nine
    jobs, and test/difficult_baseline.jl is tightened for it here as the harness asks.

The five Risch improvements the run lists ([3], [26], [27], [49], [55]) also appear on
unrelated pull requests, so they come from dependency drift rather than this change; they
would be worth a separate baseline refresh, which would let difficult go green again once
#145 is addressed.

The earlier revision of this branch also failed the two Maxima difficult jobs with
UndefVarError: rubi_sqrt not defined in SymbolicIntegration, because the shared
test/test_files corpus is read by lib/SymbolicIntegrationMaxima too, where
SymbolicIntegration is the registered release rather than this working copy. Fixed by
spelling those expected values with the public sqrt(Symbolics.Num(n)), which builds an
isequal object. Those two jobs pass now.

State on head 4b7fe12

Rebuilt on the current #136, which brings current main with it. That was needed twice over:
the pull request had gone CONFLICTING, because #147 added nonnegative_substitution,
drop_abs_of_nonnegative and their hook in int_and_subst to the same
rules_utility_functions.jl this branch rewrites. Only test/runtests.jl conflicted, where
both sides add an include; both are kept.

One real finding came out of the merge. CI reported a RuleBased regression on
[158] sqrt(2 - x - x^2)/x^2, expected 0 and got 1, on all platforms. It is not a regression
of the engine: the residual carries 0.35355339059327373 and 2.8284271247461903, that is
1/(2*sqrt(2)) and 2*sqrt(2), and they come from the reference value, where sqrt(2) is
Julia's sqrt on an Int. The SymbolicUtils = "4.49" bound makes the integrator's output
exact, so an exact result now faces an inexact reference and simplify cannot cancel the
difference. The entry verified on the earlier head only because both sides were inexact.

That is the same defect this pull request already fixes for four Apostol entries; 158 was a
fifth, missed. Its two bare sqrt(2) are now sqrt(Symbolics.Num(2)), the public spelling
the others use — lib/SymbolicIntegrationMaxima reads the same corpus and cannot see
internal helpers.

Verification

TEST_GROUP=difficult, locally, exit 0 and no regressions: RuleBased up from 93 to 94
successes, Risch unchanged at 58, 0 errored. Two improvements are deliberately left in place —
entry 132 keeps its tolerant value, which its own comment records as returning code 2 on
Julia pre and Windows, and the five Risch entries belong to #148.

TEST_GROUP=easy: 301 passed, 1 broken (pre-existing), exit 0.

CI on this head: 33 pass, 2 fail — difficult 11/11, easy 9/9, qa 9/9, docs and spell
check green. The two failures are the SymbolicIntegrationMaxima qa jobs dying at
resolution on the stale 0.2 bound that #146 fixes; it has failed on main since the 0.3.0
release and nothing here touches it.

Rational functions that need an algebraic extension for their partial
fraction decomposition came back with floating point coefficients:
integrating 1/(x^3 - 1) produced 0.5773502691896257... where
(1//3)*sqrt(3) was expected.

The Risch backend already computes its roots exactly, as algebraic
numbers, and only the conversion back to Symbolics was inexact:
`to_symb` called Julia's `sqrt` on a `Rational`, which returns a float
and contaminates the whole expression by promotion.

Keep the radical as an unevaluated symbolic `sqrt` term instead, with
the largest perfect square factor pulled out, so that a coefficient of
sqrt(12//49) reads (2//7)*sqrt(3). The trial division that finds that
factor is bounded, so a large radicand cannot make it run away; a factor
it misses only leaves a less tidy radical, never an inexact one.

Purely imaginary roots stay exact the same way: sqrt(-12) now gives
(0 + 2im)*sqrt(3) rather than 3.4641016151377544im.

This fixes the Risch backend part of JuliaSymbolics#135. The two-argument `integrate`
goes through the rule-based backend, whose RUBI rules evaluate their own
literal `sqrt(3)` to a float; that is left for separate work, because
`sqrt` also appears in rule patterns which the matcher compares by name.

Assisted-by: AI
Building an exact constant is not specific to integration. It is proposed
upstream in JuliaSymbolics/SymbolicUtils.jl#1081; if that lands, this
helper and split_perfect_square should be dropped in favour of it.

Assisted-by: AI
Takes RuleBased from 92 to 93 successes on `TEST_GROUP=difficult` with no
regressions, and leaves Risch untouched (57/77/40/3, as on main). The
`[Rule Based] Integration of 177 functions` testset now passes.

Integrating 1/(x^2 - 2) with the default two-argument `integrate` gave

    -0.7071067811865475atanh(x / 1.4142135623730951)

and now gives

    (-atanh(x / sqrt(2))) / sqrt(2)

Three pieces were needed together; none of them is sufficient alone.

**Rewrite `sqrt` on the replacement side of each rule.** Rules are a
`Pair{Expr, Expr}` and the replacement is `eval`ed once a rule fires, so a
literal `sqrt(3)` there is Julia's `sqrt` on an `Int` and returns a float.
The AST of `rule.second` is rewritten to `rubi_sqrt`, and `rt(u, 2)` routed
through it. Only `rule.second` is touched: `sqrt` also appears in match
sides, which `check_expr_r` compares by name (`rule.args[1] === :sqrt`), so
renaming it there would stop every integrand containing a square root from
matching.

**Let the sign predicates decide on a constant.** `pos`, `gt`, `ge`, `lt`
and `le` gave up as soon as an argument was symbolic, so an exact `sqrt(2)`
made `pos` fall through to its `return true` default and rules took the
branch for the wrong sign. `constant_value` evaluates an expression that
denotes a number, which keeps the form exact while restoring the decisions
taken when these constants were floats.

**Let the matcher bind a defslot to a product of constants.** A numeric
coefficient folds into a single factor while an exact radical does not:
`-1.4142*x` has arguments `[-1.4142, x]` but `-sqrt(2)*x` has
`[-1, x, sqrt(2)]`, so a pattern like `(~!b)*(~x)` failed on length alone
and `1/(1 - sqrt(2)*x + x^2)` matched no rule at all — which is why
`1/(x^4 + 1)` came back with an unresolved integral inside it. The retry
collapses the constant factors into one. It is only reached after the
equal-length match has failed, so it can create a match but never break
one.

Four expected values in the Apostol test file were floats, because
`sqrt(3)` there is Julia's `sqrt` on an `Int`, so a result that is now
exact could not compare equal. They are written with the exact value,
wrapped in `Num` so the harness comparison against 0 holds.

`test/difficult_baseline.jl` is tightened for the one entry this improves,
`(x^4)/(4 + 5x^2 + x^4)`, which now verifies symbolically. Entry 132 is
left at its tolerant value, which its comment explains is deliberate for
platform variation, and the Risch entries are left alone since they belong
to JuliaSymbolics#139.

Assisted-by: AI
@ChrisRackauckas

Copy link
Copy Markdown
Member

🤖 Automated review from an AI agent running as @ChrisRackauckas — not written or reviewed by Chris. It is posted so that you can act on it. Chris or a maintainer may disagree.

Verdict: changes needed before merge (reviewed at af86d7d).

Preserves exact radicals in rule replacements and extends coefficient matching, on top of the unmerged Risch prerequisite. Request changes because constant evaluation gives incorrect signs and single-file rule reloads undo exactness.

Risk assessment

  • Risk: medium
  • Blast radius: Default rule-based integration, coefficient matching and sign-dependent rule selection; the included prerequisite also changes Risch coefficient conversion.
  • Evidence: Head has no check runs or commit statuses, and no comments/reviews. The latest completed main CI run (35223647633, commit 278475d) passed all 27 jobs: “Julia {1.10, 1, pre} - {ubuntu-latest, macos-latest, windows-latest} - {easy, difficult, qa} - push”. There are no failing head CI checks to compare. Local results below distinguish the shared baseline failure from this PR.
  • Merge: needs changes — correct sign evaluation, preserve exactness during single-file reloads, add regression coverage for both, and format added code.

Local verification used Symbolics 7.41.1, SymbolicUtils 4.48.0, Nemo 0.56.1 and AbstractAlgebra 0.50.2:

  • TEST_GROUP=easy /home/crackauc/.juliaup/bin/julia +1.13 --project=repo -e 'using Pkg; Pkg.instantiate(); Pkg.test()': 276 pass, 1 pre-existing broken, exit 0.
  • TEST_GROUP=difficult /home/crackauc/.juliaup/bin/julia +1.13 --project=repo -e 'using Pkg; Pkg.test()', and the identical command with --project=base: 15 pass, 1 fail on each, exit 1. Both fail only baseline entry 15, sin(sqrt(1+x))/sqrt(1+x), expected code 0 / actual 2. Main was clean tracked commit b056ef3. RuleBased: main 91 succeeded / 51 failed / 35 maybe / 0 errors, head 92 / 51 / 34 / 0. Risch: 58 / 77 / 42 / 0 on both. Comparing all 177 result-table rows found only entry 124 improved, exactly as the baseline edit intends.
  • julia --project=base regression.jl versus julia --project=repo regression.jl (Julia 1.12.4): the same reviewer-written exactness test gives 4 pass / 4 fail before, 8 pass after. It checks the four advertised simple integrands for floating literals and unresolved integrals. Seven direct integration/differentiation probes had residual magnitudes at most 1.12e-16 at x=1/3; the quartic result still contains floats, consistent with the PR's disclosed limitation.
  • edgecases.jl reproduced finding 1 on Julia 1.12.4 and 1.13.0; sign-base.jl checked main's behavior. reload-check.jl reproduced finding 2 on Julia 1.13.0.
  • Runic --check --diff over the eight changed Julia files returned exit 1. QA, docs builds, other platforms, and a complete Julia 1.12 test suite were not verified.

The separately delegated clean-main investigation established an exact dependency boundary for entry 15: SymbolicUtils 4.46.6 (identical tree to the parent of 3079cbc8e459dcea4cbfdd3245331ba6089b0902) passes the standalone integration assertion; that commit fails it with the same Symbolics 7.39.2 and other dependencies. It restricts nested-power folding to integer outer exponents. Integration rule 4_1_12_81 substitutes u=sqrt(1+x) without retaining its domain, leaving sin(u)*u/sqrt(u^2) unresolved; current SymbolicUtils renders the denominator as abs(u). This is a separate integration-domain issue, not a regression introduced by this PR. No issue was filed under the read-only brief.

Scope checks: no new dependencies, public names, skipped tests, or loosened tolerances. The new SymbolicUtils accesses are public in the resolved version. The public integrate result representation changes without a version bump in this diff (head 3.7.0; current main 3.8.0); under the requested public-behavior release policy, coordinate a major release when rebasing onto the prerequisite.

Findings

  1. [P2] src/methods/rule_based/rules_utility_functions.jl:84 — Floating-point evaluation is used as a definitive sign test for exact constants. With u = SymbolicIntegration.exact_sqrt(10^16 + 1) - 10^8, I observed constant_value(u) == 0.0, pos(u) == false, neg(u) == true, and le(u, 0) == true on both Julia 1.12.4 and 1.13.0. The exact value is positive (approximately 4.999999999999999875e-9); current main reports pos=true, neg=false for the same symbolic expression. These predicates select branches in integration rules. Use exact/certified sign evaluation, or handle numerically ambiguous results conservatively, and add a cancellation regression test.

  2. [P2] src/methods/rule_based/rules_loader.jl:34 — The RHS rewrite is applied only by load_rules; exported reload_rules(path) still installs raw replacements at lines 102/107/113. On Julia 1.13.0, integrating 1/sqrt(2+x^2) yields asinh(x / sqrt(2)), but after reloading the shipped 1.1.2.1 (a+b x^2)^p.jl file it yields asinh(x / 1.4142135623730951). Apply the same transformation to every replacement/insertion in the single-file reload path and test exactness before and after reload.

  3. [P3] test/methods/risch/test_exact_algebraic_coefficients.jl:61 and added source blocks — Runic --check --diff exits 1, including for the entirely new test file and added expressions such as p*p and num*den. Format the new file and changed blocks without mixing in a whole-repository formatting sweep.

Push a fix and the PR is reviewed again automatically at the new head.


🤖 Posted by an AI agent — harness: Claude Code · model: claude-opus-5-5[1m] (fleet master); review by Codex CLI 0.157.1 / gpt-6-astra
Conversation: local Claude Code session 3cd6500a-1f81-46b5-ac0b-c466e15b6a53 on Chris's Mac (session ID, no URL)

s-celles and others added 3 commits September 27, 2026 00:15
The canonical radical form relies on the `simplify` rule that reduces
`sqrt` of a literal, added in SymbolicUtils PR #1081. That rule first ships
in v4.46.8: it was merged as `ef47adc2`, while `Project.toml` still read
4.46.7, and the next commit released 4.46.8. The tree of tag `v4.46.7` does
not contain it.

The old `"4.27"` bound still allowed 4.46.7, where the radicand is left
unreduced and `test_exact_algebraic_coefficients.jl` fails on `sqrt(3)`,
which is what CI resolved and why it was red.

Also state in the test file header what is asserted rather than what used to
happen, and make the file Runic-clean (explicit returns, `1.0e-10`).

Assisted-by: AI
Three things, all from the review of this PR.

**Rebuild `rubi_sqrt` on the current JuliaSymbolics#136.** That PR no longer introduces
`exact_sqrt`; it builds the radical as an unevaluated `term(sqrt, y)` put
through `simplify`, with the perfect-square extraction living upstream in
SymbolicUtils 4.46.8. This branch still called `exact_sqrt`, so it would not
have compiled once JuliaSymbolics#136 landed. `rubi_sqrt` now uses the same construction as
`to_symb(::QQBarFieldElem)`, and the Apostol expected values follow.

**Decide the sign of a constant exactly.** `constant_value` folds the tree in
floating point, so nearly equal radicals cancel and the predicates returned a
definite, wrong answer: for `rubi_sqrt(10^16 + 1) - 10^8`, whose value is about
5.0e-9, `pos` was `false` and `neg` and `le(u, 0)` were `true`. `pos`, `gt`,
`ge`, `lt` and `le` now go through `constant_sign` / `compare_constants`, which
keep the cheap floating point verdict while it is safely away from zero and
otherwise decide over the algebraic numbers through `algebraic_constant_value`,
using the `QQBarField` this package already depends on. Nonreal values are
filtered with `is_real` before any comparison, so this cannot reach the
`DomainError` of issue JuliaSymbolics#13, and an expression outside the supported shapes
leaves the predicate as undecided as it is for a symbolic argument.

**Rewrite roots on every load path.** `load_rules` rewrote the replacement side
of a rule while `reload_rules` installed it raw at its three call sites, so
reloading a shipped rule file during development brought floating point roots
back: `integrate(1/sqrt(2 + x^2), x)` went from `asinh(x / sqrt(2))` to
`asinh(x / 1.4142135623730951)`. Both paths now go through one
`exact_roots_in_rule` helper so they cannot drift apart again.

`test/methods/rule_based/test_exact_roots.jl` covers all of it, and is
discriminating: without the two fixes it reports 5 failures in the sign testset
and 2 in the reload testset; with them, 37 passed.

`TEST_GROUP=easy`: 278 passed, 1 broken (pre-existing), exit 0.
`TEST_GROUP=difficult` could not be completed on this machine, so the baseline
gate in CI is the check to read for corpus regressions.

Assisted-by: AI
@codecov-commenter

codecov-commenter commented Sep 27, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 90.07634% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.02%. Comparing base (8647f0d) to head (4b7fe12).
⚠️ Report is 27 commits behind head on main.

Files with missing lines Patch % Lines
src/methods/rule_based/rules_utility_functions.jl 89.42% 11 Missing ⚠️
src/methods/rule_based/rules_loader.jl 60.00% 2 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #141      +/-   ##
==========================================
+ Coverage   50.98%   53.02%   +2.03%     
==========================================
  Files          23       23              
  Lines        4309     4330      +21     
==========================================
+ Hits         2197     2296      +99     
+ Misses       2112     2034      -78     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

CI on this branch ran for the first time and the Maxima `difficult` job failed
with

    UndefVarError: `rubi_sqrt` not defined in `SymbolicIntegration`

`lib/SymbolicIntegrationMaxima/test/runtests.jl` reads the same
`test/test_files` corpus, and `lib/SymbolicIntegrationMaxima/Project.toml` has
no `[sources]` entry, so there `SymbolicIntegration` is the registered release
rather than this working copy: an internal helper of the local tree cannot be
referenced from those files. `exact_sqrt` had the same problem before it was
renamed, it simply never surfaced because no CI run had reached this branch.

The four expected values now use `sqrt(Symbolics.Num(n))`, which is public API
and builds the same object: `isequal(sqrt(Symbolics.Num(3)), rubi_sqrt(3))` is
true, so the comparison outcome for every affected entry is unchanged. Replaying
the harness comparison
`isequal(simplify(computed - expected; expand = true), 0)` on the three
non-elliptic entries gives code 0, as before.

`SymbolicIntegration.elliptic_f`, also referenced from that file, is untouched:
it is already there on main and resolves in the Maxima environment.

`TEST_GROUP=easy`: 278 passed, 1 broken, exit 0.

Assisted-by: AI
CI on this branch reports `sqrt(2 - x - x^2)/x^2` (entry 158) going from
`[ fail?]` to verifying symbolically, on all nine `difficult` jobs: Julia 1.10,
1 and pre across ubuntu, macos and windows. The harness asks for the baseline to
be tightened on the pull request that causes an improvement, so the RuleBased
expectation for that entry moves from 1 to 0.

The five Risch improvements the same run lists ([3], [26], [27], [49], [55]) are
left alone: they show up on unrelated pull requests too, so they come from
dependency drift rather than from this change and belong in a separate baseline
refresh.

Assisted-by: AI
SymbolicUtils #1079, the issue this PR's note and its numeric residual check
worked around, was fixed by SymbolicUtils #1108 and closed on 2026-10-01. The
fix is in v4.49.0 and absent from v4.48.0, which the old `"4.46.8"` bound still
allowed; verified on 4.49.0 that `simplify(term(sqrt, 2)^2)` gives `2` rather
than `2.0`.

The documented caveat was therefore wrong and is rewritten rather than dropped,
because a weaker version of it survives for a different reason. Measured on
4.49.0, the symbolic residual `d/dx F - f` now cancels to zero for `1/(x^3 - 1)`,
`1/(x^4 + 1)`, `1/(x^2 + 2)` and `x/(x^4 + 1)`, but not for `1/(x^2 - 2)` and
`1/(x^2 - 3)`, where `simplify` stops at an uncancelled product of conjugate
factors. That residual contains no floating point number and evaluates to about
1e-17 at several points, so it is a completeness limit of `simplify`, not a loss
of exactness.

The numeric residual check stays for that reason, and the test gains the
assertion that 4.49 makes meaningful: the simplified residual must contain no
floating point literal, which is exactly what #1079 used to break.

`TEST_GROUP=easy`: 264 passed, 1 broken (pre-existing), exit 0, with
SymbolicUtils 4.49.0 and Symbolics 7.42.0. The new test file is Runic-clean.
Also merges current main, which brings in JuliaSymbolics#147.

Assisted-by: AI
Review suggestion 2 on JuliaSymbolics#136: the first paragraph of the `!!! note` and the
matching test comment described what `simplify` used to do, which is of no use
to a reader of the current code. Both now state the requirement —
SymbolicUtils >= 4.49, whose `simplify` leaves a radical exact — and why the
bound is there.

The second paragraph of the note, about checking residuals numerically, is
unchanged: the reviewer confirmed it is accurate, and it documents a limitation
that is still real.

43 assertions in `test_exact_algebraic_coefficients.jl`, all passing.

Assisted-by: AI
…in-rules

Brings JuliaSymbolics#136 up to date in this branch, and with it current main, which had made
this pull request conflict: JuliaSymbolics#147 added `nonnegative_substitution`,
`drop_abs_of_nonnegative` and their hook in `int_and_subst` to the same
`rules_utility_functions.jl` that this branch rewrites.

Only `test/runtests.jl` conflicted, where both sides add an include; both are
kept. The source file merged cleanly, and all of the functions from both sides
are present: `nonnegative_substitution` and `drop_abs_of_nonnegative` from
JuliaSymbolics#147, `algebraic_constant_value`, `exact_sign`, `constant_sign`,
`compare_constants`, `exact_roots_in_rule` and `rubi_sqrt` from this branch.
The `SymbolicUtils = "4.49"` bound comes in from JuliaSymbolics#136.

`TEST_GROUP=easy`: 301 passed, 1 broken (pre-existing), exit 0.

Assisted-by: AI
CI reported one RuleBased regression on this head, `[158]
sqrt(2 - x - x^2)/x^2`, expected 0 and got 1, on ubuntu, macos and windows
alike.

It is not a regression of the engine. The residual `r - ref` carries floats —
`0.35355339059327373` and `2.8284271247461903`, that is `1/(2*sqrt(2))` and
`2*sqrt(2)` — and they come from the reference value, not from the result: in
this corpus `sqrt(2)` is Julia's `sqrt` on an `Int`. The `SymbolicUtils = "4.49"`
bound that JuliaSymbolics#136 brings into this branch makes the integrator's own output exact,
so an exact result now faces an inexact reference and `simplify` cannot cancel
the difference. The entry verified on the earlier head only because both sides
were inexact.

This is the same defect this PR already fixes for four other Apostol entries, and
158 is a fifth that was missed. Its two bare `sqrt(2)` become
`sqrt(Symbolics.Num(2))`, the public spelling used for the others — `Symbolics`
is reachable from `lib/SymbolicIntegrationMaxima`, which reads the same corpus,
whereas an internal helper would not be.

Verified: the reference is now free of floats, `simplify(r - ref; expand = true)`
is `0`, and the harness scores the entry 0 again, so the baseline stays `(0, 2)`.

`TEST_GROUP=difficult`: exit 0, **no regressions**, RuleBased up from 93 to 94
successes, Risch unchanged at 58 with 0 errors. The improvements the run still
lists are left alone: entry 132 keeps its tolerant value, which its own comment
explains returns code 2 on Julia pre and Windows, and the five Risch entries
belong to JuliaSymbolics#148.

Assisted-by: AI
@s-celles-bot

Copy link
Copy Markdown
Contributor

Updated for review, and rebuilt on the current #136 — this had gone CONFLICTING after #147
landed in main, since that PR touches the same rules_utility_functions.jl.

The two P2 findings were addressed in d7a44c3:

  • Finding 1, the floating point sign test. pos, gt, ge, lt and le now go through
    constant_sign / compare_constants: the cheap Float64 value decides while it is safely
    away from zero, and otherwise the sign is taken over the algebraic numbers through
    algebraic_constant_value, using the QQBarField this package already depends on. So the
    decision is certified rather than tolerance-based. rubi_sqrt(10^16 + 1) - 10^8 now comes out
    positive. Nonreal values are filtered with is_real before any comparison, so this cannot
    reach the DomainError of the field use_algebraic_closure in Risch method makes integration of expressions with division fail #13.
  • Finding 2, reload_rules bypassing the rewrite. Both loading paths now go through one
    exact_roots_in_rule helper, so they cannot drift apart again.

One thing the merge surfaced that is worth flagging, since it is the same class of defect this
PR is about: CI reported [158] sqrt(2 - x - x^2)/x^2 as a RuleBased regression, and it turned
out the floats were in the reference value, not the result — the SymbolicUtils = "4.49"
bound makes the integrator exact, so an exact result stopped matching an inexact reference. It
was a fifth Apostol entry of the four this PR already fixes. Details in the description.

difficult is green here, locally and in CI, with no regressions and RuleBased up from 93 to
94. The only two red checks are the Maxima qa jobs that #146 fixes.

Assisted by: AI

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.

4 participants