From 9354561bbc3063f32cec3f38f68492182ef4da0b Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Wed, 21 Oct 2026 04:04:01 +0000 Subject: [PATCH] Check for cancellation before each exact evaluation of the perfect-square collapse The collapse's numeric checks evaluate exactly at sample points, and over coefficients like b^335 one point is seconds of rational arithmetic. Rubi's 1.1.2.4:565 spent three and a half minutes in one Simplify of a substitution, nearly all of it there, and ran on for a minute after its budget had cancelled it, since no evaluation asked. Each evaluation now checks for cancellation first, as the other steps that can run long do. A budget now binds where it did not: 3.2.1:292 and 3.5:37, answered in 23-24 s under a 5 s budget because nothing on their path asked, time out at the budget instead. Part of #718. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- .../Patterns/Patterns.ExpandFactorize.cs | 24 +++++- .../Multithreading/IntegrationCancelTest.cs | 85 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 Sources/Tests/UnitTests/Core/Multithreading/IntegrationCancelTest.cs diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs index 54610c34b..aa6b36265 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs @@ -7,6 +7,7 @@ using System; using AngouriMath.Core.Budgets; +using AngouriMath.Core.Multithreading; using AngouriMath.Core.Transformations; using PeterO.Numbers; using static AngouriMath.Entity; @@ -229,6 +230,7 @@ private static bool EvaluableAtSamplePoints(Entity expr) Entity at = expr; for (var v = 0; v < variables.Count; v++) at = at.Substitute(variables[v], Real.Create(PerfectSquareSamplePoints[v % PerfectSquareSamplePoints.Length])); + MultithreadingFunctional.ExitIfCancelled(); return at.Evaled is Complex; } @@ -238,11 +240,20 @@ private static bool EvaluableAtSamplePoints(Entity expr) /// symbolic test costs anything. /// /// + /// /// The asymmetry with is deliberate and is what makes /// this safe to put in front of the symbolic test: that one answers "may I keep this", /// so it refuses whatever it cannot evaluate; this one answers "may I drop this", so it /// refuses to drop whatever it cannot evaluate. A point it cannot reach is not evidence /// of difference, and a rule may not decline on evidence it does not have. + /// + /// + /// Each evaluation checks for cancellation before it starts, as the other steps that can + /// run long do. An evaluation is exact, and over coefficients like b^335 one point + /// is seconds of rational arithmetic: Rubi's x^(7/2)/((a + b x^2)^2 (c + d x^2)^3) + /// spent a minute here after its budget had cancelled it. + /// https://github.com/asc-community/AngouriMath/issues/718 + /// /// private static bool DiffersNumerically(Entity left, Entity right) { @@ -256,7 +267,11 @@ private static bool DiffersNumerically(Entity left, Entity right) a = a.Substitute(variables[v], Real.Create(point)); b = b.Substitute(variables[v], Real.Create(point)); } - if (a.Evaled is not Complex left1 || b.Evaled is not Complex right1) + MultithreadingFunctional.ExitIfCancelled(); + if (a.Evaled is not Complex left1) + return false; + MultithreadingFunctional.ExitIfCancelled(); + if (b.Evaled is not Complex right1) return false; var difference = (left1 - right1).Abs(); if (difference is Real real && real.EDecimal.Abs() @@ -280,7 +295,12 @@ private static bool AgreesNumerically(Entity original, Entity candidate) before = before.Substitute(variables[v], Real.Create(point)); after = after.Substitute(variables[v], Real.Create(point)); } - if (before.Evaled is not Complex left || after.Evaled is not Complex right) + // Checked before each evaluation, as in DiffersNumerically. + MultithreadingFunctional.ExitIfCancelled(); + if (before.Evaled is not Complex left) + return false; + MultithreadingFunctional.ExitIfCancelled(); + if (after.Evaled is not Complex right) return false; var difference = (left - right).Abs(); if (difference is not Real real || real.EDecimal.Abs() diff --git a/Sources/Tests/UnitTests/Core/Multithreading/IntegrationCancelTest.cs b/Sources/Tests/UnitTests/Core/Multithreading/IntegrationCancelTest.cs new file mode 100644 index 000000000..55fbeab6b --- /dev/null +++ b/Sources/Tests/UnitTests/Core/Multithreading/IntegrationCancelTest.cs @@ -0,0 +1,85 @@ +// +// 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; +using System.Threading; +using System.Threading.Tasks; +using AngouriMath.Extensions; +using AngouriMath.Functions; +using Xunit; + +namespace AngouriMath.Tests.Core.Multithreading +{ + /// + /// An integration stops when the budget it runs under cancels it, including inside the + /// numeric checks of the perfect-square collapse, which evaluate exactly at sample points. + /// #718 + /// + /// + /// Rubi 1.1.2.4 #565 spends three and a half minutes in one Simplify of a + /// substitution, nearly all of it evaluating sums with coefficients like b^335 at + /// those points, and ran on for a minute after its budget was cancelled, since no + /// evaluation asked. + /// + [Trait("Area", "Core")] + public sealed class IntegrationCancelTest + { + private static readonly TimeSpan Budget = TimeSpan.FromSeconds(8); + + /// + /// The mechanism, with no clock in it: every cross term of this sum is told apart from + /// its candidate at the sample points, so the collapse reaches no Simplify that + /// would have stopped it, and answers null unless an evaluation asks first. + /// + [Fact] + public void ACancelledPerfectSquareTestStopsBeforeItEvaluates() + { + using var source = new CancellationTokenSource(); + source.Cancel(); + var task = OnItsOwnThread(source.Token, () => Patterns.CollapseToPerfectSquare("x + 2 * sqrt(x * y) + y".ToEntity())); + var thrown = Assert.Throws(() => task.Wait()); + Assert.IsAssignableFrom(thrown.InnerException); + } + + /// + /// The integrand, under a budget that lands inside those checks. A machine that answers + /// it within the budget has nothing to show here. + /// + [Fact] + public void ACancelledIntegrationStopsWithinThreeTimesItsBudget() + { + using var source = new CancellationTokenSource(); + var task = OnItsOwnThread(source.Token, () => "x^(7/2)/((a+b*x^2)^2*(c+d*x^2)^3)".ToEntity().Integrate("x")); + if (task.Wait(Budget)) + return; + source.Cancel(); + Assert.True(StopsWithin(task, 2 * Budget), + $"still running {(2 * Budget).TotalSeconds} s after its {Budget.TotalSeconds} s budget was cancelled"); + } + + // On a thread of its own, since the token is local to the thread that sets it, and a + // dedicated one, so that work on the pool cannot hold the wait up. + private static Task OnItsOwnThread(CancellationToken token, Func work) + => Task.Factory.StartNew(() => + { + MathS.Multithreading.SetLocalCancellationToken(token); + return work(); + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + private static bool StopsWithin(Task task, TimeSpan patience) + { + try + { + return task.Wait(patience); + } + catch (AggregateException stopped) when (stopped.InnerException is OperationCanceledException) + { + return true; + } + } + } +}