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.