From b1473194def411337d4d3a2a79749b057dde296c Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Thu, 3 Sep 2026 10:04:38 +0000 Subject: [PATCH] A monomial body is multiplied in closed form The same for `product` as #1152 did for `sum`, over the narrower class a product's shape allows: 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) was carried is piecewise(n! provided n >= 1, 1) product(2, k, 1, 500) was carried is the 151-digit integer `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 included. This closes the last of the three `sum`/`product` rows in #717's survey. The condition is `to >= from` where the sum's is `to >= from - 1`, and the one point between them is the reason. 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. Giving that point to the identity branch keeps a value from becoming an undefinedness. It cost nothing: both branches say 1 there. Found by comparing the closed form against the expansion at bounds either side of the boundary, which is why the check is written that way rather than against a printed form. 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. `b!/(a-1)!` holds only for `a >= 1`; below that the range runs through zero so the product is 0 while `(a-1)!` is undefined -- and `a < 1` does not make the range empty, so it cannot share a branch with the empty-range case. A piecewise reading "identity otherwise" would be wrong there. Three tests asserted the product is carried, two of them added by #1152 with the reason it could not yet be answered. They now assert what is still carried -- a body that is not one term. Syntax.md, MathS.Product's summary and the #1152 entry in BREAKING-CHANGES.md are corrected the same way. Comparison.md's cell is again left as measured, its note now naming two moved rows rather than one. Full suite 9487 passed, 0 failed. Part of #717. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- BREAKING-CHANGES.md | 40 ++++- Sources/.editorconfig | 5 + Sources/AngouriMath/Convenience/MathS.cs | 19 ++- Sources/AngouriMath/Docs/Usage/Comparison.md | 27 ++- Sources/AngouriMath/Docs/Usage/Syntax.md | 12 +- .../Algebra/Polynomials/MonomialProduct.cs | 108 ++++++++++++ .../Polynomials/PolynomialSummation.cs | 2 +- .../Evaluation.Continuous.Calculus.Classes.cs | 9 +- .../UnitTests/Core/ClosedFormProductTest.cs | 154 ++++++++++++++++++ .../UnitTests/Core/ClosedFormSummationTest.cs | 16 +- .../UnitTests/Core/SummationProductTest.cs | 13 +- 11 files changed, 373 insertions(+), 32 deletions(-) create mode 100644 Sources/AngouriMath/Functions/Algebra/Polynomials/MonomialProduct.cs create mode 100644 Sources/Tests/UnitTests/Core/ClosedFormProductTest.cs 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.