Skip to content

How much can the integrator's code be simplified? #1486

Description

@Happypig375

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

Activity

  1. Rafael-SOWNet commented on Sep 24, 2026

    @Rafael-SOWNet
    Member

    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.cs is 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 * f is not f as a tree, so the rule "took out" nothing and re-asked the same question at every depth — CI hung for 3½ h
    the same an even power arrives as (u^2)^(-1) below a substitution
    a cos + b sin rotated (#1484) a csc node above the bar hides a sine below it
    acosh beside a multiple of its radicand (#1487) (d - c^2 d x^2)^3 arrives as (d^0 (-1)^(-1) c^(-2) + x^2)^3, which the polynomial reader refuses, and simplifies to x^2 - c^(-2) provided not d = 0

    In 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, FreeQ is 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:

    1. 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.
    2. 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.
    3. 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.cs into partial-class files by family — trigonometric, hyperbolic, radicals, logarithms and exponentials, rational, inverse functions, parts. An agent working on acosh reads 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 sin are 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

  2. added this to the 2.8 milestone on Sep 24, 2026
  3. Happypig375 commented on Sep 24, 2026

    @Happypig375
    MemberAuthor

    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

  4. Happypig375 commented on Sep 24, 2026

    @Happypig375
    MemberAuthor

    If the speed and memory worsen, this might not be worthwhile after all.

  5. Rafael-SOWNet commented on Sep 24, 2026

    @Rafael-SOWNet
    Member

    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

  6. Happypig375 commented on Sep 24, 2026

    @Happypig375
    MemberAuthor

    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.

  7. Rafael-SOWNet commented on Sep 24, 2026

    @Rafael-SOWNet
    Member

    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 through Gathered). One addition was needed: Scaled, Rubi's a_.*u_, a coefficient that is 1 where 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-written switch as what runs and derives addressable rule values from its arms, because running the simplifier's rules through the matcher cost about 5% of Simplify per rule set. What you suggest is the reverse: the rule is written declaratively, and the generator emits the switch. The permutations Gathered searches at run time and the coefficient defaults Scaled tries 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 serve MatchedRules, which is where the simplifier's +13% came from.

    Next, if you agree: a generator for exactly this rule's pattern, Gathered over two Scaled parts, emitting its switch. 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

  8. Happypig375 commented on Sep 24, 2026

    @Happypig375
    MemberAuthor

    Sure, try some designs and see if each one is scalable

  9. Rafael-SOWNet commented on Sep 24, 2026

    @Rafael-SOWNet
    Member

    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:

    1. Interpreted: the pilot as it stands, +1.1%.
    2. Generated at build time from the MatchPattern expression itself. One source of truth: a generator reads the pattern's syntax and emits the switch, with Gathered's permutations and Scaled's defaults expanded into arms.
    3. 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

  10. Rafael-SOWNet commented on Sep 24, 2026

    @Rafael-SOWNet
    Member

    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 a T, Gathered<Sumf> a sum or a difference, Scaled its 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 Gathered parts 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-pilot branch, not a PR.

    🤖 Generated with Claude Code

  11. Rafael-SOWNet commented on Sep 24, 2026

    @Rafael-SOWNet
    Member

    Second measurement, with memory this time. Two rules are now data, #1485's A cos + i A sin and the imaginary tangent/cotangent (which needed a rule to hold several cases, one per shape). The Rubi harness now records what each Integrate call 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 sin from 27 to 12; the solver loses 98 lines. The machinery is paid once: Scaled and CouldMatchRoot in 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:

    1. 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.
    2. 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.
    3. Keep the build-time generator in reserve for a pattern whose matching is itself the cost — several Gathered parts 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

  12. Happypig375 commented on Sep 24, 2026

    @Happypig375
    MemberAuthor

    Can writing the rules in this way help the search somehow? I thought that there would be more than neutral here.

  13. Rafael-SOWNet commented on Sep 24, 2026

    @Rafael-SOWNet
    Member

    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.

    1. 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.
    2. 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.
    3. 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.
    4. 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

  14. Rafael-SOWNet commented on Sep 24, 2026

    @Rafael-SOWNet
    Member

    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
    SolveBySubstitution 53%, 8 ms a call 28%, 25 ms 46%, 48 ms
    SolveByPartialFractions 4% 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.

  15. Rafael-SOWNet commented on Sep 25, 2026

    @Rafael-SOWNet
    Member

    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
    SolveBySubstitution 53% 75% 36%
    SolveByPartialFractions 4% 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))^2 rows. 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 by du/dx has to take the same value at x and at the point where the sine repeats, and two evaluations settle that. I'll try that next and report what it measures.

  16. Happypig375 commented on Sep 25, 2026

    @Happypig375
    MemberAuthor

    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 Evaled is not to be used internally anymore.

  17. Rafael-SOWNet commented on Sep 26, 2026

    @Rafael-SOWNet
    Member

    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.

    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 Evaled internally: it has 739 uses in 74 files, and 517 of them are exactness tests, like x.Evaled is Number.Complex { IsZero: true } or is 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 122 EvalNumerical calls. I'll start with the evaluator and its first clients.

  18. Happypig375 commented on Sep 26, 2026

    @Happypig375
    MemberAuthor

    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).

  19. Rafael-SOWNet commented on Sep 26, 2026

    @Rafael-SOWNet
    Member

    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, HoldsAtSampledPoints calls EvalNumerical as 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. No DowncastingEnabled, no DecimalPrecisionContext.

    The type, following item 8's tower and item 10:

    • It lives in AngouriMath.Numerics, internal until v3, beside the BigInteger rational and decimal item 8 plans.
    • Interval is an approximate real on its face: two bounds, each a binary float (a BigInteger mantissa and an int exponent), with doubles as the fast path. Arithmetic rounds outward at the precision the operation is asked for. ComplexInterval is two of them.
    • Exact numbers stay exact, and become intervals only where a function has no exact value.
    • A value becomes a Number node 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 BigInteger series for exp, ln and the trigonometric functions. Then HoldsAtSampledPoints and the screens moved onto it, with nothing falling back to Evaled. I'll post measurements on #1338.

  20. Rafael-SOWNet commented on Sep 26, 2026

    @Rafael-SOWNet
    Member

    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 u agrees wherever u does") tested cheaply before the work, which is what a data rule would let us write down once for every rule.

  21. Rafael-SOWNet commented on Sep 27, 2026

    @Rafael-SOWNet
    Member

    The first piece is up as #1497: the integrator's sampled checks now run on the new internal evaluator, with no EvalNumerical and no DowncastingEnabled in the comparison, measured over every family. The decimal tier still uses PeterO decimals; binary floats replace it next. Details are on #1338.

  22. modified the milestones: 2.8, 2.6.0 on Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions