From d58ff2bc88c0c0822d59e612dc4f62af03484387 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Wed, 9 Sep 2026 14:56:05 +0000 Subject: [PATCH] An integral already being worked out is a cycle, and is declined at once #1233 bounded the integrator's descent, which stopped it killing the process. That was necessary and it was not sufficient: the descent branches, so 32 levels of a cycle is still an enormous search. Measured on Rubi's independent test suites, problems 1400-1450 took 985 seconds under the bound alone where v2.4.0 took 44. One integrand held the run for over ten minutes. Declining at the first repeat instead: 37 seconds over the same fifty problems, which is v2.4.0's figure and slightly under it. The set is keyed on the integrand with its variable renamed to one canonical name, and that renaming is the whole point. SolveBySubstitution names each new variable with Variable.CreateUnique, so a level and the level it came from are alpha-equivalent rather than equal. A set keyed on the integrand as written would never see the same entry twice -- which is exactly why the memo added in #1157 could not stop this either, and why the first fix had to be a depth bound rather than a visited set. DeepestDescent stays as a backstop for anything this does not model, rather than as the mechanism. https://github.com/asc-community/AngouriMath/issues/1232 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- .../Integration/Integration.Definition.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs b/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs index 6bd53f143..802d7ec87 100644 --- a/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs +++ b/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs @@ -224,6 +224,32 @@ private static Entity Normalized(Entity expr) => /// [System.ThreadStatic] private static bool descentTruncated; + /// + /// The integrals this thread is part-way through, so that asking for one again while it is + /// still being worked out is recognised as a cycle rather than followed round again. + /// + /// + /// + /// Keyed on the integrand with its variable renamed to , which + /// is the whole point. names + /// each new variable with Variable.CreateUnique, so a level and the level it came + /// from are alpha-equivalent rather than equal, and a set keyed on the integrand as + /// written would never see the same entry twice. Neither does , and + /// for the same reason — which is why the memo could not stop this. + /// + /// + /// This is what makes a backstop instead of the mechanism. + /// The bound alone stops the process dying, and it is not enough on its own: the descent + /// branches, so 32 levels of a cycle is still an enormous search, and one Rubi integrand + /// took over ten minutes under the bound where v2.4.0 passed the whole section it + /// is in within a minute. Declining at the first repeat costs nothing and answers at once. + /// + /// + [System.ThreadStatic] private static HashSet<(Entity, bool)>? inProgress; + + /// The name every in-progress integrand's variable is rewritten to. + [ConstantField] private static readonly Entity.Variable CycleVariable = Entity.Variable.CreateVariableOrConstant("__integration_cycle_var"); + /// Does not add the constant of integration because this is called recursively. internal static Entity? ComputeIndefiniteIntegral(Entity expr, Entity.Variable x, bool integrateByParts = true) { @@ -233,7 +259,22 @@ private static Entity Normalized(Entity expr) => return null; } if (descentDepth == 0) + { descentTruncated = false; + inProgress?.Clear(); + } + + // Renamed so that the same integral under two different variable names is one entry. + var cycleKey = (expr.Substitute(x, CycleVariable), integrateByParts); + var open = inProgress ??= new HashSet<(Entity, bool)>(); + if (!open.Add(cycleKey)) + { + // Already on the stack: following it again cannot reach an answer this call has + // not already tried, so declining is both correct and immediate. + descentTruncated = true; + return null; + } + descentDepth++; try { @@ -241,6 +282,7 @@ private static Entity Normalized(Entity expr) => } finally { + open.Remove(cycleKey); descentDepth--; } }