Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,56 @@ namespace AngouriMath
{
partial record Entity
{
/// <summary>
/// The operand a sum or a difference simplifies first, or <see langword="null"/> for any
/// other node.
/// </summary>
private static Entity? LeftOperandOfASum(Entity node)
=> node switch { Sumf sum => sum.Augend, Minusf difference => difference.Minuend, _ => null };

/// <summary>How far apart the nodes simplified ahead along a long left spine are.</summary>
private const int SpineCheckpointSpacing = 32;

/// <summary>
/// Simplifies a long chain of sums from the bottom up before <paramref name="sum"/> is
/// simplified, so that no recursion through it goes deeper than
/// <see cref="SpineCheckpointSpacing"/>.
/// </summary>
/// <remarks>
/// <para>
/// 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. <c>CollectLikeTerms</c> builds one, and so does
/// anything that adds terms up in a loop. The integrals of
/// <c>(q + p x)/(c + b x + a x^2)^3</c> 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
/// </para>
/// <para>
/// Every node's simplification is cached, so simplifying every
/// <see cref="SpineCheckpointSpacing"/>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.
/// </para>
/// </remarks>
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<Entity>();
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);
}

/// <summary>
/// <paramref name="interval"/> scaled by <paramref name="factor"/>, or
/// <see langword="null"/> where the factor's sign is not known.
Expand Down Expand Up @@ -83,8 +133,10 @@ public partial record Sumf
{
private protected override Entity IntrinsicCondition => Boolean.True;
/// <inheritdoc/>
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,
Expand All @@ -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;

/// <inheritdoc/>
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,
Expand All @@ -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
{
Expand Down
45 changes: 45 additions & 0 deletions Sources/Tests/UnitTests/Common/LongSumStackDepthTest.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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
/// <c>(q + p x)/(c + b x + a x^2)^3</c> did the same to a 1 MB stack.
/// <a href="https://github.com/asc-community/AngouriMath/issues/2166">#2166</a>
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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());
}
}
}
Loading