Skip to content

Differentiating over a constant answers as though it varies: sin(pi) differentiated by pi is -1 #993

Description

@Rafael-SOWNet

Entity.Differentiate(Variable) is typed to take a Variable, and MathS.pi and MathS.e are ones — so the constants can be handed to it, and it differentiates as though they varied.

Measured on master at 4ee698da, and identically on the branch for #984, so this is neither introduced nor fixed by that:

"pi ^ 2".ToEntity().Differentiate(MathS.pi)    // 2 * pi
"sin(pi)".ToEntity().Differentiate(MathS.pi)   // -1
"e ^ 2".ToEntity().Differentiate(MathS.e)      // 2 * e
"x".ToEntity().Differentiate(MathS.pi)         // 0

sin(pi) is 0. Its derivative with respect to anything is 0, and -1 is cos(pi) — the chain rule run over a symbol that cannot change.

Why it is separate from #984

That issue is about a name a binder declares. derivative(pi ^ 2, pi) goes through the binder and is answered over the variable the binder holds; this call has no binder in it, and the constant arrives directly in the position that says what varies.

What the answer should be

Two candidates, and I have no strong preference:

  • 0 — nothing in the expression varies with respect to a thing that cannot vary, and Differentiate already answers 0 for a variable that does not occur.
  • Refuse — SymPy raises here (diff(exp(2), pi) is ValueError: Can't calculate derivative wrt pi), on the ground that the question has no meaning rather than a zero answer.

0 is the smaller change and reads consistently with the rest of Differentiate; refusing says more. Worth deciding rather than leaving it at -1.

Where

Variable.InnerDifferentiate compares the node with the target — a Constant equals a Constant of the same name, so it answers 1 for the constant itself and the chain rule takes it from there. A guard at Differentiate(Variable) for a Constant target would cover every route in one place.

Activity

  1. Rafael-SOWNet commented on Aug 22, 2026

    @Rafael-SOWNet
    MemberAuthor

    Measuring the routes before implementing changed what the question is. Two things, and the second means this should be decided before it is coded rather than after.

    The defect is not Differentiate's

    Every calculus entry point takes the constant and treats it as a variable. On master at 5211ccd6:

    "pi ^ 2".ToEntity().Integrate(MathS.pi)      // pi ^ 3 / 3 + C
    "x".ToEntity().Integrate(MathS.pi)           // x * pi + C
    "pi ^ 2".ToEntity().Limit(MathS.pi, 0)       // 0
    "pi ^ 2".ToEntity().Substitute(MathS.pi, 3)  // 3 ^ 2

    So a guard at Differentiate(Variable) would fix the one call I happened to file and leave the same category error in three of its neighbours — [[one rule fix exposes another]] in its usual shape. Whatever is decided here should be decided for the family.

    And it decides the 0-vs-refuse question, which I left open above

    I wrote that 0 was "the smaller change and reads consistently". With the neighbours in view it stops being consistent:

    • d/dc f = 0 is defensible — the expression does not vary with something that cannot vary, and Differentiate already answers 0 for a variable that does not occur.
    • ∫ f dc has no 0-shaped answer at all. There is nothing to integrate with respect to. The only honest result is a refusal.

    Answering one with a value and the other with a refusal would be incoherent, so refusing throughout is the option that survives the family, and it is what AGENTS.md's own line points at — not answering is a legitimate answer; answering wrongly is not. It is also what SymPy does (ValueError: Can't calculate derivative wrt pi).

    That is a change of shape rather than of value, so I would rather have it confirmed than assume it. Substitute is the one I would leave alone either way — replacing pi with a numeral is a thing a caller may legitimately want, and it is not a calculus operation.

    Two things separated out of this while measuring

  2. Rafael-SOWNet commented on Aug 22, 2026

    @Rafael-SOWNet
    MemberAuthor

    Fixed in #1004 as 0, which reverses what I argued for in my previous comment.

    I said refusing was "the option that survives the family", because ∫ f dc has no zero-shaped answer and answering one with a value while refusing the other would be incoherent. The two are not the same case:

    • d(sin(pi))/d(pi) is settled. Decide pi is constant and the expression does not vary, so the answer is 0. Refusing it declines something the library can answer.
    • ∫ f dpi has nothing to solve, so leaving it unevaluated is the existing "I could not settle this" doing its job — not a refusal of something settled.

    So this is the first option in the original post, Integrate and Limit are untouched, and it costs no exception, no new API and no signature change anywhere.

    Two corrections to the original post

    x! needed the guard higher than "where". I wrote that Variable.InnerDifferentiate is where the comparison happens and that a guard at Differentiate(Variable) "would cover every route in one place". The first half is right and insufficient: Factorialf and Derivativef build a Derivativef node instead of recursing, so the chain rule's base case is never reached and "x!".Differentiate(MathS.pi) stayed unevaluated. It is 0 — the variable settles it whatever the expression is — so the guard is on the public overloads, with Variable.InnerDifferentiate carrying it too for the internal callers that reach the chain rule directly.

    And the guard cannot be name-based. variable.Evaled is Number, not "is it spelled pi". A name a binder declares can vary even when it is spelled pi, and it evaluates to itself — so a spelling test would undo #984. I measured the predicate on master and on the #984 branch before writing it.

    Scope, and how I found I had over-reached

    My first attempt guarded Derivativef.InnerSimplify as well, so that the node form answered 0 too. Five tests failed when I composed it with #991, and they were right to: with that PR in, the parser binds pi in derivative(_, pi), so derivative(pi ^ 2, pi) is 2 * pi_1 — a derivative over the variable the binder holds. This issue's own text says so — "this call has no binder in it" — and I had gone past it. The guard is now on the direct API only.

    Composed with #990, #991 and #1003 on a local integration branch: 7484 passed, 0 failed, each behaviour intact.

    sin(pi) by MathS.pi          0
    derivative(pi ^ 2, pi)       2 * pi_1
    derivative(x ^ 3, 3)         declined
    "x ^ 4".Differentiate(x, 3)  2 * x * 3 * 4
    
  3. added theissue type on Sep 22, 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

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions