Skip to content

Nothing writes an expression as a single quotient: no together / ratsimp #1239

Description

@Rafael-SOWNet

There is no operation that writes an expression as a single quotient. Simplify never combines a sum over a common denominator, at any level:

"1 + 2/(1+t^2)"        Simplify ->  1 + 2 / (1 + t ^ 2)
"3 + 10*t/(t^2+1)"     Simplify ->  3 + 10 * t / (t ^ 2 + 1)
"1/(t^2+1) + 1/(t+1)"  Simplify ->  1 / (1 + t ^ 2) + 1 / (1 + t)
"a + b/c"              Simplify ->  a + b / c

Simplify(5) gives the same. Transformation.Rewriting(RewriteRules.CollapseMultipleFractions) does not do it either — that one moves nesting around without combining terms. Transformation.Rationalization is about clearing a surd from a denominator, which is a different job.

This is not a defect in Simplify. Putting a sum over a common denominator is a choice that makes some expressions worse, which is why other systems keep it as a separate operation — SymPy has together(), Maxima has ratsimp, Mathematica has Together. We do not have it at all, and that is the gap.

Where it bites

It is the blocker for the rest of the half-angle substitution family added in #1238. That rewrite turns a rational function of sine and cosine into a rational function of t = tan(x/2), and partial fractions answers the result — but only when the result is a single Divf of two polynomials. Where the rewrite leaves a sum with a fraction in it, nothing downstream can read it:

integrand rewrites to outcome
1/(1+cos(x)) 1 answered, tan(x/2)
1/sin(x) 2/(1+t^2) / (2t/(1+t^2)), which collapses answered, ln(tan(x/2))
1/(1-sin(x)) 2/((t^2+1) * (1 + (-2)t/(t^2+1))) declined
1/(1+cos(x)/2) 2/((t^2+1) * (1 + (2/(t^2+1) - 1)/2)) declined
1/(2cos(x)+3sin(x)) same shape declined

Every declined row needs one step: distribute the outer factor into the bracket. (t^2+1) * (1 + (-2)t/(t^2+1)) is t^2 - 2t + 1, and then partial fractions answers it immediately.

I checked by hand that the algebra is otherwise right — building the quotient manually gives 2/(1 + t^2 - 2t) for the third row, which is 2/(t-1)^2 — so the substitution and the integrator are both fine and only the intermediate shape is in the way.

What it would take

A transformation that writes an expression as numerator / denominator with a single division at the root: recursively, a sum of quotients becomes one quotient over the product of the denominators, and a product of quotients multiplies through. It wants to be an explicit operation rather than part of Simplify, for the reason every other system keeps it separate.

Beyond unblocking the above, it is independently useful — it is a thing users ask a CAS to do — and it would feed partial fractions generally, since that already wants its input in exactly this shape and currently just declines anything that is not.

