diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs
index 3e4513f13..81926e59b 100644
--- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs
+++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Continuous/Evaluation.Continuous.Arithmetics.Classes.cs
@@ -12,6 +12,56 @@ namespace AngouriMath
{
partial record Entity
{
+ ///
+ /// The operand a sum or a difference simplifies first, or for any
+ /// other node.
+ ///
+ private static Entity? LeftOperandOfASum(Entity node)
+ => node switch { Sumf sum => sum.Augend, Minusf difference => difference.Minuend, _ => null };
+
+ /// How far apart the nodes simplified ahead along a long left spine are.
+ private const int SpineCheckpointSpacing = 32;
+
+ ///
+ /// Simplifies a long chain of sums from the bottom up before is
+ /// simplified, so that no recursion through it goes deeper than
+ /// .
+ ///
+ ///
+ ///
+ /// A sum simplifies its left operand first, and that operand's left operand, and so on, so
+ /// a chain of n sums nested to the left is simplified by a recursion n deep. A sum built
+ /// one term at a time is that shape. CollectLikeTerms builds one, and so does
+ /// anything that adds terms up in a loop. The integrals of
+ /// (q + p x)/(c + b x + a x^2)^3 simplified one about 900 deep, which overflowed a
+ /// 1 MB stack in a Debug build and 384 KB in Release.
+ /// https://github.com/asc-community/AngouriMath/issues/2166
+ ///
+ ///
+ /// Every node's simplification is cached, so simplifying every
+ /// th node of the spine first, deepest first, leaves
+ /// each of those recursions stopping at the one below it, and then the sum's own. The
+ /// same nodes are simplified as before, each once, in another order. Under twice the
+ /// spacing nothing is done, so an ordinary sum pays for a walk of a few nodes and nothing
+ /// else.
+ ///
+ ///
+ private static void SimplifyALongLeftSpineFirst(Entity sum, bool isExact)
+ {
+ var depth = 0;
+ for (var node = LeftOperandOfASum(sum); node is not null && depth < 2 * SpineCheckpointSpacing; node = LeftOperandOfASum(node))
+ depth++;
+ if (depth < 2 * SpineCheckpointSpacing)
+ return;
+ var checkpoints = new List();
+ depth = 0;
+ for (var node = LeftOperandOfASum(sum); node is Sumf or Minusf; node = LeftOperandOfASum(node)!)
+ if (++depth % SpineCheckpointSpacing == 0)
+ checkpoints.Add(node);
+ for (var i = checkpoints.Count - 1; i >= 0; i--)
+ _ = checkpoints[i].InnerSimplified(isExact);
+ }
+
///
/// scaled by , or
/// where the factor's sign is not known.
@@ -83,8 +133,10 @@ public partial record Sumf
{
private protected override Entity IntrinsicCondition => Boolean.True;
///
- protected override Entity InnerSimplify(bool isExact) =>
- ExpandOnTwoArguments(Augend, Addend,
+ protected override Entity InnerSimplify(bool isExact)
+ {
+ SimplifyALongLeftSpineFirst(this, isExact);
+ return ExpandOnTwoArguments(Augend, Addend,
(augend, addend) => (augend, addend) switch
{
(Complex a, Complex b) when !isExact => a + b,
@@ -98,14 +150,17 @@ _ when Core.Sets.Cardinality.Absorbed(augend, addend) is { } larger => larger,
_ => null
},
(@this, a, b) => ((Sumf)@this).New(a, b), isExact);
+ }
}
public partial record Minusf
{
private protected override Entity IntrinsicCondition => Boolean.True;
///
- protected override Entity InnerSimplify(bool isExact) =>
- ExpandOnTwoArguments(Minuend, Subtrahend,
+ protected override Entity InnerSimplify(bool isExact)
+ {
+ SimplifyALongLeftSpineFirst(this, isExact);
+ return ExpandOnTwoArguments(Minuend, Subtrahend,
(augend, addend) => (augend, addend) switch
{
(Complex a, Complex b) when !isExact => a - b,
@@ -127,6 +182,7 @@ _ when Core.Sets.Cardinality.HasNoValue(augend, addend) => MathS.NaN,
_ => null
},
(@this, a, b) => ((Minusf)@this).New(a, b), isExact);
+ }
}
public partial record Mulf
{
diff --git a/Sources/Tests/UnitTests/Common/LongSumStackDepthTest.cs b/Sources/Tests/UnitTests/Common/LongSumStackDepthTest.cs
new file mode 100644
index 000000000..03b200300
--- /dev/null
+++ b/Sources/Tests/UnitTests/Common/LongSumStackDepthTest.cs
@@ -0,0 +1,45 @@
+//
+// 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 System.Linq;
+using System.Threading;
+using AngouriMath;
+using AngouriMath.Extensions;
+using Xunit;
+
+namespace AngouriMath.Tests.Common
+{
+ ///
+ /// A sum built one term at a time is nested as deep as it is long, and a sum simplifies its
+ /// left operand first, so it was simplified by a recursion as deep as the sum. Expanding a
+ /// product of two sums of 30 terms collects 900, and that overflowed a 256 KB stack in a
+ /// Release build. In C# Test's Debug build on Windows, the integral of
+ /// (q + p x)/(c + b x + a x^2)^3 did the same to a 1 MB stack.
+ /// #2166
+ ///
+ ///
+ /// Run on a thread of a fixed stack size, because the test runner's threads have whatever
+ /// stack the platform gives them: 8 MB on Linux, 512 KB on macOS. A regression here is a
+ /// stack overflow, which takes the test host down rather than failing one test.
+ ///
+ [Trait("Area", "Common")]
+ public sealed class LongSumStackDepthTest
+ {
+ [Fact]
+ public void ASumOf900TermsIsCollectedOnASmallStack()
+ {
+ var left = string.Join(" + ", Enumerable.Range(1, 30).Select(i => "a_" + i)).ToEntity();
+ var right = string.Join(" + ", Enumerable.Range(1, 30).Select(i => "b_" + i)).ToEntity();
+ Entity? expanded = null;
+ var worker = new Thread(() => expanded = (left * right).Expand(), 256 * 1024);
+ worker.Start();
+ worker.Join();
+ Assert.NotNull(expanded);
+ Assert.Equal(900, Entity.Sumf.LinearChildren(expanded!).Count());
+ }
+ }
+}