Let an index named i shadow the imaginary unit in sum and product (#976) - #979
Let an index named i shadow the imaginary unit in sum and product (#976)#979Rafael-SOWNet wants to merge 1 commit into
i shadow the imaginary unit in sum and product (#976)#979Conversation
`i` lexes as the imaginary unit -- the NUMBER rule in AngouriMath.g ends `| 'i'` -- so `sum(i, i, 1, 10)`, which is how a textbook writes that sum, arrived with a number in the index position, bound nothing, and was carried unevaluated. #966 documented and pinned that, which was honest but is not what a reader means by it. An index is a name and the imaginary unit is not one, so no reading of the old behaviour has the caller meaning the constant: the declaration is now taken seriously and shadows it inside the body. sum(i, i, 1, 10) carried -> 55 product(i, i, 1, 4) carried -> 24 sum(2i, i, 1, 3) carried -> 12 sum(2 + 3i, i, 1, 2) carried -> 13 sum(i, k, 1, 3) 3i -> 3i -- i was not declared here sum(sqrt(-1), i, 1, 3) carried -> 3i -- denotes it without naming it sum(i, i, 1, i) carried -> carried, i still the constant in the bound It is the name that is shadowed rather than the value, which decides the three cases that are not the obvious one. A pure-imaginary literal is rewritten because `2i` is a single number token: reading `sum(2i, i, 1, 3)` as `6i` while `sum(2 * i, i, 1, 3)` is `12` would answer one expression two ways. `sqrt(-1)` names nothing and keeps its value. The bounds sit outside the binder, in the scope the declaration is made in, so the constant survives there and a range ending at it is carried for want of an integer bound. In MathS.Sum and MathS.Product rather than in the grammar action, so that the parser and `MathS.Sum("i", "i", 1, 10)` agree -- `"i"` becomes an Entity by being parsed, so the C# call arrives exactly as the text does -- and so the parser needs no regeneration. The test that pinned the old answer is replaced rather than loosened: the answer got better. The round-trip theory gains a shadowed index, which only closes because parsing the printed form shadows it again. Suite 7323 passed, 0 failed. No BREAKING-CHANGES entry: sum and product are in no released version, since v2.2.0 predates #966, so no observable answer changes.
1e1ff69 to
6b3a388
Compare
|
Rebuilt on current This now supersedes #981, which is already merged. #981 solved the same issue a different way while this sat open; I did not spot the overlap until after it landed. Rebuilding this on top meant resolving both conflicted files in favour of this branch, so merging it removes I would not normally ask to replace merged work. The reason here is that #981 is wrong on a case this gets right:
This branch handles it by shadowing the name: a pure-imaginary literal becomes It also leaves the bounds alone, which #981 does not. They lie outside the binder, in the scope the declaration is made in, so the imaginary unit is still the imaginary unit there. Suite 7328 passed, 0 failed, 14 skipped. If you would rather keep #981 as merged, the alternative is a smaller follow-up that only adds the |
|
Remember to do this for e and pi as well. This shadowing should generalize over all named constants How about other operators like lambda? |
|
Measured both questions on current
|
e (a Variable) |
i (a Complex) |
|
|---|---|---|
sum(x, x, 1, 3) |
6 |
6 (since #981) |
sum(2 * x, x, 1, 3) |
12 |
12 (since #981) |
sum(2x, x, 1, 3) |
12 |
6i |
product(2x, x, 1, 3) |
6 |
-8i |
integral(x, x) |
e ^ 2 / 2 + C |
-1/2 + C |
limit(x, x, 0) |
0 |
unevaluated |
lambda(x, x + 1) |
lambda(e, 1 + e) |
InvalidArgumentParseException |
{ x : x > 0 } |
{ e : True } |
NaN |
The reason is one line in MathS:
[ConstantField] public static readonly Variable e = Variable.e;
[ConstantField] public static readonly Variable pi = Variable.pi;
[ConstantField] public static readonly Complex i = Complex.ImaginaryOne;e and pi are variables that happen to carry a known value, so every binder in the language already shadows them by construction — nothing special was ever needed and nothing is needed now. i is a number, so no binder can bind it.
So generalising the shadowing "over all named constants" would add machinery for two constants that do not need it. I would rather not, unless you see a case I have not measured — and if a fourth named constant is ever added as a Variable, it inherits the correct behaviour for free.
Other operators: yes, and it is worse than in sum
lambda(i, i + 1) throws, and { i : i > 0 } is NaN — a wrong answer rather than a refusal. integral(i, i) is -1/2 + C, which is neither reading done consistently: it integrated i as though it were the variable and then evaluated the symbol as the constant.
What I think this means for the fix
The generalisation worth having is over binders, for the one constant that is not a variable — the mirror image of the request, and I would not have found it without measuring.
That is a bigger change than this PR, and it argues against this PR's approach: ShadowImaginaryUnitIndex (here) and Iterated (merged, #981) both patch sum and product at the MathS entry points, which does nothing for integral, limit, lambda or the set-builder, and nothing for the parser paths that do not go through MathS.
Three options, and I would like your call before building:
- Leave
ialone in binders and refuse instead.lambda(i, ...)and{ i : ... }become stated errors rather than a throw and aNaN. Smallest, and honest, butsum(i, i, 1, 10)stops working — undoing Let i be the loop variable when it is named as one (#976) #981. - Handle it once, where a binder takes its bound name, so every binder gets it together, including
2i. Biggest, and the only one that makesibehave likee. - Keep Let i be the loop variable when it is named as one (#976) #981 and add the
2icase toIteratedonly — what I offered above. Cheapest, leaveslambdaand the set-builder broken.
I have closed nothing yet. If you want (2) I will withdraw this PR and open one against the binders instead, since the entry-point approach here would be the wrong shape for it.
|
(2) would probably be most mathematically close to how we denote in math. There are a few architectural questions: Should |
|
Took the four questions in order. Two have measured answers now, and one of them cost an experiment I would otherwise have argued for on instinct. How SymPy resolves itSymPy 1.14, run locally: parse_expr('i') # Symbol('i') — an ordinary variable
parse_expr('I') # ImaginaryUnit — is_Symbol == False
Sum(I, (I, 1, 10)) # ValueError: Invalid limits given: ((I, 1, 10),)
Integral(I, I) # ValueError: Invalid limits given: (I,)
Lambda(I, I + 1) # BadSignatureError: signature should be only tuples and symbols
Sum(sqrt(-1)*i, (i, 1, 10)) # 55*IIt sidesteps the collision rather than resolving it: the constant is That differs from written mathematics, which is the tension you named — nobody writes Should
|
| 42 | LatexTests.JuxtapositionWithNumbers |
| 5 | ToSymPyTest.TestSymPy — i emits an unbound symbol where sympy.I is wanted |
| 3 | LatexTests.Pie, LatexTests.Juxtaposition |
| 1 | StringizeRoundTripTest.ANamedConstantRoundTrips(MathS.i) |
| 1 | CircleTest.Circle("(3i)!") |
Every one is about how i is written — none is a wrong answer. 2i stops being one token and becomes 2 * i, so the printers and the SymPy emitter have to learn that i is a named constant, exactly as they already know pi and e. That is a migration rather than a wall, but it is a breaking parser change, and it still leaves integral(i, i), limit(i, i, 0), apply(lambda(i, i + 1), 5) and { i : i > 0 } wrong, because i remains constant-valued and gets folded where a plain variable would not.
So, my recommendation
Stay with (2), the binder-level fix, and file the Variable route separately.
(2) reaches integral, limit, lambda and the set-builder, which the Variable route does not, and it needs no grammar change and no output migration. Handling 2i inside it is the ImaginaryPart * index decomposition this PR already carries — that part survives the rescope.
The Variable route is worth its own issue rather than being lost in this thread: it is the only thing that makes i structurally ordinary, it matches how the codebase already treats its other two named constants, and its cost is now measured rather than guessed. It reads as a major-version change to me.
I will withdraw this PR and open one against the binders unless you would rather I take the Variable route — say which and I will start there instead. Either way #979 as it stands should not merge: it patches the MathS entry points only, which is the wrong shape for what you chose.
Closes the first half of #976. The argument-order half is answered on the issue, since the answer
there turned out to be a measurement rather than a change.
ilexes as the imaginary unit — theNUMBERrule inAngouriMath.gends| 'i'— sosum(i, i, 1, 10), which is how a textbook writes that sum, arrived with a number in the indexposition, bound nothing, and was carried unevaluated. #966 documented that and pinned it, which was
honest but is not what a reader means by it. Declaring
ias the index now shadows the constantinside the body.
Every expression whose index was
iused to come back exactly as written, because the expansionbegins
if (var is not Variable index) return null;and a number is not aVariable:Simplifysum(i, i, 1, 10)product(i, i, 1, 4)sum(2i, i, 1, 3)sum(2 + 3i, i, 1, 2)sum(i, i, 1, i)iis still the constant in the boundsum(i, k, 1, 3)3i3i—iwas not declared heresum(sqrt(-1), i, 1, 3)3i3i— denotes the constant without naming itThree decisions, stated rather than left to be found
It is the name that is shadowed, not the value.
2iis a single number token, so it is writtenwith the letter being shadowed and becomes twice the index; otherwise
sum(2i, i, 1, 3)andsum(2 * i, i, 1, 3)would be one expression with two answers.sqrt(-1)denotes the constantwithout naming it, so it is untouched — the same way shadowing works anywhere else, and the reason
the rule is a rewrite of literals rather than a hunt for the value.
The bounds are outside the binder. They are written in the scope the declaration is made in, so
sum(i, i, 1, i)keeps the imaginary unit in its upper bound and is carried for want of an integerbound, rather than the declaration reaching back into the scope that contains it.
Always on, rather than a setting. An index that is the imaginary unit binds nothing, so there is
no behaviour on the other side of a flag that anybody wants; and a flag would make one text mean two
things depending on ambient state, which is worse than either meaning.
Why it lives in
MathS.Sumand not in the grammar action"i"becomes anEntityby being parsed, soMathS.Sum("i", "i", 1, 10)arrives exactly as theparsed text does. Putting the shadowing in the grammar would fix
"sum(i, i, 1, 10)".ToEntity()andleave the C# call declining — one implementation covers both, and the parser needs no regeneration.
new Entity.Summationf(...)still constructs literally, as every other node does when built by hand.Evidence
SummationProductTest.cs, and the one that pinnedthe old behaviour (
TheImaginaryUnitIsNotAnIndex) is replaced rather than loosened — the answergot better, which is the case AGENTS.md step 4 asks to name.
sum(i, i, 1, n)andproduct(i^2, i, 1, n): a shadowed index printsas
i, so the round trip only closes because parsing the printed form shadows it again.PublicApi.txtunchanged — the helper is private and no signature moved.BREAKING-CHANGES.mdentry, and deliberately:sumandproductare in no releasedversion (v2.2.0 predates Add sum and product, the first operators that bind a variable over a range (#248) #966), so no answer anybody can observe today changes.