Skip to content

Add sum and product, the first operators that bind a variable over a range (#248) - #966

Merged
Rafael-SOWNet merged 1 commit into
masterfrom
feat/summation-product
Aug 16, 2026
Merged

Rafael-SOWNet merged 1 commit into
masterfrom
feat/summation-product

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Member

Closes #248 for Σ and Π. The n-ary set operators (⋃, ⋂) are not included — see below.

sum(k, k, 1, 10)        55
product(k, k, 1, 5)     120
sum(k^2, k, 1, 4)       30
sum(k, k, 1, n)         sum(k, k, 1, n)      — carried, not refused
sum(x, k, 1, 3)         3 * x
sum(k, k, 5, 1)         0                    — empty sum
product(k, k, 5, 1)     1                    — empty product

Nodes, not a parser trick

The bounds may be symbolic, and sum(k, k, 1, n) is a well-formed expression with no finite expansion. A parser-level version that only worked for concrete bounds would have to change shape later, so this is Summationf and Productf as CalculusOperators alongside Integralf and Limitf.

Where the bounds are concrete integers and there are at most 100 terms it expands; otherwise it is carried. A thousand-term expansion is correct and useless, and everything downstream then walks it.

The empty range returns the operator's identity explicitly — 0 for a sum, 1 for a product — rather than letting the accumulator's initial value fall out of the loop, where it would read as a coincidence rather than a decision.

The index is bound

This is the part that is easy to get wrong, and #878 is it going wrong for set-builders. Substitute alpha-renames before substituting, exactly as Integralf does:

"sum(k, k, 1, n)".ToEntity().Substitute("k", 5)   // unchanged — k is the index
"sum(k, k, 1, n)".ToEntity().Substitute("n", 3)   // 6 — n is free

Both are pinned by tests.

Consistent with the binders already here, rather than inventing conventions

I checked each against integral, lambda, apply and ConditionalSet before choosing:

sum/product matches
Vars exposes the index yes integral(y, x) → y,x; lambda(x, x^2) → x
solving for an un-invertible binder { } apply(f, x) → { }
differentiating / limiting one unevaluated operator #958's lesson — not NaN
DomainCondition True Integralf

One of those is worth flagging rather than hiding. sum(k, k, 1, n) = 0 solved for n answers { }, and n = 0 is a solution (the empty sum is 0). That is the existing convention for a binder the solver cannot invert — apply(f, x) does the same — so I matched it rather than deviating in one new node. If that convention is wrong it is wrong in three places and should be changed in all of them; #964 is the same family.

i is the imaginary unit

sum(i, i, 1, 10) is left unevaluated, because i is not a Variable and so binds nothing. Every textbook writes this sum with i, so it is documented on the parameter and pinned by a test. It declines rather than guessing.

Evidence

  • suite 7297 passed, 0 failed; 17 new tests
  • PublicApi.txt regenerated
  • the parser was regenerated from the grammar with the vendored ANTLR jar and the post-processor, so the committed parser matches AngouriMath.g — Fail CI when the committed parser no longer matches its grammar (#898) #965 adds a CI check for exactly that
  • LaTeX (\sum_{k=1}^{n}, \prod_{k=1}^{n}), Stringize round-trip, and sympy.Sum/sympy.Product export are all covered

Not included

⋃ and ⋂, the other two operators the issue names. They bind over a family of sets rather than a numeric range, and the set layer has its own union/intersection already; that is a separate design question and I would rather it were decided than assumed.

This also does not add => or juxtaposition syntax (#495) or quantifiers (#225) — but it establishes the pattern both would follow, which is what makes them cheaper now.

🤖 Generated with Claude Code

…range (#248)

    sum(k, k, 1, 10)        55
    product(k, k, 1, 5)     120
    sum(k^2, k, 1, 4)       30
    sum(k, k, 1, n)         sum(k, k, 1, n)      -- carried, not refused
    sum(x, k, 1, 3)         3 * x
    sum(k, k, 5, 1)         0                    -- empty sum
    product(k, k, 5, 1)     1                    -- empty product

Nodes rather than a parser trick, because the bounds may be symbolic and
sum(k, k, 1, n) is a well-formed expression with no finite expansion. Where the
bounds are concrete integers and there are few enough terms it expands; otherwise it
is carried. The cap is 100 terms: a thousand-term expansion is correct and useless,
and everything downstream then walks it.

The empty range returns the operator's identity explicitly -- 0 for a sum, 1 for a
product -- rather than letting the accumulator's initial value fall out of the loop,
where it would read as a coincidence rather than a decision.

**The index is bound**, which is the whole point of the exercise and the part that is
easy to get wrong: Substitute alpha-renames before substituting, exactly as Integralf
does, so sum(k, k, 1, n).Substitute("k", 5) is unchanged while .Substitute("n", 3)
gives 6. #878 was this bug for set-builders.

Consistent with the existing binders rather than inventing conventions: Vars exposes
the index as integral and lambda do; an un-invertible binder answers the empty set as
apply(f, x) does; differentiating or taking a limit of one returns the unevaluated
operator rather than NaN, which is #958's lesson.

i is the imaginary unit, so it cannot be an index -- sum(i, i, 1, 10) is left
unevaluated rather than guessed at. Documented on the parameter and pinned by a test,
since every textbook writes this sum with i.

Suite 7297 passed, 0 failed, 17 new tests. PublicApi.txt regenerated.
@Happypig375

Happypig375 commented Aug 16, 2026 •

Copy link
Copy Markdown
Member

It might be desirable to allow redefining how i parses in the sum if it's declared as the loop variable.

Also, refer to WolframAlpha and Sympy if the expression should be at front or end; we write the iteration index and limit in the front in math.

@Rafael-SOWNet
Rafael-SOWNet merged commit 35e504a into master Aug 16, 2026
25 checks passed
Rafael-SOWNet added a commit that referenced this pull request Aug 17, 2026
`sum(i, i, 1, 10)` summed nothing. `i` is the imaginary unit, and that is decided
in the lexer -- `NUMBER: ... | 'i'` -- so it never reaches the rule that makes
variables and cannot be one anywhere in the language. The index arrived as a
number, the operator had no variable to bind, and the whole thing stayed
unevaluated. #966 documented that as a trap; Happypig375 asked for it not to be
one.

    sum(i, i, 1, 10)      unevaluated  ->  55
    product(i, i, 1, 5)   unevaluated  ->  120
    sum(i ^ 2, i, 1, 4)   unevaluated  ->  30

Naming i as the index is a statement about the whole operator, so it is honoured
through the summand and the bounds and not only in the index position. Doing it
there alone would be worse than leaving it alone: the index would become a
variable while every i in the summand stayed the imaginary unit, nothing would
substitute, and sum(i, i, 1, 10) would answer 10i -- a wrong answer in place of
an unevaluated one.

And only inside the operator that declares it. These are unchanged, and they are
what makes the above a fix rather than a new defect:

    sum(i * k, k, 1, 3)     6i
    sum(k + i, k, 1, 2)     3 + 2i
    sum(i, i, 1, 3) + i     6 + i
    i * i                   -1
    sqrt(-1)                i

In MathS.Sum and MathS.Product rather than in the grammar. The first attempt was
a grammar action, which fixed `"sum(i, i, 1, 10)".ToEntity()` and left
`MathS.Sum("i", "i", 1, 10)` broken -- one library with two answers, since the
string conversion is the only thing the parser sees. Putting it at the factory
covers both, and leaves the parser untouched, so there is no regeneration and
nothing for the grammar-drift check from #965 to catch.

The class remark on SummationProductTest taught the trap and now records that it
is gone. The test that pinned the old answer is replaced by cases for both
halves -- the index being read, and i still being the imaginary unit everywhere
else.

Suite 7318 passed, 0 failed, 14 skipped.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Rafael-SOWNet added a commit that referenced this pull request Aug 17, 2026
`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.
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.

N-ary operators with variables

2 participants