diff --git a/Sources/.editorconfig b/Sources/.editorconfig
index 35cd5c5f6..a6f7ce704 100644
--- a/Sources/.editorconfig
+++ b/Sources/.editorconfig
@@ -192,3 +192,6 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed
[Tests/UnitTests/Calculus/RepeatedQuadraticIntegralTest.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/Calculus/IntegralAnswerCacheTest.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/SettingClass.cs b/Sources/AngouriMath/Convenience/SettingClass.cs
index f9e9a8099..a6ee5a1ca 100644
--- a/Sources/AngouriMath/Convenience/SettingClass.cs
+++ b/Sources/AngouriMath/Convenience/SettingClass.cs
@@ -10,6 +10,88 @@
namespace AngouriMath.Convenience
{
+ /// A setting, without its value's type — enough to ask what it currently reads as.
+ internal interface ISettingState
+ {
+ ///
+ /// The object identifying this setting's value in the calling flow, or
+ /// where nothing is set and the default applies.
+ ///
+ object? CurrentState { get; }
+ }
+
+ ///
+ /// What every setting reads as, taken together, so that something holding a result computed
+ /// under the settings of a moment can tell whether those settings still hold.
+ ///
+ ///
+ ///
+ /// A state and not a change count. Counting changes looks equivalent and is not: a
+ /// scope opened and closed leaves every setting exactly as it found it while moving a
+ /// counter twice, and the library opens such scopes constantly — a single
+ /// Simplify of one unevaluated integral opened 3520 of them, all four from
+ /// numeric downcasting in Number/Operators.cs. A counter therefore reports
+ /// "everything has changed" continuously and is useless to a cache. Measured, not assumed:
+ /// the counter was built first and threw away the whole benefit.
+ ///
+ ///
+ /// The state of one setting is the reference to the frame on top of its stack, which
+ /// is exact rather than a hash — releasing a scope restores the very frame object that was
+ /// there before it, since the chain below a pushed frame is never rebuilt. So comparing
+ /// snapshots is a handful of reference comparisons with no chance of collision, and a
+ /// balanced open-and-close compares equal, which is the whole point.
+ ///
+ ///
+ internal static class SettingsState
+ {
+ ///
+ /// Every setting there is. Replaced rather than appended to, so that a reader can walk it
+ /// without a lock: registering happens as the settings are constructed and a reader is on
+ /// the integrator's hot path, where taking a lock per call would be contention bought for
+ /// a list that stops changing before any real work starts.
+ ///
+ [ConcurrentField] private static ISettingState[] registered = System.Array.Empty();
+
+ [ConcurrentField] private static readonly object registering = new();
+
+ internal static void Register(ISettingState setting)
+ {
+ lock (registering)
+ {
+ var grown = new ISettingState[registered.Length + 1];
+ System.Array.Copy(registered, grown, registered.Length);
+ grown[^1] = setting;
+ System.Threading.Volatile.Write(ref registered, grown);
+ }
+ }
+
+ /// What every setting reads as right now, in this flow.
+ internal static object?[] Snapshot()
+ {
+ var settings = System.Threading.Volatile.Read(ref registered);
+ var snapshot = new object?[settings.Length];
+ for (var i = 0; i < snapshot.Length; i++)
+ snapshot[i] = settings[i].CurrentState;
+ return snapshot;
+ }
+
+ ///
+ /// Whether is still what every setting reads as. One taken
+ /// before a setting existed has a different length and is reported stale, which is what
+ /// should happen — that setting's value was not part of it.
+ ///
+ internal static bool StillHolds(object?[] snapshot)
+ {
+ var settings = System.Threading.Volatile.Read(ref registered);
+ if (snapshot.Length != settings.Length)
+ return false;
+ for (var i = 0; i < snapshot.Length; i++)
+ if (!ReferenceEquals(snapshot[i], settings[i].CurrentState))
+ return false;
+ return true;
+ }
+ }
+
///
/// This class for configuring some internal mechanisms from outside
///
@@ -34,7 +116,7 @@ namespace AngouriMath.Convenience
/// chain and assign it, rather than editing one.
///
///
- public sealed class Setting where T : notnull
+ public sealed class Setting : ISettingState where T : notnull
{
///
/// One pushed value, and the chain below it. Never mutated once built.
@@ -58,7 +140,20 @@ private sealed class Frame
private readonly AsyncLocal frames = new();
private long lastId;
- internal Setting(T defaultValue) => Default = defaultValue;
+ internal Setting(T defaultValue)
+ {
+ Default = defaultValue;
+ SettingsState.Register(this);
+ }
+
+ ///
+ /// The frame this setting currently reads from, as the identity of its state. Released
+ /// in order — which using guarantees — a scope restores the very frame that was
+ /// on top before it, so this compares equal across a balanced open and close. Out of
+ /// order the chain above is rebuilt and this reads as a change, which is conservative
+ /// and not wrong.
+ ///
+ object? ISettingState.CurrentState => frames.Value;
///
/// Sets the new value for the setting
@@ -98,7 +193,7 @@ private void Remove(long id)
if (top.Id == id)
{
frames.Value = top.Next;
- return;
+ return;
}
var above = new System.Collections.Generic.List();
var current = top;
diff --git a/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs b/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs
index 5aaef26c5..52bfd6564 100644
--- a/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs
+++ b/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs
@@ -5,6 +5,7 @@
// Website: https://am.angouri.org.
//
+using AngouriMath.Convenience;
using AngouriMath.Core.Transformations;
using AngouriMath.Extensions;
using AngouriMath.Functions.Algebra;
@@ -109,10 +110,98 @@ internal static partial class Integration
private static Entity Normalized(Entity expr) =>
expr.Replace(Patterns.GatherPowersOfOneBase);
+ ///
+ /// The integrals already answered under the settings in force, so that the same question
+ /// is not worked out again.
+ ///
+ ///
+ ///
+ /// Why an integral is asked for twice at all. Two layers of it. Within one call the
+ /// solvers overlap — substitution, partial fractions, splitting a sum and integration by
+ /// parts each decompose the integrand differently and produce pieces that coincide — and
+ /// across calls Simplify asks about one integral through every
+ /// rewritten candidate it generates. Traced on sin(x)/(x^2 + 1)^2, which has no
+ /// elementary antiderivative and so runs the search to exhaustion, that is 562
+ /// top-level calls for 3 distinct integrands, one of them asked 500 times; on
+ /// e^x/(x^2 + 1)^2 a single call enters the integrator 5330 times for 23.
+ ///
+ ///
+ /// The answers are held across calls and not only within one, which is where the
+ /// repetition is: discarding them at the end of each top-level call leaves the 562 to be
+ /// paid in full. Measured on those three integrands, keeping them takes a
+ /// Simplify from 115, 86 and 51 seconds to about 1.2, 1.5 and 0.2, and a single
+ /// cold Integrate of sqrt(tan(x)) from 155 ms to 79.
+ /// https://github.com/asc-community/AngouriMath/issues/1156
+ ///
+ ///
+ /// Why it is sound to hold them. An answer depends on the ambient settings —
+ /// decides whether the logarithms carry an
+ /// abs, MaxExpansionTermCount bounds the by-parts recursion — and a setting
+ /// is scoped to a flow rather than to a thread, so "the settings have not changed" is not
+ /// something a per-thread cache can assume. answers it
+ /// exactly, by comparing what every setting reads as rather than by counting changes —
+ /// which matters, because the library opens and closes thousands of balanced scopes while
+ /// simplifying and a change count is therefore never still.
+ ///
+ ///
+ [System.ThreadStatic] private static Dictionary<(Entity, Entity.Variable, bool), Entity?>? answered;
+
+ /// The settings was filled under.
+ [System.ThreadStatic] private static object?[]? answeredUnder;
+
+ ///
+ /// How many answers to hold before starting over. Comfortably above what one call needs
+ /// — the worst measured is 23 distinct integrands within a call and 3 across the
+ /// candidates of one Simplify — and low enough that a process integrating
+ /// different things all day does not accumulate them.
+ ///
+ private const int MostAnswersKept = 4096;
+
/// Does not add the constant of integration because this is called recursively.
internal static Entity? ComputeIndefiniteIntegral(Entity expr, Entity.Variable x, bool integrateByParts = true)
{
expr = Normalized(expr);
+
+ if (answered is null || answeredUnder is null || !SettingsState.StillHolds(answeredUnder))
+ {
+ // A fresh dictionary rather than Clear(), because this flow may have inherited
+ // the reference from the one that started it, and emptying it in place would
+ // empty that one's too.
+ answered = new();
+ answeredUnder = SettingsState.Snapshot();
+ }
+
+ // Held as locals over the recursive call, which reaches this method again and may
+ // replace both fields on the way. Writing the answer into whatever the fields hold
+ // afterwards would put it in a dictionary stamped with different settings.
+ var into = answered;
+ var stamp = answeredUnder;
+
+ var key = (expr, x, integrateByParts);
+ if (into.TryGetValue(key, out var already))
+ return already;
+
+ var computed = ComputeIndefiniteIntegralUncached(expr, x, integrateByParts);
+
+ // Only kept if those settings still hold. Working the answer out runs simplification,
+ // which opens scopes of its own; one still open means this answer was computed under
+ // settings the stamp does not describe.
+ if (SettingsState.StillHolds(stamp))
+ {
+ // Emptied rather than grown without end. Nothing here expires on its own — the
+ // settings holding still is the whole condition for keeping an answer — so a
+ // long-lived process integrating a stream of different expressions would hold
+ // every one of them for ever. What this is for is being asked the same question
+ // again, which needs the recent answers and not all of them.
+ if (into.Count >= MostAnswersKept)
+ into.Clear();
+ into[key] = computed;
+ }
+ return computed;
+ }
+
+ private static Entity? ComputeIndefiniteIntegralUncached(Entity expr, Entity.Variable x, bool integrateByParts)
+ {
if (!expr.ContainsNode(x)) return expr * x; // base case, handle here
if ((IntegralPatterns.TryStandardIntegrals(expr, x)) is { } answer) return answer;
// The flag has to be handed on. Every one of these recurses, and a solver that
diff --git a/Sources/Tests/UnitTests/Calculus/IntegralAnswerCacheTest.cs b/Sources/Tests/UnitTests/Calculus/IntegralAnswerCacheTest.cs
new file mode 100644
index 000000000..0daa68877
--- /dev/null
+++ b/Sources/Tests/UnitTests/Calculus/IntegralAnswerCacheTest.cs
@@ -0,0 +1,119 @@
+//
+// 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.Diagnostics;
+using AngouriMath;
+using AngouriMath.Core;
+using AngouriMath.Extensions;
+using Xunit;
+
+namespace AngouriMath.Tests.Calculus
+{
+ ///
+ /// The integrator keeps the answers it has already worked out, because it is asked the same
+ /// question over and over — Simplify puts one integral through every rewritten
+ /// candidate it generates, which measured at 562 calls for 3 distinct integrands.
+ /// https://github.com/asc-community/AngouriMath/issues/1156
+ ///
+ ///
+ /// The tests that matter here are the ones below on the codomain. A cache like this does not
+ /// fail by returning nonsense; it fails by returning a correct answer that was worked
+ /// out under settings the caller is no longer standing in.
+ ///
+ [Trait("Area", "Calculus")]
+ public sealed class IntegralAnswerCacheTest
+ {
+ ///
+ /// 1/x integrates to ln(abs(x)) on the reals and ln(x) on the complex
+ /// plane, so it separates the two codomains in one expression. Asked under each in turn,
+ /// in one flow, which is where a cache that does not notice the setting would answer the
+ /// second question with the first one's answer.
+ ///
+ [Fact]
+ public void ANarrowedCodomainIsNotAnsweredFromTheWiderOne()
+ {
+ var complex = "1 / x".ToEntity().Integrate("x").Stringize();
+
+ string real;
+ using (MathS.Settings.Codomain.Set(Domain.Real))
+ real = "1 / x".ToEntity().Integrate("x").Stringize();
+
+ Assert.Contains("abs", real);
+ Assert.DoesNotContain("abs", complex);
+ }
+
+ /// The same, asked in the other order — a cache is only sound in both.
+ [Fact]
+ public void AWidenedCodomainIsNotAnsweredFromTheNarrowerOne()
+ {
+ string real;
+ using (MathS.Settings.Codomain.Set(Domain.Real))
+ real = "1 / x".ToEntity().Integrate("x").Stringize();
+
+ var complex = "1 / x".ToEntity().Integrate("x").Stringize();
+
+ Assert.Contains("abs", real);
+ Assert.DoesNotContain("abs", complex);
+ }
+
+ ///
+ /// And with the scope entered while an answer for the same integrand is already held,
+ /// which is the ordering that a cache keyed on anything coarser than the settings
+ /// themselves gets wrong.
+ ///
+ [Fact]
+ public void AnAnswerHeldFromBeforeAScopeIsNotUsedInsideIt()
+ {
+ var before = "1 / x".ToEntity().Integrate("x").Stringize();
+ Assert.DoesNotContain("abs", before);
+
+ using (MathS.Settings.Codomain.Set(Domain.Real))
+ Assert.Contains("abs", "1 / x".ToEntity().Integrate("x").Stringize());
+
+ Assert.DoesNotContain("abs", "1 / x".ToEntity().Integrate("x").Stringize());
+ }
+
+ ///
+ /// Repeating a question gives the same answer, which is the property a cache is allowed
+ /// to have and the one that would break first if a stale entry were served.
+ ///
+ [Theory]
+ [InlineData("1 / (x ^ 2 + 1)")]
+ [InlineData("1 / (x ^ 2 + 1) ^ 2")]
+ [InlineData("x * ln(x)")]
+ [InlineData("sqrt(tan(x))")]
+ [InlineData("sin(x) / (x ^ 2 + 1) ^ 2")]
+ public void AskingTwiceAnswersTheSame(string integrand)
+ {
+ var first = integrand.ToEntity().Integrate("x");
+ var second = integrand.ToEntity().Integrate("x");
+ Assert.Equal(first, second);
+ }
+
+ ///
+ /// An integrand with no elementary antiderivative, which runs every solver to exhaustion
+ /// and so pays the full cost of the search. It took 115 seconds before the answers
+ /// were kept, and about one after.
+ ///
+ ///
+ /// The bound is two orders of magnitude above what it now takes and a quarter of what it
+ /// used to, so it is a guard against the regression rather than a measurement of the
+ /// machine. A timing assertion tight enough to be a benchmark would only flake.
+ ///
+ [Fact]
+ public void AnIntegralWithNoAnswerStillFinishesQuickly()
+ {
+ var watch = Stopwatch.StartNew();
+ var answer = "sin(x) / (x ^ 2 + 1) ^ 2".ToEntity().Integrate("x").Simplify();
+ watch.Stop();
+
+ Assert.Contains("integral(", answer.Stringize());
+ Assert.True(watch.Elapsed.TotalSeconds < 30,
+ $"took {watch.Elapsed.TotalSeconds:F1}s; it was 115s before the answers were kept");
+ }
+ }
+}