Skip to content

Let a binder bind i, in every binder there is (#976) - #986

Merged
Rafael-SOWNet merged 1 commit into
masterfrom
feat/binder-shadows-imaginary-unit
Aug 19, 2026
Merged

Rafael-SOWNet merged 1 commit into
masterfrom
feat/binder-shadows-imaginary-unit

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Member

Closes #976. Replaces #979, which I am closing unmerged — it patched the MathS entry points, and after your "(2) would probably be most mathematically close to how we denote in math" that is the wrong place. This is (2).

What was wrong

i is the imaginary unit and the lexer decides it — NUMBER: ... | 'i' — so it never reaches the rule that makes variables and cannot be a bound name anywhere in the language. Every binder handed one did something other than bind:

written was is
sum(i, i, 1, 10) unevaluated 55
sum(2i, i, 1, 3) 6i 12
integral(i, i) -1/2 + C i ^ 2 / 2 + C
limit(i, i, 0) unevaluated 0
derivative(i ^ 2, i) 0 2 * i
{ i : i > 0 } NaN { i : i > 0 }
lambda(i, i + 1) InvalidArgumentParseException the lambda

integral(i, i) is the one that shows it was never merely missing: -1/2 + C is the symbol read as the variable in one place and as the constant in another, in one answer.

Where it is done

One type, Core/Binding.cs, holding the decision; the nodes ask it, not the ways in. CalculusOperator covers the five calculus operators and ConditionalSet the set builder, so the parser, MathS.Sum and new Summationf(...) all get the same reading and none of them has to remember to.

A lambda's parameter is typed Variable, so it is the one binder that cannot be handed the unit at all — no node can read what it cannot hold. That one is read in the grammar, and the parser is regenerated from the pinned antlr-4.13.1-complete.jar; the generated diff is exactly the nine lines of the changed action.

2i is one token, so a written coefficient on the bound name arrives as a single number with nothing in it to rename. Under a binder that names i it is the writer's 2 beside the writer's i — 2k there is a product — and that is what turns 6i into 12.

Your four questions

sum(sqrt(-1) * i, i, 1, 10) → 55i. The sum's index, times the imaginary unit. That is what SymPy gives, and it works here because sqrt(-1) is Powf(-1, 1/2) at parse time and not a complex literal, so it is not a written i and is not renamed.

How SymPy resolves it — measured, 1.14: it names the constant I, so lowercase i is free, and it refuses to bind the constant (Sum(I, (I, 1, 10)) is a ValueError, Lambda(I, I + 1) a BadSignatureError). It buys consistency by giving up the notation — nobody writes ∑_I.

integral(i, i) → i ^ 2 / 2 + C, which is what integral(k, k) gives with the name changed. Under this reading there is nothing special left about it.

Should i become a Variable? I built it to find out: three lines and a regenerated lexer. It works, and it costs 53 tests — 42 LaTeX juxtaposition, 5 SymPy export, the rest round-trips — all about how i is written. This PR costs 0. It is a real option for a major version and its price is now measured rather than guessed; I did not want to spend it here.

A correction, and a new issue

I told you on #979 that "e and pi need nothing — they already work, everywhere". That was measured on too few paths and it is wrong.

derivative(e ^ 2, e)    0          should be 2 * e
derivative(pi ^ 2, pi)  0          should be 2 * pi
{ e : e > 0 }           { e : True }
limit(e, e, 0).Evaled   2.71828…   .Simplify() gets it right, and gives 0

Variable.ConstantList is keyed by name, so a variable called e is 2.718… wherever it is read — a bound e and the constant e are the same object, and nothing downstream can tell them apart. That is the opposite of i's problem and it is why i was fixable at all: a Variable named i is distinct from Complex.ImaginaryOne. Filed with the measurements as #984; the tests here pin the four wrong answers so nobody re-derives them.

Two unrelated export defects turned up while checking that the new forms round-trip, both reproducing on master with ordinary names: #985.

Measured

  • Suite 7353 passed, 0 failed, 14 skipped — 29 new tests.
  • Corpus 116/119, 0 wrong, 0 error, 0 timeout — unchanged.
  • Public surface unchanged; PublicApi.txt needed no edit.
  • Cost: one type test on the way into a binder node, which fails for the Variable nearly every bound name is. Node construction over three runs each — master 7.66 / 8.02 / 8.10 ns, this branch 7.84 / 7.85 / 8.25 ns. That is the same number.
  • Round trips, LaTeX and SymPy export all check out, and LaTeX even distinguishes them: \sum_{i=1}^{3}2 i for the index against \mathrm{i} for the unit.
  • BREAKING-CHANGES.md records all seven changed answers, including Let i be the loop variable when it is named as one (#976) #981's, which was never recorded.

i is the imaginary unit and the lexer decides it -- NUMBER: ... | 'i' -- so it
never reaches the rule that makes variables and cannot be a bound name anywhere
in the language. Every binder handed one did something other than bind: a sum
bound nothing and stayed unevaluated, a set builder answered NaN, a lambda
threw, an integral read the same symbol as the variable in one place and as the
constant in another and produced -1/2 + C.

    sum(i, i, 1, 10)      unevaluated                 ->  55
    sum(2i, i, 1, 3)      6i                          ->  12
    integral(i, i)        -1/2 + C                    ->  i ^ 2 / 2 + C
    limit(i, i, 0)        unevaluated                 ->  0
    derivative(i ^ 2, i)  0                           ->  2 * i
    { i : i > 0 }         NaN                         ->  { i : i > 0 }
    lambda(i, i + 1)      InvalidArgumentParseException -> the lambda

#981 did this for sum and product at the MathS entry points. That is the wrong
place: it reaches neither the other binders nor the constructors, and the
entry points are not where a binder decides what it binds. Core/Binding.cs is
that decision, written once, and the nodes ask it -- CalculusOperator for the
five calculus operators and ConditionalSet for the set builder -- so the parser,
MathS and `new Summationf(...)` all get the same reading. A lambda's parameter
is typed Variable and so cannot be handed the unit at all; that one is read in
the grammar, which is the only place it can be.

2i is one token, so a written coefficient on the bound name arrives as a single
number with nothing in it to rename. Under a binder that names i it is the
writer's 2 beside the writer's i, exactly as 2k is a product, and that is what
turns 6i into 12 above.

Only inside the binder that declares it, which is what makes this a fix rather
than a new defect:

    sum(i * k, k, 1, 3)            6i, unchanged
    sum(i, i, 1, 3) + i            6 + i, unchanged
    sum(sqrt(-1) * i, i, 1, 10)    55i

The last is the sum's index times the imaginary unit, and it is what SymPy
answers for Sum(sqrt(-1) * i, (i, 1, 10)) -- which resolves the same collision
by naming the constant I and refusing to bind it.

e and pi needed nothing here: they are variables carrying a value, so a binder
has always taken those names. They are also not repaired by this -- a bound e
still means 2.718, because the bound name and the constant are one object.
Measured and filed as #984.

Costs one type test on the way into a binder node, which fails for the
Variable nearly every bound name is. Node construction is 7.66-8.10ns on master
and 7.84-8.25ns here over three runs each, which is the same number.

Suite 7353 passed, 0 failed, 14 skipped. Corpus 116/119, 0 wrong, 0 error,
0 timeout. Public surface unchanged. The parser is regenerated from the pinned
antlr-4.13.1-complete.jar and the diff is the nine lines of the grammar action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Rafael-SOWNet
Rafael-SOWNet merged commit 4ee698d into master Aug 19, 2026
26 checks passed
Rafael-SOWNet added a commit that referenced this pull request Aug 20, 2026
#984)

Happypig375 on #991: "If there's a pi that doesn't become 3.14159... then we have
produced an expression that doesn't parse back to itself." Right, and the PR body
called it a known boundary -- a boundary that gives a wrong answer when the printed
form is read back is a defect.

Measured which operations reach it, with a roundtrip:: probe that simplifies, prints,
reads the printed form back and compares entities:

    derivative(N ^ 2, N)      differs for pi, e and i; survives for an ordinary name
    integral(N ^ 2, N)        differs for pi, e and i; survives for an ordinary name
    everything else           survives

Two operations, and they are the two that *return* the bound name rather than
consuming it. A sum answers a number, a set builder keeps the name inside itself,
and those print as they were written. So the name only has to be writable where it
leaves, and renaming a bound variable is free -- it is the same function either way.
Binding.Written does it at those two sites: 2 * pi_1, pi_1 ^ 3 / 3 + C.

This covers i, which has had the same answer since #986 -- derivative(i ^ 2, i) was
2 * i, which reads back as the number 2i. It is 2 * i_1 now. An ordinary name is
never renamed.

Probing that objection found the last name-keyed path in the library:

    protected override Entity InnerDifferentiate(Variable variable)
        => Name == variable.Name ? 1 : 0;

A constant that simplification produces inside a binder over that name compares equal
by name to the index, so the product rule differentiated it as well:

    derivative(arccos(0) * pi, pi)   0                       ->  pi / 2
    derivative(ln(x) * e, e)         0 provided not x = 0    ->  ln(x) provided not x = 0

pi / 2 is what the same expression over an ordinary name answers. Comparing the node
rather than the name fixes it, and `Name ==` now has no occurrences in the library.

Suite 7434 passed, 0 failed, 14 skipped; F# wrapper 134 passed. Corpus 116/119,
0 wrong, 0 error, 0 timeout. Public surface unchanged from the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rafael-SOWNet added a commit that referenced this pull request Aug 22, 2026
* A name a binder declares is a variable, including pi and e (#984)

Variable.ConstantList was keyed by name and Variable.InnerSimplify read it, so a
variable called e was 2.718 wherever it was read -- binder or no binder. The tree
carried nothing that said an occurrence was bound, so nothing downstream could tell
a declared name from the constant of that name.

    derivative(e ^ 2, e)             0             ->  2 * e
    derivative(pi ^ 2, pi)           0             ->  2 * pi
    { e : e > 0 }                    { e : True }  ->  { e : e > 0 }
    limit(e, e, 0).Evaled            2.718...      ->  0
    integral(e, e, 0, 1).Evaled      3.694...      ->  1/2
    integral(arccos(0), pi, 0, 1)    1/4           ->  pi / 2

Entity.Constant is the mechanism: a constant is a node, and a binder replaces the
occurrences it was handed with a plain Variable of that name. It derives from
Variable so that everything reading a leaf by name keeps working -- Vars,
Substitute and Solve are all typed Variable -- and the public surface is additive,
13 members added and none removed. Rational : Real : Complex is the same shape,
with the same SealedOrAbstract exemption.

Nothing carries a scope downstream and nothing needed to. Binding is decided at
construction, in the node's initialiser, which is also why the last row above needs
no scope-aware evaluator: arccos(0) is pi / 2, and the pi that produces is a
different occurrence from the one the binder replaced.

A binder binds occurrences and not values, so the second distinction is identity
rather than equality. Every constant a writer types is the one object in
NamedConstants, because the parser and MathS.pi hand that object back; ln and exp
are built over Constant.EulerIntrinsic, a separate object, because Euler's number
in an operator's own definition is a value and not a mention of the name e.

    sum(ln(x), e, 1, 2)      log(1, x) + log(2, x)  ->  2 * ln(x)
    sum(exp(x), e, 1, 2)     1 + 2 ^ x              ->  2 * e ^ x
    sum(ln(e), e, 1, 1)      NaN, being log(1, 1)   ->  0
    ln(x).Substitute("e", 3) log(3, x)              ->  ln(x)

    sum(log(e, x), e, 1, 2)  log(1, x) + log(2, x), unchanged -- written, so bound
    sum(e ^ x, e, 1, 2)      1 + 2 ^ x, unchanged            -- likewise

The two are ==, print alike and hash alike, so every rule matches both and nothing
about canonical form, substitution or equality changes: e ^ 2 * exp(3) is still
e ^ 5. Making them unequal instead was tried and is worse -- it breaks that
collection, and normalising them back together makes InnerSimplify not idempotent.

All seven binders read the same Binding: the five calculus operators through
CalculusOperator, the set builder, and now Lambda in the node rather than only
where the grammar builds one. The name-keyed paths are gone with it -- ToSymPy
exported a bound e as sympy.E, LaTeX set it upright as a constant, and the
compiler substituted it by name.

Free pi and e are untouched: sin(pi) is 0, ln(e) is 1, arccos(0) is pi / 2, and
MathS.pi is still typed Variable. Two consequences to read for, both in
BREAKING-CHANGES.md: Vars reports a bound constant's name, since it is a variable
there, and substituting the constant no longer reaches one.

Suite 7426 passed, 0 failed, 14 skipped; F# wrapper 134 passed. Corpus 116/119,
0 wrong, 0 error, 0 timeout. Public surface additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Rename a bound name that outlives its binder, so the answer reads back (#984)

Happypig375 on #991: "If there's a pi that doesn't become 3.14159... then we have
produced an expression that doesn't parse back to itself." Right, and the PR body
called it a known boundary -- a boundary that gives a wrong answer when the printed
form is read back is a defect.

Measured which operations reach it, with a roundtrip:: probe that simplifies, prints,
reads the printed form back and compares entities:

    derivative(N ^ 2, N)      differs for pi, e and i; survives for an ordinary name
    integral(N ^ 2, N)        differs for pi, e and i; survives for an ordinary name
    everything else           survives

Two operations, and they are the two that *return* the bound name rather than
consuming it. A sum answers a number, a set builder keeps the name inside itself,
and those print as they were written. So the name only has to be writable where it
leaves, and renaming a bound variable is free -- it is the same function either way.
Binding.Written does it at those two sites: 2 * pi_1, pi_1 ^ 3 / 3 + C.

This covers i, which has had the same answer since #986 -- derivative(i ^ 2, i) was
2 * i, which reads back as the number 2i. It is 2 * i_1 now. An ordinary name is
never renamed.

Probing that objection found the last name-keyed path in the library:

    protected override Entity InnerDifferentiate(Variable variable)
        => Name == variable.Name ? 1 : 0;

A constant that simplification produces inside a binder over that name compares equal
by name to the index, so the product rule differentiated it as well:

    derivative(arccos(0) * pi, pi)   0                       ->  pi / 2
    derivative(ln(x) * e, e)         0 provided not x = 0    ->  ln(x) provided not x = 0

pi / 2 is what the same expression over an ordinary name answers. Comparing the node
rather than the name fixes it, and `Name ==` now has no occurrences in the library.

Suite 7434 passed, 0 failed, 14 skipped; F# wrapper 134 passed. Corpus 116/119,
0 wrong, 0 error, 0 timeout. Public surface unchanged from the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Correct a Substitute claim that measurement does not support (#984)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

Sum and product improvements

1 participant