Skip to content

Commit 229c9ab

Browse files
The integrator's descent is bounded, so a substitution cycle declines instead of killing the process (#1233)
Integrate could recurse without bound. A stack overflow is not an exception a caller can handle: the process aborts, and everything it had not finished is lost. Found by work/intbench against Rubi's independent test suites, where the run died with SIGABRT partway through and the harness refused to publish a report from it. The trace was thousands of frames alternating ComputeIndefiniteIntegral with SolveBySubstitution. It is a regression. The same corpus with the same flags on the same machine: v2.4.0 ran past the point of failure and on into the next file; the 2.5.0 release commit aborted 130 lines into it. Why the memo added in #1157 does not already stop this, which is the part worth writing down. SolveBySubstitution names its new variable with Variable.CreateUnique, so every level integrates with respect to a *fresh* variable. The key (expr, x, integrateByParts) therefore differs at every level even when the level is the same problem renamed, and the cache never sees the same question twice. A set of already-visited shapes would fail for exactly the same reason: the shapes are alpha-equivalent rather than equal. A depth bound does not care what the levels are called, which is why it is the fix. One detail that is easy to get wrong. A null produced by running out of descent must not be written into the memo as though it were a fact about the integrand, because the same key reached from less deep may well be answerable. A non-null answer is kept whatever happened elsewhere, since an antiderivative that was found is correct however deep the search that found it went. Declining is a legitimate answer here and a wrong one is not: an unevaluated integral(...) says "I could not settle this", which is true, where an aborted process says nothing at all. #1232 Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4e7d0ac commit 229c9ab

1 file changed

Lines changed: 84 additions & 1 deletion

File tree

‎Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,86 @@ private static Entity Normalized(Entity expr) =>
166166
/// </summary>
167167
private const int MostAnswersKept = 4096;
168168

169+
/// <summary>
170+
/// How deep the recursive descent may go before it declines to go further.
171+
/// </summary>
172+
/// <remarks>
173+
/// <para>
174+
/// Without this the descent has **no bound at all**, and a stack overflow is not an
175+
/// exception a caller can handle: it takes the process down, so anything the process had
176+
/// not finished is lost. Found by <c>work/intbench</c> against Rubi's independent test
177+
/// suites, where the run died with <c>SIGABRT</c> partway through; the trace was thousands
178+
/// of frames alternating <see cref="ComputeIndefiniteIntegral"/> with
179+
/// <see cref="IndefiniteIntegralSolver.SolveBySubstitution"/>.
180+
/// https://github.com/asc-community/AngouriMath/issues/1232
181+
/// </para>
182+
/// <para>
183+
/// <b>Why the memo above does not already stop it, which is the part worth stating.</b>
184+
/// <c>SolveBySubstitution</c> names its new variable with
185+
/// <c>Variable.CreateUnique</c>, so every level integrates with respect to a *fresh*
186+
/// variable. The key <c>(expr, x, integrateByParts)</c> therefore differs at every level
187+
/// even when the level is the same problem renamed, and <see cref="answered"/> can never
188+
/// fire on a cycle. Neither could a set of shapes already visited, for the same reason —
189+
/// the shapes are alpha-equivalent rather than equal. A depth bound does not care what
190+
/// the levels are called.
191+
/// </para>
192+
/// <para>
193+
/// <b>The number, which is measured rather than picked.</b> Instrumenting the descent over
194+
/// twenty-three integrands chosen from the hard end of the corpus — the ones that take
195+
/// substitutions, by-parts chains and partial fractions — the deepest any *answered*
196+
/// integral reaches is <b>13</b>, for <c>x^5*cosh(x)</c>. Next are <c>x^2*sqrt(5-x^2)</c>
197+
/// at 8 and <c>e^(x^(1/3))</c> at 7; everything else sits at 4 or less. 32 is therefore
198+
/// about two and a half times the deepest real descent seen.
199+
/// </para>
200+
/// <para>
201+
/// <b>Why not far more, since a bound only has to stop the overflow.</b> Because this
202+
/// descent branches: the remark on <see cref="answered"/> above records a single call
203+
/// entering the integrator 5,330 times for 23 distinct integrands. Depth that is never
204+
/// legitimately used is still searched before it is abandoned, so a bound of 64 stopped
205+
/// the crash and left the run crawling. A bound has to be tight enough to be a bound.
206+
/// </para>
207+
/// <para>
208+
/// Declining is a legitimate answer here and a wrong one is not: an unevaluated
209+
/// <c>integral(...)</c> says "I could not settle this", which is true, where an aborted
210+
/// process says nothing at all.
211+
/// </para>
212+
/// </remarks>
213+
private const int DeepestDescent = 32;
214+
215+
/// <summary>How deep the current descent is. Per thread, like <see cref="answered"/>.</summary>
216+
[System.ThreadStatic] private static int descentDepth;
217+
218+
219+
/// <summary>
220+
/// Whether anything in the current top-level call gave up on <see cref="DeepestDescent"/>
221+
/// rather than on the mathematics. A <c>null</c> produced that way must not be cached as
222+
/// "this cannot be integrated", because the same key may well be answerable when it is
223+
/// not reached from so deep.
224+
/// </summary>
225+
[System.ThreadStatic] private static bool descentTruncated;
226+
169227
/// <summary>Does not add the constant of integration because this is called recursively.</summary>
170228
internal static Entity? ComputeIndefiniteIntegral(Entity expr, Entity.Variable x, bool integrateByParts = true)
229+
{
230+
if (descentDepth >= DeepestDescent)
231+
{
232+
descentTruncated = true;
233+
return null;
234+
}
235+
if (descentDepth == 0)
236+
descentTruncated = false;
237+
descentDepth++;
238+
try
239+
{
240+
return ComputeIndefiniteIntegralBounded(expr, x, integrateByParts);
241+
}
242+
finally
243+
{
244+
descentDepth--;
245+
}
246+
}
247+
248+
private static Entity? ComputeIndefiniteIntegralBounded(Entity expr, Entity.Variable x, bool integrateByParts)
171249
{
172250
expr = Normalized(expr);
173251

@@ -195,7 +273,12 @@ private static Entity Normalized(Entity expr) =>
195273
// Only kept if those settings still hold. Working the answer out runs simplification,
196274
// which opens scopes of its own; one still open means this answer was computed under
197275
// settings the stamp does not describe.
198-
if (SettingsState.StillHolds(stamp))
276+
// A null that came of running out of descent, rather than of the mathematics, is not
277+
// an answer about this integrand and must not be remembered as one — the same key
278+
// reached from less deep may well be answerable. A non-null answer is kept whatever
279+
// happened elsewhere, since an antiderivative that was found is correct regardless of
280+
// how deep the search that found it went.
281+
if (SettingsState.StillHolds(stamp) && (computed is not null || !descentTruncated))
199282
{
200283
// Emptied rather than grown without end. Nothing here expires on its own — the
201284
// settings holding still is the whole condition for keeping an answer — so a

0 commit comments

Comments
 (0)