Repository navigation
How much can the integrator's code be simplified? #1486
Description
Activity
Yes, but not the way the question hopes, and the reason matters for the design. Measured first, then a plan in three steps; the first is not breaking and could start now.
Where the lines go.
IndefiniteIntegralSolver.csis 16,857 lines in one file: 88 rules, a third of it comments. The four rules I wrote last night are 27 to 68 lines of code each, and each of them hit a trap about how the integrand is spelled, not about the mathematics:rule the trap a constant out of a fractional power (#1481) 1 * fis notfas a tree, so the rule "took out" nothing and re-asked the same question at every depth — CI hung for 3½ hthe same an even power arrives as (u^2)^(-1)below a substitutiona cos + b sinrotated (#1484)a cscnode above the bar hides a sine below itacoshbeside a multiple of its radicand (#1487)(d - c^2 d x^2)^3arrives as(d^0 (-1)^(-1) c^(-2) + x^2)^3, which the polynomial reader refuses, and simplifies tox^2 - c^(-2) provided not d = 0In Rubi a rule is one line because Mathematica's pattern matcher and its canonical forms carry that weight:
a_. + b_.*x_matches a missing coefficient, commutativity and associativity are free,FreeQis a primitive, and every integrand is in one spelling before any rule sees it. Ours re-derive all of that inside each rule. So the honest answer is that a pattern DSL moves most of those lines into one place rather than removing them — which is still worth doing, because then they are written once and tested once, instead of rediscovered one trap per rule.The design, and what it needs. Rules as data, Rubi-shaped: a pattern with typed captures (
Free(x),Integer,Linear(x),Polynomial(x)), a condition over the captures, and an answer template or a rewrite into a smaller question. Most of the machinery exists in pieces: simplification rule sets are data already (MatchedRules.cs),...is the pattern operator, and #746's tier 1 names pattern matching as data. Three counter-arguments that decide the order:- The canonical form is the prerequisite, not the DSL. A pattern is only concise against one spelling. Without Goal: Math OS — a ten-year vision for AngouriMath as an open mathematical reasoning platform #746's written canonical form, each pattern would need its spelling variants, and that is exactly the length we have now.
- Cost. Converting Simplify's rule sets to data cost +13% on
SimplifyEasy, measured against where it started, and it needed an index before it was acceptable. Rubi has about 7,000 rules; tried in order at every node, that is not a design, it is a timeout. It wants a discrimination tree — the e-matcher is a start. - Rules do not replace the searches. Many rows come out of generic machinery — the substitution search, parts, Rothstein–Trager — that Rubi enumerates rule by rule. A table would sit in front of those, not instead of them, and the order between table and search is itself a defect surface.
The plan I would propose:
- Now, not breaking: split
IndefiniteIntegralSolver.csinto partial-class files by family — trigonometric, hyperbolic, radicals, logarithms and exponentials, rational, inverse functions, parts. An agent working onacoshreads about 2,000 lines instead of 17,000. This answers the context-window half of the question directly, whatever happens to the other half, and it is a move with no behaviour change, so its review is mechanical. - 2.8, design tried before v3: a small internal pattern DSL with typed captures over today's normalisation. Port ten rules that are pure shape-and-template — the imaginary tangent and A cos + i A sin below the bar is written as the exponential it is #1485's
A cos + i A sinare the obvious first two — and measure code lines per rule, time per call, and the family pass rates, before and after. - v3: the canonical form, and rules as data at scale, if 2.8's measurement says the cost is acceptable. That part is breaking, because answers change spelling.
Triaged as Maintenance on 2.8, since it is the design work 2.8 exists for; the breaking half would move to 3.0 when it is split out. If you agree with the file split, I will open it as its own PR; it touches no rule.
🤖 Generated with Claude Code
Yes you can pilot this design before whole-repo refactor
But this might still be a tradeoff between speed/memory and code size / agent context filling
If the speed and memory worsen, this might not be worthwhile after all.
Agreed, it is a tradeoff, so the pilot should measure both sides of it for every rule it ports: code lines and what an agent has to read to change the rule, against time and allocation per call — the gate, plus a benchmark per rule — and the Rubi family pass rates before and after.
The tradeoff is also not uniform, and I think the result will say so. Every integrand passes through a few rules near the top of the dispatch, and those may not be able to afford an interpreted pattern at all. Rules that fire rarely, deep in the list, can. If that is how it comes out, the design is data for the cold rules and hand-written code for the hot ones, and the measurement is what draws the line between them.
On the file split, I read "before the whole-repo refactor" as putting it in the v3 pass with the rest of the restructuring, so I will leave the file as it is unless you say otherwise.
🤖 Generated with Claude Code
You can also investigate compile-time machinery to optimize declarative rules. Optimization can be itself encoded as code too, and it should have a positive effect on the simplifier too.
The pilot has one rule ported, #1485's
A cos(y) + i A sin(y)→A e^(i y), on the existing matcher (MatchPattern, which already does the associative-commutative part throughGathered). One addition was needed:Scaled, Rubi'sa_.*u_, a coefficient that is1where the factor stands alone.The rule is now its pattern, its condition and its result, 12 lines:
MatchPattern.Gathered<Sumf>("rest", MatchPattern.Scaled("A", MatchPattern.Node<Cosf>(MatchPattern.Any("y"))), MatchPattern.Scaled("B", MatchPattern.Node<Sinf>(MatchPattern.Any("y")))), when: (bound, x) => bound["rest"] == 0 && bound["y"].ContainsNode(x) && !bound["A"].ContainsNode(x) && !bound["B"].ContainsNode(x) && ImaginaryUnitSign(bound["B"], bound["A"]) != 0, becomes: (bound, _) => bound["A"] * e^(±i · bound["y"]),
The hand-written version is 27 lines plus its share of a 47-line reader. The rule type (45 lines) and
Scaled(49) are paid once. Behaviour is identical: the rule's 62 tests, seven probes compared answer for answer, and all of Rubi 4.7.2 at 249 of 290 on both builds, every run.Speed, measured as you asked. The whole of 4.7.2 is 290 trigonometric integrands, and every one of them passes this rule's place in the dispatch. Three runs each:
runs (ms) median hand-written 161,553 · 161,825 · 161,520 161,553 as data 163,324 · 163,365 · 161,481 163,324 (+1.1%) One rule costs up to about 1% where it fires most, and one of the three runs matched the hand-written version. That is affordable for this rule. It is a cost per rule, though, and 88 of them interpreted would add up to exactly what you warned about. So the measurement agrees with you: an interpreter is not the design, and generating the matcher at compile time is.
On compile-time machinery. The repository already has a generator that settled this trade the other way round:
RuleRegistryGenerator(#825) keeps the hand-writtenswitchas what runs and derives addressable rule values from its arms, because running the simplifier's rules through the matcher cost about 5% ofSimplifyper rule set. What you suggest is the reverse: the rule is written declaratively, and the generator emits theswitch. The permutationsGatheredsearches at run time and the coefficient defaultsScaledtries become arms at build time. That keeps both the 12-line source and the hand-written code's cost, and it is Native-AOT-safe, which compiling expression trees at run time is not. The same generator would serveMatchedRules, which is where the simplifier's +13% came from.Next, if you agree: a generator for exactly this rule's pattern,
Gatheredover twoScaledparts, emitting itsswitch. I would check that its output is the same as the interpreted rule's on 4.7.2, and that its time is the hand-written one's, before it takes a second rule. The pilot branch stays unmerged until then. A +1.1% change is not one I would merge on its own merits.🤖 Generated with Claude Code
Sure, try some designs and see if each one is scalable
Will do. Three designs, each on the same rule and measured the way the pilot was — all of 4.7.2, three runs each, output compared answer for answer:
- Interpreted: the pilot as it stands, +1.1%.
- Generated at build time from the
MatchPatternexpression itself. One source of truth: a generator reads the pattern's syntax and emits theswitch, withGathered's permutations andScaled's defaults expanded into arms. - Specialised at start-up. The pattern is compiled once into a tree of closures that do no search where the pattern allows none. It is not build time, but it is Native-AOT-safe and needs no generator to maintain, which counts for something if its cost comes close.
By scalable I mean: the cost per rule stays at the hand-written one however many rules there are, a rule is written once and read once, and whatever a design cannot express is found and written down, not worked around. I will report lines, time, allocation and those limits for each.
🤖 Generated with Claude Code
The first result changes the order I proposed. Interpreted, with one cheap test in front, costs what the hand-written rule costs.
The whole of the pilot's +1.1% was the matcher starting an enumerator at every node below the bar, when the pattern can only ever match a sum. A test on the node's kind before any enumerator is started removes it. Interleaved runs of all of 4.7.2, three each, answers identical every run:
runs (ms) median hand-written 163,684 · 162,210 · 161,601 162,210 as data, with the test 161,334 · 163,900 · 162,185 162,185 A difference of 0.02%, inside the ±0.8% spread of either. The test is derived from the pattern, not written per rule:
MatchPattern.CouldMatchRoot.Node<T>matches aT,Gathered<Sumf>a sum or a difference,Scaledits part or a product. No rule has to remember to say it, which is what makes it scale: the cost per rule is a type check at each node, the same one the hand-written rules open with.So, on this evidence:
- Design 1, interpreted plus the derived test, is the baseline. It is at hand-written cost for a rule of this kind, with the rule 12 lines instead of 27 plus its reader.
- Design 2, generated at build time, is held in reserve rather than built now. It pays where a pattern searches hard — several
Gatheredparts over long chains, where the permutations are real work — and this rule is not that. The next ports will say whether any rule is. - Design 3, specialised at start-up, is not needed unless design 1 stops being enough.
Two limits on what this shows. It is time, not allocation: the gate does not exercise
Integrate, so memory is still unmeasured, and I will add an allocation count per rule before the next port. And it is one rule. Next I would port the imaginary tangent and the reader that #1484's rotation shares, and measure the same way, so that "scales" rests on three rules rather than on one.The pilot is on the
rules-as-data-pilotbranch, not a PR.🤖 Generated with Claude Code
Second measurement, with memory this time. Two rules are now data, #1485's
A cos + i A sinand the imaginary tangent/cotangent (which needed a rule to hold several cases, one per shape). The Rubi harness now records what eachIntegratecall allocates.Rubi 4.7.2 + 4.3.10 + 4.4.10, 312 problems where both rules fire, three interleaved runs each, answers identical every run (269/312):
time (ms) allocation (MB, 297 problems measured) hand-written 244,246 · 243,000 · 241,328 → 243,000 205,034 · 205,036 · 205,036 → 205,036 as data 241,617 · 247,062 · 241,962 → 241,962 205,053 · 205,034 · 205,043 → 205,043 Time is inside the noise (−0.4%, the run spread is ±1%). Allocation is +7 MB out of 205 GB, 0.003%.
Code: the tangent rule went from 57 lines to 18 and
A cos + i A sinfrom 27 to 12; the solver loses 98 lines. The machinery is paid once:ScaledandCouldMatchRootin the matcher, and the rule type, about 100 lines together.A side finding matters more than the rules for your memory question: those files allocate about 690 MB per problem. The integrator's memory goes into its search, not into how a rule is spelled, so if memory is the concern, that is where it would pay to look.
My proposal, given two rules at no measurable cost:
- Open the pilot as a PR: the machinery and these two rules. It is cost-neutral, shorter, and gives the pattern form a place in the code base.
- Move other rules over when they are next touched, not as a rewrite. A rule that reads a shape is the candidate. Rules whose work is a search or an algorithm — Rothstein–Trager, the substitution search, parts — are not, and stay as code.
- Keep the build-time generator in reserve for a pattern whose matching is itself the cost — several
Gatheredparts over long chains. None has shown up yet.
If you would rather the pilot port more rules before it becomes a PR, say which kind; the hot rules at the top of the dispatch would be the stronger test of the pre-filter.
🤖 Generated with Claude Code
Can writing the rules in this way help the search somehow? I thought that there would be more than neutral here.
It can, but not by being moved in place, which is all the pilot did. A rule moved one-for-one into data runs the same search in the same order, and neutral is the most that can come of that. The gains are in what a rule declared as a pattern lets the dispatcher do that it cannot do with a method, and there are four, cheapest first.
- Index the rules by what they need. A pattern says which node kinds it can match at all: this one needs a sum with a cosine and a sine in it. The integrand's node kinds are one pass to collect. Today all 85 dispatch entries are called in order at every level of the search, and each does its own check, some of them expensive (
SingleQuotient.Combine, a polynomial read) before declining. With patterns, a rule whose kinds are absent is never called. This is Rubi's advantage too: its rules are found by the head of the integrand, not tried in turn. - Order by specificity instead of by hand. Order has been the defect surface all along: a slow search in front of a rule that would answer instantly is a timeout. A power of a constant multiple of the radicand a logarithm holds is written over it #1487's first version shows the same thing from the other side, where a rule firing too early cost 7 s → 31 s. Patterns can be compared by how much they constrain, so the more specific ones go first without anyone deciding it rule by rule.
- Check a rule before it runs. The bug that hung CI on A constant comes out of a fractional power beside another power of the same function #1481 was a rewrite whose output matched its own trigger, and An odd power of a cos + b sin is rotated into one cosine #1484's first version rotated its own output the same way. With the pattern and the result both as data, "can the result match the pattern" is a question you can ask at build time.
- Search over rewrites instead of committing to one. The same-question rewrites — a constant out, a spelling changed — are exactly e-graph rewrites. The integrator could saturate an integrand's equivalent forms and ask the closed rules of all of them, instead of betting on one order. That is Goal: Math OS — a ten-year vision for AngouriMath as an open mathematical reasoning platform #746's tier 2 and the e-matcher's purpose; it is also the one with a real memory cost, and those files already allocate about 690 MB a problem.
Only the first is cheap to test now, and it rests on a number I don't have: how much of the integrator's time goes into rules that are called and decline. I'll measure it per rule over the same Rubi files. If declines are a large share, the index is the gain you expected; if they're small, the speed isn't in the dispatch, and the case for data is the code and the checks in 3.
🤖 Generated with Claude Code
- Index the rules by what they need. A pattern says which node kinds it can match at all: this one needs a sum with a cosine and a sine in it. The integrand's node kinds are one pass to collect. Today all 85 dispatch entries are called in order at every level of the search, and each does its own check, some of them expensive (
Measured. Every rule call in the dispatcher was timed, and each call's own time (nested calls taken out, so nothing counts twice) was sorted by what the call did.
where the integrator's time goes 1774-problem suite 4.7.2, 4.3.10, 4.4.10 family 1, 6 a file total 72 s 480 s 208 s declines under 100 µs, which an index could skip 1.7% 0.3% 0.3% declines over 100 µs with no sub-question 66% 62% 80% declines after sub-questions 15% 19% 6% answers 15% 2% 5% cut off at the 5 s limit 0% 17% 8% Most rule calls are cheap declines: 409,000 of the suite's 419,000, 2.9 µs each on average. An index can save a percent or two at most.
The time goes to two rules that do real work and then decline without asking anything:
suite trig files family 1 SolveBySubstitution53%, 8 ms a call 28%, 25 ms 46%, 48 ms SolveByPartialFractions4% 28%, 25 ms 26%, 31 ms Neither can be skipped by node kind. The substitution search applies to almost anything, and partial fractions reads any quotient, trigonometric ones included.
That's where data can help the search: by testing, before the work, what a rule needs, instead of skipping the calls that are cheap already. "A quotient of polynomials in x" is a pattern, and partial fractions currently spends 25 ms per call on trigonometric quotients it can't use. Next I'll find where those milliseconds go in the two rules. A stated precondition there is the first measurable test of your question.
A correction to the table above. For the trig files and family 1 its shares include time spent in problems that ran out of time. The 1774-problem suite has no timeouts, so its column stands. Over the problems each set actually solves:
own time of declines without a sub-question suite 4.7.2, 4.3.10, 4.4.10 family 1, 6 a file SolveBySubstitution53% 75% 36% SolveByPartialFractions4% 3% under 1% Partial fractions' 28% on the trig files was in problems past the 5 s budget. It's the Hermite reduction running symbolic linear solves in 14 to 26 unknowns that find no solution, about 1.6 s each, all inside 4.7.2's
sec(c + d x)/(a sin(c + d x) + b tan(c + d x))^2rows. I tried two fixes and measured both against master: accepting only rational quotients, and screening each solve with the symbols pinned to rationals first. Neither changed anything the harness can see: the same answers, the same 15 timeouts, 166.1 vs 166.5 s. Those rows still run past 60 s on master and on either branch, so neither fix is going in.Where time goes in the problems the integrator answers is the substitution search: 1 to 5 ms of simplification per candidate, a few candidates per call, mostly declined. Step timers inside the rule put 82% of the trig files' solved time in two steps: rewriting the integrand in each candidate and simplifying it, and the sine/cosine complement pass. That is the lever, and it's a precondition question of the kind you asked about. A candidate that can't work can often be recognised numerically before simplifying. For
u = sin(c + d x), the quotient bydu/dxhas to take the same value atxand at the point where the sine repeats, and two evaluations settle that. I'll try that next and report what it measures.Would it make sense to build some of #1019's new numerical evaluation infrastructure to assist with internal optimizations before v3? For example these numerical tests should use it rather than the global settings. This can help pilot the design. This alternative system can coexist with the legacy numerical evaluation system, and
Evaledis not to be used internally anymore.Sorry for the slow reply. Yes, it makes sense, and the integrator is a good place to pilot it: its numeric checks already ask item 10's question, just through the global settings.
HoldsAtSampledPointsandDerivativeHoldsAtSampledPointsswitchDowncastingEnabledoff in a scope, pin the symbols, evaluate atDecimalPrecisionContext's hundred digits, and compare with a 1e-9 tolerance. They need about twelve digits and a three-way answer: agree, differ, or can't tell.- The screens are the same question: A power of a constant multiple of the radicand a logarithm holds is written over it #1487's constant-ratio test, the pinned solve in
PartialFractions, and the repeat test I'm adding to the substitution search. (a + b arcsin(c x))/sqrt(d - c^2 d x^2)integrates to NaN with downcasting off #1490 shows what the global settings cost. The same integral is answered with downcasting on and comes backNaNwith it off.
The pilot I'd build is an internal evaluator that takes the accuracy as an argument and returns an interval. It would use double (or double-double) interval arithmetic over the nodes these checks meet: arithmetic, powers, exp and ln, the trigonometric and hyperbolic functions and their inverses. Anything else goes back to the caller as undecided. It sits beside the legacy evaluator and reads no settings. Its first clients would be the two sampled checks and the screens, measured by every check verdict that changes over the Rubi sets, and by time, since these checks run inside many rules.
On
Evaledinternally: it has 739 uses in 74 files, and 517 of them are exactness tests, likex.Evaled is Number.Complex { IsZero: true }oris Number.Integer. Those are questions an exact API answers without evaluating anything. So there are two replacements: an exact one for those tests, and the evaluator for the 122EvalNumericalcalls. I'll start with the evaluator and its first clients.I don't think the new design wants to depend on DowncastingEnabled and DecimalPrecisionContext at all. If it's for test validation only, this precision can stay in the tests as a global parameter. In v3 we likely want to remove DowncastingEnabled and DecimalPrecisionContext altogether.
The new number type that we want to use for #1019 should also be defined and used, for places that don't need EDecimal interaction (i.e. don't need to be stored as a number node).
Agreed on both. The evaluator I've drafted reads neither setting. What still reads them is the fallback under it: where double intervals can't tell,
HoldsAtSampledPointscallsEvalNumericalas before. In the new design the answer there is the same interval evaluation at more bits. The caller asks for an accuracy, evaluation starts at 53 bits and doubles the precision until the interval is narrow enough or reaches a cap, and the answer is still agree, differ or can't tell. NoDowncastingEnabled, noDecimalPrecisionContext.The type, following item 8's tower and item 10:
- It lives in
AngouriMath.Numerics, internal until v3, beside theBigIntegerrational and decimal item 8 plans. Intervalis an approximate real on its face: two bounds, each a binary float (aBigIntegermantissa and anintexponent), with doubles as the fast path. Arithmetic rounds outward at the precision the operation is asked for.ComplexIntervalis two of them.- Exact numbers stay exact, and become intervals only where a function has no exact value.
- A value becomes a
Numbernode only where it's stored.
The order I'd take: first the type and its evaluation for the nodes the integrator's checks meet, using the #1364/#1366
BigIntegerseries for exp, ln and the trigonometric functions. ThenHoldsAtSampledPointsand the screens moved onto it, with nothing falling back toEvaled. I'll post measurements on #1338.- It lives in
The first precondition is in, #1493: the substitution search now drops a trigonometric candidate the integrand's quotient can't be a function of, found with two pinned evaluations instead of a simplification. On 4.7.2, 4.3.10 and 4.4.10 the problems the integrator answers take 22% less time (two interleaved pairs), with every verdict and every answer text unchanged. Family 4 is about 10% faster. It's a stated property of the rule ("a function of
uagrees whereverudoes") tested cheaply before the work, which is what a data rule would let us write down once for every rule.- added a commit that references this issue
on Sep 29, 2026 - added a parent issue
on Sep 30, 2026
Rubi's rules themselves are condense; one line is one rule. But in our implementation each rule becomes hundreds of lines. This suggests that some of the matching logic should really be abstractable. Does there exist a design that captures the conciseness of Rubi rules such that the agent's context window won't be filled that quickly?
If the best design is necessarily breaking, queue this for v3