Repository navigation
Let a binder bind i, in every binder there is (#976) - #986
Merged
Merged
Conversation
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>
This was referenced Aug 19, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #976. Replaces #979, which I am closing unmerged — it patched the
MathSentry 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
iis 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:sum(i, i, 1, 10)55sum(2i, i, 1, 3)6i12integral(i, i)-1/2 + Ci ^ 2 / 2 + Climit(i, i, 0)0derivative(i ^ 2, i)02 * i{ i : i > 0 }NaN{ i : i > 0 }lambda(i, i + 1)InvalidArgumentParseExceptionintegral(i, i)is the one that shows it was never merely missing:-1/2 + Cis 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.CalculusOperatorcovers the five calculus operators andConditionalSetthe set builder, so the parser,MathS.Sumandnew 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 pinnedantlr-4.13.1-complete.jar; the generated diff is exactly the nine lines of the changed action.2iis 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 namesiit is the writer's2beside the writer'si—2kthere is a product — and that is what turns6iinto12.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 becausesqrt(-1)isPowf(-1, 1/2)at parse time and not a complex literal, so it is not a writteniand is not renamed.How SymPy resolves it — measured, 1.14: it names the constant
I, so lowercaseiis free, and it refuses to bind the constant (Sum(I, (I, 1, 10))is aValueError,Lambda(I, I + 1)aBadSignatureError). It buys consistency by giving up the notation — nobody writes∑_I.integral(i, i)→i ^ 2 / 2 + C, which is whatintegral(k, k)gives with the name changed. Under this reading there is nothing special left about it.Should
ibecome aVariable? 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 howiis 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 "
eandpineed nothing — they already work, everywhere". That was measured on too few paths and it is wrong.Variable.ConstantListis keyed by name, so a variable calledeis2.718…wherever it is read — a boundeand the constanteare the same object, and nothing downstream can tell them apart. That is the opposite ofi's problem and it is whyiwas fixable at all: aVariablenamediis distinct fromComplex.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
masterwith ordinary names: #985.Measured
PublicApi.txtneeded no edit.Variablenearly every bound name is. Node construction over three runs each — master7.66 / 8.02 / 8.10 ns, this branch7.84 / 7.85 / 8.25 ns. That is the same number.\sum_{i=1}^{3}2 ifor the index against\mathrm{i}for the unit.BREAKING-CHANGES.mdrecords all seven changed answers, including Let i be the loop variable when it is named as one (#976) #981's, which was never recorded.