Skip to content

Let an index named i shadow the imaginary unit in sum and product (#976) - #979

Open
Rafael-SOWNet wants to merge 1 commit into
masterfrom
feat/sum-index-shadows-i
Open

Let an index named i shadow the imaginary unit in sum and product (#976)#979
Rafael-SOWNet wants to merge 1 commit into
masterfrom
feat/sum-index-shadows-i

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

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.

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 that and pinned it, which was
honest but is not what a reader means by it. Declaring i as the index now shadows the constant
inside the body.

Every expression whose index was i used to come back exactly as written, because the expansion
begins if (var is not Variable index) return null; and a number is not a Variable:

Simplify before after
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, i, 1, i) carried carried — i is still the constant in the bound
sum(i, k, 1, 3) 3i 3ii was not declared here
sum(sqrt(-1), i, 1, 3) 3i 3i — denotes the constant without naming it

Three decisions, stated rather than left to be found

It is the name that is shadowed, not the value. 2i is a single number token, so it is written
with the letter being shadowed and becomes twice the index; otherwise sum(2i, i, 1, 3) and
sum(2 * i, i, 1, 3) would be one expression with two answers. sqrt(-1) denotes the constant
without 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 integer
bound, 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.Sum and not in the grammar action

"i" becomes an Entity by being parsed, so MathS.Sum("i", "i", 1, 10) arrives exactly as the
parsed text does. Putting the shadowing in the grammar would fix "sum(i, i, 1, 10)".ToEntity() and
leave 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

  • suite 7323 passed, 0 failed; 9 new tests in SummationProductTest.cs, and the one that pinned
    the old behaviour (TheImaginaryUnitIsNotAnIndex) is replaced rather than loosened — the answer
    got better, which is the case AGENTS.md step 4 asks to name.
  • the round-trip theory gains sum(i, i, 1, n) and product(i^2, i, 1, n): a shadowed index prints
    as i, so the round trip only closes because parsing the printed form shadows it again.
  • PublicApi.txt unchanged — the helper is private and no signature moved.
  • no BREAKING-CHANGES.md entry, and deliberately: sum and product are in no released
    version (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.

`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.
@Rafael-SOWNet
Rafael-SOWNet force-pushed the feat/sum-index-shadows-i branch from 1e1ff69 to 6b3a388 Compare August 17, 2026 23:52
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

Rebuilt on current master — the conflict is gone, and the scope of this PR has changed, so please read this before merging.

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 MathS.Iterated (from #981) and replaces it with ShadowImaginaryUnitIndex. Only #981 had touched those two files since this branch was cut, so nothing else is lost in the swap.

I would not normally ask to replace merged work. The reason here is that #981 is wrong on a case this gets right:

master (has #981) this
sum(i, i, 1, 10) 55 55
sum(i * 2, i, 1, 3) 12 12
sum(2i, i, 1, 3) 6i 12
product(2i, i, 1, 3) -8i 48
sum(3i, i, 1, 2) 6i 9

2i lexes as a single Complex token rather than as 2 * i, and #981 only matches a bare i — so on master today the same symbol is read two ways inside one binder depending on whether it carries a coefficient. sum(2i, i, 1, 3) answering 6i while sum(2 * i, i, 1, 3) answers 12 is one expression given two answers.

This branch handles it by shadowing the name: a pure-imaginary literal becomes ImaginaryPart * index, guarded with is not Real so an ordinary 2 is untouched, and sqrt(-1) is left alone because it denotes the constant without naming it.

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 2i handling to Iterated and leaves the bounds as they are — say which you prefer and I will do that instead.

@Happypig375

Happypig375 commented Aug 18, 2026

Copy link
Copy Markdown
Member

Remember to do this for e and pi as well. This shadowing should generalize over all named constants

How about other operators like lambda?

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

Measured both questions on current master (34f72d1), and the answer to the first is not what either of us assumed.

e and pi need nothing — they already work, everywhere

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:

  1. Leave i alone in binders and refuse instead. lambda(i, ...) and { i : ... } become stated errors rather than a throw and a NaN. Smallest, and honest, but sum(i, i, 1, 10) stops working — undoing Let i be the loop variable when it is named as one (#976) #981.
  2. Handle it once, where a binder takes its bound name, so every binder gets it together, including 2i. Biggest, and the only one that makes i behave like e.
  3. Keep Let i be the loop variable when it is named as one (#976) #981 and add the 2i case to Iterated only — what I offered above. Cheapest, leaves lambda and 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.

@Happypig375

Copy link
Copy Markdown
Member

(2) would probably be most mathematically close to how we denote in math. There are a few architectural questions:

Should i become a Variable that means sqrt(-1) instead? How should integrate(i, i) work? What happens if we encounter something like sum(sqrt(-1)*i,i,1,10)? How does Wolfram Alpha and Sympy resolve this issue, and would it differ from written mathematics?

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

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 it

SymPy 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*I

It sidesteps the collision rather than resolving it: the constant is I, so lowercase i is free and binds like any symbol. Binding the constant itself is refused loudly in all three binders. And the mixed case is unambiguous precisely because the two are different symbols.

That differs from written mathematics, which is the tension you named — nobody writes ∑_I, everybody writes ∑_i. SymPy buys its consistency by giving up the notation.

Should i become a Variable?

The mechanism is already there, and i is one dictionary entry away from it:

internal static readonly Variable pi = new Variable(nameof(pi));
internal static readonly Variable e  = new Variable(nameof(e));
internal static readonly IReadOnlyDictionary<string, Complex> ConstantList =
    new Dictionary<string, Complex> { { nameof(pi),}, { nameof(e),} };   // no "i"

e and pi are plain variables whose names carry a value, which is why every binder already shadows them and why sum(e, e, 1, 3) is 6 with no special-casing anywhere. So I tried it: added { "i", Complex.ImaginaryOne }, dropped 'i' from the NUMBER lexer rule, regenerated the parser.

It works, and it fixes the case neither #979 nor #981 handles:

i ^ 2                     -> -1        (1 + i) * (1 - i) -> 2      2i -> 2i
sum(i, i, 1, 10)          -> 55        sum(2i, i, 1, 3)  -> 12     product(2i, i, 1, 3) -> 48
lambda(i, i + 1)          -> builds instead of throwing
sum(sqrt(-1) * i, i, 1, 10) -> 55i     — the same answer SymPy gives

But it costs 53 tests, and it does not finish the job. The 53:

42 LatexTests.JuxtapositionWithNumbers
5 ToSymPyTest.TestSymPyi 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.

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.

2 participants