diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md
index 07708bdc5..9800c1ae2 100644
--- a/BREAKING-CHANGES.md
+++ b/BREAKING-CHANGES.md
@@ -109,6 +109,7 @@ read first.
| | `"a => a + 3".ToEntity()`, and every lambda written with an arrow | `UnhandledParseException` | `lambda(a, a + 3)` |
| | `"sum(k, k, 1, n)".ToEntity().Simplify()`, and every summation whose body is a polynomial in the index | `sum(k, k, 1, n)` — carried | `piecewise((n + n ^ 2) / 2 provided n >= 0, 0)` |
| | `"sum(k, k, 1, 100000)".ToEntity().Simplify()`, and every concrete range past a hundred terms | `sum(k, k, 1, 100000)` — carried | `5000050000` |
+| | `"product(k, k, 1, n)".ToEntity().Simplify()`, and every product whose body is a monomial in the index | `product(k, k, 1, n)` — carried | `piecewise(n! provided n >= 1, 1)` |
### A polynomial summand is summed in closed form
@@ -147,7 +148,8 @@ answers a different question. `+oo` is refused the same way, so an infinite seri
**A product is unchanged, whatever its body.** `product(k, k, 1, n)` is still carried, and
`factorial(n)` would be a wrong answer for it rather than a missing one: the empty product is `1`
at every `n < 1`, and `factorial` is undefined at the negative integers. Answering it needs the
-same kind of condition the sum now carries, and is not done here.
+same kind of condition the sum now carries, and is not done here. *It is done in the entry below,
+which gives it that condition.*
No Bernoulli numbers are involved. The sum of a degree-`d` polynomial is a polynomial of degree
`d + 1`, so `d + 2` of its values determine it, and those values are short sums computed directly;
@@ -155,6 +157,42 @@ interpolating them recovers the coefficients exactly in rational arithmetic.
[#717](https://github.com/asc-community/AngouriMath/issues/717).
+### A monomial body is multiplied in closed form
+
+The same for `product`, with the narrower reach a product has: a sum of two terms is the sum of
+their sums, and a product of two terms is not the product of their products in any way that
+helps. What separates is the body that **is** one term.
+
+```
+"product(k, k, 1, n)".Simplify() was product(k, k, 1, n)
+ is piecewise(n! provided n >= 1, 1)
+
+"product(2, k, 1, 500)".Simplify() was product(2, k, 1, 500)
+ is 3273390607896141870013189696827599152216642046043064789483291368096133796404674554883270092325904157150886684127560071009217256545885393053328527589376
+```
+
+`product(k^2, k, 1, n)` is `(n!)^2`, `product(2 * k, k, 1, n)` is `n! * 2^n`, and a constant body
+needs no factorial at all — `product(c, k, m, n)` is `c^(n - m + 1)`, symbolic lower bound and
+all.
+
+**The condition is `to >= from`, where the sum's is `to >= from - 1`,** and the one point between
+them is the whole reason. At the empty range the closed form is `c^0`, which is `1` for every `c`
+except zero and undefined there, while the empty product is `1` for every `c` including zero.
+Giving that point to the identity branch keeps a value from becoming an undefinedness, and costs
+nothing, since both branches say `1` there.
+
+**A lower bound that is not a concrete integer of at least one is declined** where the index is in
+the body, rather than conditioned. `product(k, k, a, b)` is `b!/(a-1)!` only for `a >= 1`; below
+that the range runs through zero so the product is `0`, while `(a-1)!` is undefined. That cannot
+share a branch with the empty-range case, because `a < 1` does not make the range empty — a
+piecewise reading "identity otherwise" would be wrong there. So `product(k, k, 0, n)` and
+`product(k, k, m, n)` stay as written.
+
+**What is not one term is carried**, `product(k + 1, k, 1, n)` included. As for the sum, a bound
+that is a number and not a whole one is carried too.
+
+[#717](https://github.com/asc-community/AngouriMath/issues/717).
+
### A lambda is written with an arrow as well as a call
`a => a + 3` was a parse error and is now the same entity as `lambda(a, a + 3)`. Several
diff --git a/Sources/.editorconfig b/Sources/.editorconfig
index 79b5d3c3c..358af3a8c 100644
--- a/Sources/.editorconfig
+++ b/Sources/.editorconfig
@@ -181,3 +181,8 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
[Tests/UnitTests/Core/ClosedFormSummationTest.cs]
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
+
+[AngouriMath/Functions/Algebra/Polynomials/MonomialProduct.cs]
+file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
+[Tests/UnitTests/Core/ClosedFormProductTest.cs]
+file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
diff --git a/Sources/AngouriMath/Convenience/MathS.cs b/Sources/AngouriMath/Convenience/MathS.cs
index e9e63f9c9..7f73e36f3 100644
--- a/Sources/AngouriMath/Convenience/MathS.cs
+++ b/Sources/AngouriMath/Convenience/MathS.cs
@@ -6999,14 +6999,27 @@ public static Entity Sum(Entity expr, Entity var, Entity from, Entity to)
///
/// A product of as runs from
/// to inclusive. Mirrors
- /// exactly, with an empty range
- /// multiplying to 1.
+ /// , with an empty range multiplying to
+ /// 1.
///
/// The factor. It may mention .
/// The index, which this binds.
/// The first value of the index.
/// The last value of the index, inclusive.
- /// The product written out where it can be, and an unevaluated node otherwise.
+ ///
+ /// The product written out where the bounds are concrete integers and there are not too
+ /// many terms; a closed form where the factor is a monomial in the index — a
+ /// narrower class than the sum's, a product having no linearity to take a sum of terms
+ /// apart with; and an unevaluated node otherwise.
+ ///
+ ///
+ /// The closed form's condition is to >= from rather than the sum's
+ /// to >= from - 1: at the empty range itself it would be c ^ 0, which is
+ /// undefined at c = 0 where the empty product is 1. Where the index is in
+ /// the factor the lower bound must be a concrete integer of at least one, since the
+ /// answer is a ratio of factorials and factorial has no value at the negative
+ /// integers.
+ ///
///
///
/// using System;
diff --git a/Sources/AngouriMath/Docs/Usage/Comparison.md b/Sources/AngouriMath/Docs/Usage/Comparison.md
index cf51d607f..301c0ac54 100644
--- a/Sources/AngouriMath/Docs/Usage/Comparison.md
+++ b/Sources/AngouriMath/Docs/Usage/Comparison.md
@@ -248,20 +248,19 @@ Long answers are truncated in the table below at 64 characters, with `…`.
| holonomic | holonomic functions | `expr_to_holonomic exists: True` | `` | absent |
| codegen | compile to a callable | `9` | `9` | answers |
-### One row has moved since the run
-
-**concrete / symbolic summation** was measured as `declines` at `6b93b401` and is answered now:
-`sum(k, k, 1, n)` is `(n + n^2)/2`, carrying the condition `n >= 0` that this library's
-empty-range convention requires and SymPy's reversed-range convention does not. The row is left
-as it was measured rather than edited, because the table is a record of one run at one commit and
-a hand-corrected cell would be a measurement nobody took; it will read `answers` when
-`sympyparity` is next run.
-
-The two rows either side of it have **not** moved. *Infinite series* is still declined —
-`sum(1 / k ^ 2, k, 1, +oo)` needs the zeta function, which the row four below records as absent.
-*Symbolic product* is still declined, and deliberately: `factorial(n)` is undefined at the
-negative integers where the empty product is `1`, so answering it needs a condition of the same
-kind the sum now carries.
+### Two rows have moved since the run
+
+**concrete / symbolic summation** and **concrete / symbolic product** were both measured as
+`declines` at `6b93b401` and are answered now: `sum(k, k, 1, n)` is `(n + n^2)/2` and
+`product(k, k, 1, n)` is `factorial(n)`, each carrying a condition on the range being non-empty
+that this library's empty-range convention requires and SymPy's reversed-range convention does
+not. The rows are left as they were measured rather than edited, because the table is a record of
+one run at one commit and a hand-corrected cell would be a measurement nobody took; they will
+read `answers` when `sympyparity` is next run.
+
+The row between them has **not** moved. *Infinite series* is still declined —
+`sum(1 / k ^ 2, k, 1, +oo)` needs the zeta function, which the row four below records as absent,
+and an infinite bound is refused outright by both closed forms.
### Implemented, but not reachable from outside
diff --git a/Sources/AngouriMath/Docs/Usage/Syntax.md b/Sources/AngouriMath/Docs/Usage/Syntax.md
index 0f75ed9c6..53f8febf4 100644
--- a/Sources/AngouriMath/Docs/Usage/Syntax.md
+++ b/Sources/AngouriMath/Docs/Usage/Syntax.md
@@ -255,8 +255,16 @@ rather than a hundred thousand terms. The answer carries the condition it needs
— because below that the range is empty and the polynomial is not zero; where the bounds are
concrete that condition is decidable and the answer is a number. A bound that is a number and not
a whole one still stays as written, since the index runs over the integers and
-`sum(k, k, 1, 5/2)` is `1 + 2` rather than the polynomial at `5/2`. A `product` stays as written
-whatever its body.
+`sum(k, k, 1, 5/2)` is `1 + 2` rather than the polynomial at `5/2`.
+
+A `product` gets the same treatment over the narrower class its shape allows: a **monomial** in
+the index, since a product has no linearity to take a sum of terms apart with. So
+`product(k, k, 1, n)` is `factorial(n)`, `product(k ^ 2, k, 1, n)` is `factorial(n) ^ 2`, and
+`product(c, k, m, n)` is `c ^ (n - m + 1)`. Its condition is `to >= from` rather than
+`to >= from - 1`, because at the empty range itself the closed form would be `c ^ 0` — undefined
+at `c = 0`, where the empty product is `1`. Where the index appears in the body the lower bound
+must be a concrete integer of at least one, `factorial` having no value at the negative integers;
+`product(k, k, 0, n)` therefore stays as written.
**The declared name means the name, and only inside the operator that declares it.** Two names
show this, because both mean something else in this language:
diff --git a/Sources/AngouriMath/Functions/Algebra/Polynomials/MonomialProduct.cs b/Sources/AngouriMath/Functions/Algebra/Polynomials/MonomialProduct.cs
new file mode 100644
index 000000000..2e65bd464
--- /dev/null
+++ b/Sources/AngouriMath/Functions/Algebra/Polynomials/MonomialProduct.cs
@@ -0,0 +1,108 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using PeterO.Numbers;
+using static AngouriMath.Entity;
+using static AngouriMath.Entity.Number;
+
+namespace AngouriMath.Functions
+{
+ ///
+ /// A product whose body is a monomial in the index, written in closed form:
+ /// product(k, k, 1, n) is factorial(n).
+ ///
+ ///
+ ///
+ /// The sibling of and a narrower thing than it, because a
+ /// product has no linearity to take apart: a sum of two terms is the sum of their sums, and a
+ /// product of two terms is not the product of their products in any way that helps. What is
+ /// left is the body that is a single term — c * k^p — over which the product
+ /// separates into a power of the constant and a power of the factorial.
+ ///
+ ///
+ /// The condition is the same one, for the same reason. An empty range multiplies to
+ /// 1, so product(k, k, 1, n) is 1 at every n < 1, while
+ /// factorial(n) is not — it is undefined at the negative integers, being the gamma
+ /// function's poles. Answering the one with the other unconditionally would turn a value into
+ /// an undefinedness, which is the failure the contract's O4 is about. The identity holds
+ /// where to >= from - 1, and that is what is attached.
+ ///
+ ///
+ /// A positive lower bound where the index is in the body, and that one cannot go in the
+ /// condition. product(k, k, a, b) is b! / (a-1)! only where a >= 1; below
+ /// that the range runs through zero and the product is 0, while (a-1)! is
+ /// undefined. But a < 1 does not mean the range is empty, so it cannot share a
+ /// branch with the empty-range case — a piecewise saying "identity otherwise" would be wrong
+ /// there. So it is decided before anything is built, and a lower bound that is not a concrete
+ /// integer of at least one is declined instead. A constant body has no such restriction,
+ /// there being no factorial in its answer.
+ ///
+ ///
+ internal static class MonomialProduct
+ {
+ ///
+ /// The largest power of the index this will take. The answer carries the factorial to
+ /// that power, so the ceiling is about how big an expression is worth returning.
+ ///
+ private const int MaxPower = 16;
+
+ ///
+ /// product(expression, index, from, to) written in closed form, or
+ /// where the body is not a monomial in the index, or the bounds
+ /// are not ones the identity speaks about.
+ ///
+ internal static Entity? ClosedForm(Entity expression, Entity var, Entity from, Entity to)
+ {
+ if (var is not Variable index)
+ return null;
+ if (!PolynomialSummation.IsWholeOrSymbolic(from) || !PolynomialSummation.IsWholeOrSymbolic(to))
+ return null;
+ if (!TreeAnalyzer.TryGetPolynomial(expression, index, out var monomials))
+ return null;
+
+ // One term, or this is not a monomial and the product does not separate.
+ if (monomials.Count != 1)
+ return null;
+ var only = System.Linq.Enumerable.Single(monomials);
+ if (only.Key.Sign < 0 || !only.Key.CanFitInInt32())
+ return null;
+ var power = only.Key.ToInt32Checked();
+ if (power > MaxPower)
+ return null;
+ var coefficient = only.Value;
+ if (coefficient.ContainsNode(index))
+ return null;
+
+ // How many times the body is multiplied: to - from + 1, which the condition below
+ // guarantees is not negative.
+ var count = (to - from + 1).InnerSimplified;
+ Entity closed = MathS.Pow(coefficient, count);
+
+ if (power > 0)
+ {
+ // b! / (a-1)! needs a - 1 to be a whole number that factorial is defined at,
+ // which is decided here rather than asked in the condition -- see the remarks.
+ if (from.Evaled is not Integer lower || lower.EInteger.Sign < 1)
+ return null;
+ var factorials = (MathS.Factorial(to) / MathS.Factorial(from - 1)).InnerSimplified;
+ closed *= power == 1 ? factorials : MathS.Pow(factorials, Integer.Create(power));
+ }
+
+ // `to >= from` and not `to >= from - 1`, which is where the sum's condition sits.
+ // The two agree everywhere but the empty range itself, and there the closed form is
+ // c^0 -- which is 1 for every c except 0, where it is undefined, while the empty
+ // product is 1 for every c including 0. Handing the empty range to the identity
+ // branch instead keeps a value from becoming an undefinedness, and costs nothing:
+ // both branches say 1 there.
+ return MathS.Piecewise(new[]
+ {
+ new Providedf(closed.InnerSimplified, new GreaterOrEqualf(to, from)),
+ new Providedf(Integer.Create(1), Entity.Boolean.True),
+ }).InnerSimplified;
+ }
+ }
+}
diff --git a/Sources/AngouriMath/Functions/Algebra/Polynomials/PolynomialSummation.cs b/Sources/AngouriMath/Functions/Algebra/Polynomials/PolynomialSummation.cs
index 701cb1483..eaf84894a 100644
--- a/Sources/AngouriMath/Functions/Algebra/Polynomials/PolynomialSummation.cs
+++ b/Sources/AngouriMath/Functions/Algebra/Polynomials/PolynomialSummation.cs
@@ -121,7 +121,7 @@ Entity AtBound(Entity bound)
/// anything symbolic to go through — the symbolic case being the one the closed form
/// exists for.
///
- private static bool IsWholeOrSymbolic(Entity bound)
+ internal static bool IsWholeOrSymbolic(Entity bound)
=> bound.Evaled is Integer || bound.Evaled is not Number;
///
diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Calculus.Classes.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Calculus.Classes.cs
index 0e28140ab..706ac0f8c 100644
--- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Calculus.Classes.cs
+++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Calculus.Classes.cs
@@ -226,8 +226,15 @@ public partial record Productf
private protected override Entity IntrinsicCondition => Boolean.True;
///
+ ///
+ /// The expansion first and the closed form after, as for the sum — and the closed
+ /// form is narrower here, a product having no linearity to take a general polynomial
+ /// apart with. A monomial body is what separates.
+ ///
protected override Entity InnerSimplify(bool isExact) =>
- Summationf.Expanded(this, Expression, Var, From, To, static (a, b) => a * b, 1, isExact) ?? this;
+ Summationf.Expanded(this, Expression, Var, From, To, static (a, b) => a * b, 1, isExact)
+ ?? Functions.MonomialProduct.ClosedForm(Expression, Var, From, To)
+ ?? this;
}
public partial record Limitf
diff --git a/Sources/Tests/UnitTests/Core/ClosedFormProductTest.cs b/Sources/Tests/UnitTests/Core/ClosedFormProductTest.cs
new file mode 100644
index 000000000..092fa1437
--- /dev/null
+++ b/Sources/Tests/UnitTests/Core/ClosedFormProductTest.cs
@@ -0,0 +1,154 @@
+//
+// Copyright (c) 2019-2026 Angouri.
+// AngouriMath is licensed under MIT.
+// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
+// Website: https://am.angouri.org.
+//
+
+using AngouriMath;
+using AngouriMath.Extensions;
+using Xunit;
+
+namespace AngouriMath.Tests.Core
+{
+ ///
+ /// A product whose body is a monomial in the index, answered in closed form:
+ /// product(k, k, 1, n) is factorial(n).
+ /// #717
+ ///
+ ///
+ /// Narrower than the sum's closed form, and not by omission: a sum of two terms is the sum of
+ /// their sums, and a product of two terms is not the product of their products in any way
+ /// that helps. What separates is the body that is one term.
+ ///
+ [Trait("Area", "Core")]
+ public sealed class ClosedFormProductTest
+ {
+ ///
+ /// The closed form agrees with writing the product out, at bounds either side of the
+ /// empty-range boundary — which is where the two could disagree and did.
+ ///
+ private static void AgreesWithTheExpansion(string expression, params int[] bounds)
+ {
+ var closed = expression.ToEntity().Simplify();
+ Assert.IsNotType(closed);
+ foreach (var at in bounds)
+ Assert.Equal(
+ expression.ToEntity().Substitute("n", at).Simplify().Evaled,
+ closed.Substitute("n", at).Simplify().Evaled);
+ }
+
+ /// The factorial, which is the row the survey names.
+ [Theory]
+ [InlineData("product(k, k, 1, n)")]
+ [InlineData("product(k ^ 2, k, 1, n)")]
+ [InlineData("product(k ^ 3, k, 1, n)")]
+ public void ThePowerOfAFactorial(string expression)
+ => AgreesWithTheExpansion(expression, -2, -1, 0, 1, 2, 3, 5, 8);
+
+ /// A constant factor comes out as a power of itself, one per term.
+ [Theory]
+ [InlineData("product(2, k, 1, n)")]
+ [InlineData("product(c, k, 1, n)")]
+ [InlineData("product(2 * k, k, 1, n)")]
+ [InlineData("product(c * k, k, 1, n)")]
+ [InlineData("product(c * k ^ 2, k, 1, n)")]
+ public void AConstantFactor(string expression)
+ => AgreesWithTheExpansion(expression, -2, -1, 0, 1, 2, 3, 5);
+
+ ///
+ /// The empty range is where the boundary had to move. The sum's condition is
+ /// to >= from - 1; the product's is to >= from, because at the empty range
+ /// itself the closed form is c^0 — which is 1 for every c but zero,
+ /// and undefined there, while the empty product is 1 for every c including
+ /// zero. Handing that one point to the identity branch keeps a value from becoming an
+ /// undefinedness.
+ ///
+ [Fact]
+ public void TheEmptyProductIsOneEvenWhereTheBodyIsZero()
+ {
+ var closed = "product(c, k, 1, n)".ToEntity().Simplify();
+ var atTheBoundary = closed.Substitute("n", 0).Substitute("c", 0).Simplify();
+ Assert.Equal(Entity.Number.Integer.Create(1), atTheBoundary.Evaled);
+ Assert.False(atTheBoundary.Evaled.IsNaN);
+ }
+
+ /// A symbolic lower bound, where the body has no index to need a factorial.
+ [Fact]
+ public void ASymbolicLowerBoundWithAConstantBody()
+ {
+ var closed = "product(c, k, m, n)".ToEntity().Simplify();
+ Assert.IsNotType(closed);
+ foreach (var (from, to) in new[] { (1, 4), (2, 5), (3, 3), (4, 3), (-1, 2) })
+ Assert.Equal(
+ "product(c, k, m, n)".ToEntity().Substitute("m", from).Substitute("n", to)
+ .Substitute("c", 3).Simplify().Evaled,
+ closed.Substitute("m", from).Substitute("n", to).Substitute("c", 3).Simplify().Evaled);
+ }
+
+ ///
+ /// A concrete range too long to write out is answered rather than carried, the same as
+ /// for the sum.
+ ///
+ [Fact]
+ public void ARangeTooLongToWriteOutIsStillAnswered()
+ => Assert.Equal(
+ MathS.Pow(2, 500).Evaled,
+ "product(2, k, 1, 500)".ToEntity().Simplify().Evaled);
+
+ ///
+ /// A lower bound that is not a concrete positive integer is declined where the index
+ /// is in the body, and that cannot be a condition instead. b!/(a-1)! holds
+ /// only for a >= 1; below it the range runs through zero and the product is
+ /// 0 while (a-1)! is undefined — and a < 1 does not mean the
+ /// range is empty, so it cannot share a branch with the empty-range case.
+ ///
+ [Theory]
+ [InlineData("product(k, k, 0, n)")]
+ [InlineData("product(k, k, -3, n)")]
+ [InlineData("product(k, k, m, n)")]
+ public void ALowerBoundThatIsNotAConcretePositiveIntegerIsDeclined(string expression)
+ => Assert.IsType(expression.ToEntity().Simplify());
+
+ ///
+ /// What is not a monomial in the index is carried. A product has no linearity, so a sum
+ /// of terms in the body is not something this takes apart.
+ ///
+ [Theory]
+ [InlineData("product(k + 1, k, 1, n)")]
+ [InlineData("product(k ^ 2 + k, k, 1, n)")]
+ [InlineData("product(2 ^ k, k, 1, n)")]
+ [InlineData("product(sin(k), k, 1, n)")]
+ [InlineData("product(1 / k, k, 1, n)")]
+ public void WhatIsNotAMonomialIsCarried(string expression)
+ => Assert.IsType(expression.ToEntity().Simplify());
+
+ ///
+ /// A bound that is a number and not a whole one is carried, as for the sum: the index
+ /// runs over the integers.
+ ///
+ [Theory]
+ [InlineData("product(k, k, 1, 5/2)")]
+ [InlineData("product(k, k, 1, +oo)")]
+ public void ABoundThatIsNotAWholeNumberIsCarried(string expression)
+ => Assert.IsType(expression.ToEntity().Simplify());
+
+ /// The short concrete ranges are written out as they were.
+ [Theory]
+ [InlineData("product(k, k, 1, 5)", "120")]
+ [InlineData("product(k, k, 3, 6)", "360")]
+ [InlineData("product(k ^ 2, k, 1, 4)", "576")]
+ [InlineData("product(k, k, 5, 1)", "1")]
+ [InlineData("product(2 ^ k, k, 1, 4)", "1024")]
+ public void AShortConcreteRangeIsUnchanged(string expression, string expected)
+ => Assert.Equal(expected.ToEntity().Evaled, expression.ToEntity().Simplify().Evaled);
+
+ /// The index stays bound.
+ [Fact]
+ public void TheIndexDoesNotEscape()
+ {
+ var closed = "product(k, k, 1, n)".ToEntity().Simplify();
+ Assert.DoesNotContain((Entity.Variable)"k", closed.Vars);
+ }
+ }
+}
diff --git a/Sources/Tests/UnitTests/Core/ClosedFormSummationTest.cs b/Sources/Tests/UnitTests/Core/ClosedFormSummationTest.cs
index c969d5168..7c90a917f 100644
--- a/Sources/Tests/UnitTests/Core/ClosedFormSummationTest.cs
+++ b/Sources/Tests/UnitTests/Core/ClosedFormSummationTest.cs
@@ -153,14 +153,18 @@ public void WhatIsNotPolynomialInTheIndexIsCarried(string expression)
=> Assert.IsType(expression.ToEntity().Simplify());
///
- /// The product is carried whatever its body, including product(k, k, 1, n), whose
- /// closed form factorial(n) would be a wrong answer here: the empty product is
- /// 1 at every n < 1, while factorial is undefined at the negative
- /// integers. Answering it needs the same condition the sum carries, and is not done.
+ /// A product whose body this cannot read is carried. The ones it can are
+ /// 's subject — a product has no linearity, so where
+ /// the sum takes any polynomial apart the product needs a single term.
///
+ ///
+ /// product(k, k, 1, n) was on this list, for wanting a condition of the same kind
+ /// the sum carries: the empty product is 1 at every n < 1, while
+ /// factorial is undefined at the negative integers. It has that condition now.
+ ///
[Theory]
- [InlineData("product(k, k, 1, n)")]
- [InlineData("product(k ^ 2, k, 1, n)")]
+ [InlineData("product(k + 1, k, 1, n)")]
+ [InlineData("product(2 ^ k, k, 1, n)")]
public void TheProductIsCarried(string expression)
=> Assert.IsType(expression.ToEntity().Simplify());
diff --git a/Sources/Tests/UnitTests/Core/SummationProductTest.cs b/Sources/Tests/UnitTests/Core/SummationProductTest.cs
index 80df23fa6..e19fc6c90 100644
--- a/Sources/Tests/UnitTests/Core/SummationProductTest.cs
+++ b/Sources/Tests/UnitTests/Core/SummationProductTest.cs
@@ -64,10 +64,15 @@ public void AnEmptyRangeIsTheIdentity(string expression, string expected) =>
public void ASymbolicBoundIsCarried(string expression)
=> Assert.IsType(expression.ToEntity().Simplify());
- /// A product is carried whatever its summand; only the sum has a closed form.
- [Fact]
- public void AProductWithASymbolicBoundIsCarried()
- => Assert.IsType("product(k, k, 1, n)".ToEntity().Simplify());
+ ///
+ /// A product is carried unless its body is a single term. Where the sum takes any
+ /// polynomial apart by linearity, a product has none to take it apart with.
+ ///
+ [Theory]
+ [InlineData("product(k + 1, k, 1, n)")]
+ [InlineData("product(2 ^ k, k, 1, n)")]
+ public void AProductWithASymbolicBoundIsCarried(string expression)
+ => Assert.IsType(expression.ToEntity().Simplify());
///
/// A polynomial summand does have a closed form, and it is given rather than carried.