Activity

  1. added this to the 2.7.0 milestone on Sep 18, 2026
  2. Rafael-SOWNet commented on Sep 30, 2026

    @Rafael-SOWNet
    MemberAuthor

    Measured on master at 2eeb163: #1240 does this internally for the integrator (SingleQuotient), but nothing public does it yet.

                            RewriteRules.CommonDenominator                   Simplify
    1/(t^2+1) + 1/(t+1)     (2 + t + t ^ 2) / ((1 + t) * (1 + t ^ 2))        as written
    1 + 2/(1+t^2)           as written                                       as written
    a + b/c                 a + b * 1 / c                                    as written
    

    CommonDenominator puts a sum of quotients over one denominator, and a whole term beside a quotient stays as written.

    Proposal: expose SingleQuotient as Entity.Together(), with MathS.Together and Transformation.Together, which is the name SymPy and Mathematica use. It returns one numerator over one denominator, cancels nothing, and is never applied by Simplify, as the issue says. OK to add?

  3. Happypig375 commented on Sep 30, 2026

    @Happypig375
    Member

    "Together" sounds too vague, do mathematicians use this terminology, or is it a jargon artifact of engineers?

  4. Rafael-SOWNet commented on Sep 30, 2026

    @Rafael-SOWNet
    MemberAuthor

    Jargon, mostly: Together is Mathematica's name, and SymPy copied it. A mathematician says write it as a single fraction, or put it over a common denominator. So I'd name the result: AsSingleFraction(), with MathS.AsSingleFraction and Transformation.AsSingleFraction. OverCommonDenominator() would name the method instead, but it sits too close to RewriteRules.CommonDenominator, which does only part of the job. Which do you prefer?

  5. Happypig375 commented on Sep 30, 2026

    @Happypig375
    Member

    AsSingleFraction seems better because of as feeling like seeing from a different perspective.

    You should update the instructions to do critical thinking on naming and logic instead of copying blindly too.

  6. Rafael-SOWNet commented on Sep 30, 2026

    @Rafael-SOWNet
    MemberAuthor

    AsSingleFraction it is, and I'm building it now. The instructions are in #1617: a name is what a textbook calls the operation, and states the result; another system is only corroboration; and logic taken from one is checked against the mathematics before it is adopted.

  7. modified the milestones: 2.7.0, 2.6.0 on Sep 30, 2026
  8. Rafael-SOWNet commented on Sep 30, 2026

    @Rafael-SOWNet
    MemberAuthor

    A correction to my first comment. "Nothing public does it yet" was wrong: I measured the rule sets and missed Transformation.RationalCanonicalization (#934), which already does this for rational functions. On master at 2eeb163:

    1 + 2/(1+t^2)          (t ^ 2 + 3) / (t ^ 2 + 1)
    1/(t^2+1) + 1/(t+1)    (t ^ 2 + t + 2) / (t ^ 3 + t ^ 2 + t + 1)
    a + b/c                (a * c + b) / c
    x/x                    1 provided not x = 0
    sin(x) + 1/cos(x)      as written
    sqrt(2) + 1/x          as written
    

    It is a canonical form: reduced by the gcd, the denominator expanded and monic, and nothing outside rational functions over QQ. So AsSingleFraction has a narrower job than I said. It writes any expression as one fraction, functions included, so sin(x) + 1/cos(x) becomes (sin(x) cos(x) + 1) / cos(x). It cancels and expands nothing, so the denominator stays (1 + t)(1 + t^2). I'll build it with that scope and point its docs at RationalCanonicalization for the reduced form, unless you'd rather not have both.

  9. Happypig375 commented on Sep 30, 2026

    @Happypig375
    Member

    It feels like the same operation at two different cost levels, maybe there is a better modeling approach here

  10. Rafael-SOWNet commented on Sep 30, 2026

    @Rafael-SOWNet
    MemberAuthor

    I don't think they are one operation at two costs. They share their first step and then promise different things. I'd model them as one pipeline with named stages:

    1. Write it as a single fraction: AsSingleFraction. Any expression, and nothing cancelled.
    2. Reduce to lowest terms: divide out the gcd of the numerator and denominator, carrying provided g ≠ 0.
    3. Normalise: multiply out both halves and make the denominator monic.

    CanonicalizeAsRationalFunction is 1 → 2 → 3, and it answers only for rational functions over ℚ. That is where lowest terms is unique, and uniqueness is the point of it: two equal rational functions give the identical tree, so comparing trees decides equality.

    A cost setting on one method doesn't fit, because the higher setting would answer less than the lower one. sin(x) + 1/cos(x) can be written as one fraction, and the canonical form declines it, as it has to. sin and cos are not independent, so a gcd that treats them as separate letters gives a form that isn't unique: sin(x)^2 + cos(x)^2 and 1 would come out different. Falling back to stage 1 there would hand back something that looks canonical and isn't. The costs don't separate them either. On four small inputs, three of them the issue's, the canonical form was the faster of the two: 8–14 µs against 9–18 µs, best of five passes of 2000 calls.

    What the code doesn't have yet is the pipeline. RationalFunction gathers into one quotient itself, in polynomial arithmetic, and SingleQuotient does it again on expressions. Both have the same defect in the same place. Dividing by a fraction moves its denominator into the numerator and loses the fact that it was nonzero. On master at fc9c0c2 (neither file has changed since f633765, where I measured):

                     canonical form           at (rest 1)      expression there
    1/(1/x)          x                        x = 0: 0         no value
    1/(1 + 1/x)      x / (x + 1)              x = 0: 0         no value
    (a/b)/(c/d)      a * d / (b * c)          d = 0: 0         no value
    (x/y)^(-2)       y ^ 2 / x ^ 2            y = 0: 0         no value
    x/(y/x)          x ^ 2 / y                x = 0: 0         no value
    (1/x)/(1/x)      1 provided not x = 0     right: cancelling carries its condition
    

    That breaks O4 of the simplification contract: "what is forbidden is turning undefined into a value". The AsSingleFraction branch had the same gap. It now answers 1/(1/x) with x provided not x = 0, and (1/x)/(1/x) stays x / x with no condition, because x is still in the denominator (branch as-single-fraction, 2f28c3f).

    So I'll:

    • make stage 1 gather over the least common multiple of the denominators as written, the way a textbook does, instead of their product. At the moment 1/x + 1/x^2 is gathered over x · x^2, where a textbook uses x^2. Then I'll open the AsSingleFraction PR;
    • file the canonical form's gap as a bug, and fix it by making stage 1 the canonical form's first step, so the domain is handled in one place. That goes through the gate, since Simplify calls the canonical form inside the interval-membership rule (RewriteRules.Common does not terminate: a three-cycle on -x * 1/2 #1056);
    • not expose stage 2 on its own until someone asks for "in lowest terms" without the normalisation.

    Does that model work for you?

  11. Rafael-SOWNet commented on Sep 30, 2026

    @Rafael-SOWNet
    MemberAuthor

    #1622 is up and green: stage 1, with the least common denominator and the provided a turned-over denominator owes. I'm holding the merge until you've decided on the model above, because merging closes this issue. The canonical form's domain fix (#1618) follows it.

  12. Happypig375 commented on Sep 30, 2026

    @Happypig375
    Member

    Looks good to me

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