Added the Mathematics tools as a prototype (#1162)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-05 10:39:40 +01:00
1 parent ce846e540e
commit 9c98bdae86
49 files changed
+4373 -11

No files matched your search

@@ -0,0 +1,426 @@
using AngouriMath;
using AngouriMath.Core;
using AngouriMath.Core.Exceptions;
using PeterO.Numbers;
namespace AIStudio.Tests.Tools.Mathematics;
/// <summary>
/// Pins the behavior of AngouriMath which the Mathematics tools build on.
/// </summary>
/// <remarks>
/// AngouriMath puts correct answers ahead of backward compatibility, and says so: the release notes of
/// 2.6.0 list 516 changed answers, fourteen of them silent, where the call still succeeds and returns
/// something else. An update which changes anything below fails here first, not in a chat. Each test
/// names what AngouriMath does; where the tools have to guard against it, the test says so.
/// </remarks>
[TestFixture]
public sealed class AngouriMathBehaviorTests
{
private static readonly Entity.Variable X = MathS.Var("x");
private static Entity Parse(string text)
{
using var _ = MathS.Settings.ExplicitParsingOnly.Set(true);
return MathS.FromString(text);
}
private static string DecimalOf(string text, int workingPrecision, int digits)
{
using var _ = MathS.Settings.DecimalPrecisionContext.Set(EContext.ForPrecision(workingPrecision));
return Parse(text).EvalNumerical().RealPart.EDecimal.RoundToPrecision(EContext.ForPrecision(digits)).ToString();
}
[Test]
public void RationalsStayExact()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("547 / 57576").Evaled, Is.EqualTo(Entity.Number.Rational.Create(547, 57576)));
Assert.That(Parse("0.1 + 0.2").Evaled, Is.EqualTo(Entity.Number.Rational.Create(3, 10)), "A decimal literal is read as the rational it writes.");
Assert.That(Parse("1.5e-3").Evaled, Is.EqualTo(Entity.Number.Rational.Create(3, 2000)));
Assert.That(Parse("2^200 / 3^100").Evaled, Is.TypeOf<Entity.Number.Rational>());
Assert.That(Parse("(-8)^(1/3)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(-2)), "The cube root of a negative integer is the real one.");
}
}
[Test]
public void IrrationalValuesAreDecimalsOfTheCurrentPrecision()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MathS.Settings.DecimalPrecisionContext.Value.Precision.ToInt32Checked(), Is.EqualTo(100), "The default precision is 100 significant digits.");
Assert.That(Parse("sqrt(2)").Evaled, Is.TypeOf<Entity.Number.Real>());
Assert.That(Parse("sqrt(2)").InnerSimplified.ToString(), Is.EqualTo("sqrt(2)"), "Simplifying keeps an irrational value exact.");
}
}
[Test]
public void ElementaryFunctionsAreRightToTheLastDigit()
{
using (Assert.EnterMultipleScope())
{
Assert.That(DecimalOf("sin(1)", 60, 60), Is.EqualTo("0.841470984807896506652502321630298999622563060798371065672752"));
Assert.That(DecimalOf("e", 60, 60), Is.EqualTo("2.71828182845904523536028747135266249775724709369995957496697"));
Assert.That(DecimalOf("pi", 60, 60), Is.EqualTo("3.14159265358979323846264338327950288419716939937510582097494"));
Assert.That(DecimalOf("ln(2)", 60, 60), Is.EqualTo("0.693147180559945309417232121458176568075500134360255254120680"));
Assert.That(DecimalOf("sqrt(2)", 60, 60), Is.EqualTo("1.41421356237309504880168872420969807856967187537694807317668"));
}
}
[Test]
public void ACompoundExpressionNeedsGuardDigits()
{
// pi^4/15 is 6.4939394022668291490960221792470074166485057115123614460978572926..., see Python's decimal:
using (Assert.EnterMultipleScope())
{
Assert.That(DecimalOf("pi^4/15", 60, 60), Is.Not.EqualTo("6.49393940226682914909602217924700741664850571151236144609786"), "At the precision asked for, the last digits are off: every step rounds.");
Assert.That(DecimalOf("pi^4/15", 70, 60), Is.EqualTo("6.49393940226682914909602217924700741664850571151236144609786"), "Ten guard digits make up for it.");
}
}
[Test]
public void DowncastingTurnsTinyValuesIntoZero()
{
// The Boltzmann constant at 300 K, the energy of a photon of 500 nm, and e^-100:
using (Assert.EnterMultipleScope())
{
Assert.That(MathS.Settings.DowncastingEnabled.Value, Is.True, "AngouriMath downcasts by default.");
Assert.That(DecimalOf("1.380649e-23 * 300", 30, 20), Is.EqualTo("0"), "Downcasting takes a tiny value for the integer 0, so NumericEvaluator.EvaluateAt does without it.");
Assert.That(DecimalOf("6.62607015e-34 * 299792458 / 500e-9", 40, 20), Is.EqualTo("0"));
Assert.That(DecimalOf("e^(-100)", 60, 20), Is.EqualTo("0"), "More digits only lower the bound below which a value vanishes.");
using var withoutDowncasting = MathS.Settings.DowncastingEnabled.Set(false);
Assert.That(DecimalOf("1.380649e-23 * 300", 30, 20), Is.EqualTo("4.14194700E-21"));
Assert.That(DecimalOf("6.62607015e-34 * 299792458 / 500e-9", 30, 20), Is.EqualTo("3.97289171430E-19"), "500e-9 is read as the fraction 1/2000000, and dividing by it rounds to 30 decimal places, see below.");
Assert.That(DecimalOf("6.62607015e-34 * 299792458 / 500e-9", 60, 20), Is.EqualTo("3.9728917142978574000E-19"));
Assert.That(DecimalOf("e^(-100)", 30, 20), Is.EqualTo("3.7200759760208359630E-44"));
}
}
[Test]
public void DowncastingWhileParsingLosesTinyLiterals()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("1e-60"), Is.EqualTo(Entity.Number.Integer.Create(0)), "A literal below about 1e-50 is read as 0, so MathExpressionReader parses without downcasting.");
Assert.That(Parse("6.62607015e-34"), Is.TypeOf<Entity.Number.Real>(), "Unlike 0.1, a literal with a long denominator stays an inexact decimal.");
Assert.That(Parse("1 + 1e-40").InnerSimplified, Is.EqualTo(Entity.Number.Integer.Create(1)), "Simplifying drops an inexact decimal from a sum, so MathExpressionReader turns every literal into its fraction.");
Assert.That(Entity.Number.Rational.Create(EInteger.One, EInteger.FromInt32(10).Pow(200)).EDecimal.IsZero, Is.True, "A fraction converts to a fixed number of decimal places, so NumericEvaluator converts an exact value itself.");
Assert.That(Parse("pi/10^1500").InnerSimplified, Is.EqualTo(Entity.Number.Integer.Create(0)), "Simplifying takes a value below about 1e-1000 for 0, so NumericEvaluator checks an exact 0 numerically.");
Assert.That(Parse("pi/10^1000").InnerSimplified, Is.TypeOf<Entity.Divf>());
// Where it evaluates a function numerically, the bound lies near the precision instead:
using var lowerPrecision = MathS.Settings.DecimalPrecisionContext.Set(EContext.ForPrecision(50));
Assert.That(MathS.FromString("132521403/200000000000000000000000000000000000000000 * sin(61/100)", useCache: false).InnerSimplified, Is.EqualTo(Entity.Number.Integer.Create(0)), "So NumericCheck evaluates an exact 0 numerically.");
}
}
[Test]
public void WithoutDowncastingAQuotientOfIntegersIsRoundedToDecimalPlaces()
{
using var withoutDowncasting = MathS.Settings.DowncastingEnabled.Set(false);
using (Assert.EnterMultipleScope())
{
Assert.That(DecimalOf("3/10^50 * 7", 30, 20), Is.EqualTo("0E-30"), "At 30 digits, the quotient keeps 30 decimal places, so NumericEvaluator.EvaluateAt adds the leading zeros of the smallest fraction to the precision.");
Assert.That(DecimalOf("3/10^50 * 7", 80, 20), Is.EqualTo("2.1000000000000000000E-49"));
Assert.That(DecimalOf("2^(-200)", 30, 20), Is.EqualTo("6.2230152778611417071E-61"), "A power keeps its significant digits.");
}
}
[Test]
public void SettingsBelongToTheFlowWhichSetsThem()
{
var inner = Task.Run(() =>
{
using var _ = MathS.Settings.DecimalPrecisionContext.Set(EContext.ForPrecision(30));
return MathS.Settings.DecimalPrecisionContext.Value.Precision.ToInt32Checked();
}).Result;
using (Assert.EnterMultipleScope())
{
Assert.That(inner, Is.EqualTo(30));
Assert.That(MathS.Settings.DecimalPrecisionContext.Value.Precision.ToInt32Checked(), Is.EqualTo(100), "A setting made in another flow does not leak into this one.");
}
}
[Test]
public void ExplicitParsingRefusesImplicitMultiplication()
{
// Without explicit parsing, x2 is x^2, sin x is the variable sin times x, and foo(3) is foo times 3:
using (Assert.EnterMultipleScope())
{
Assert.That(MathS.FromString("x2").ToString(), Is.EqualTo("x ^ 2"));
Assert.That(MathS.FromString("sin x").ToString(), Is.EqualTo("sin * x"));
Assert.That(MathS.FromString("foo(3)").ToString(), Is.EqualTo("foo * 3"));
foreach (var text in new[] { "2x", "x y", "x2", "sin x", "foo(3)", "2 pi", "x(x+1)", "(x+1)(x-1)" })
Assert.Throws<MissingOperatorParseException>(() => Parse(text), text);
}
}
[Test]
public void ExplicitParsingReadsTheSyntaxTheToolsDocument()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("2*(x+1)").ToString(), Is.EqualTo("2 * (x + 1)"));
Assert.That(Parse("-x^2").ToString(), Is.EqualTo("-x ^ 2"));
Assert.That(Parse("3!").Evaled, Is.EqualTo(Entity.Number.Integer.Create(6)));
Assert.That(Parse("log(100)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(2)), "log without a base is the decimal logarithm.");
Assert.That(Parse("log(2, 8)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(3)), "The base comes first.");
Assert.That(Parse("ln(e)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(1)));
Assert.That(Parse("exp(x)").ToString(), Is.EqualTo("e ^ x"));
Assert.That(Parse("e^(i*pi)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(-1)));
Assert.That(Parse("π"), Is.TypeOf<Entity.Constant>());
Assert.That(Parse("gamma(5)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(24)));
Assert.That(Parse("binomial(5, 2)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(10)));
Assert.That(Parse("abs(-2)").Evaled, Is.EqualTo(Entity.Number.Integer.Create(2)));
Assert.That(Parse("a <> b").ToString(), Is.EqualTo("not a = b"));
Assert.That(Parse("x_1 + x_2").ToString(), Is.EqualTo("x_1 + x_2"));
Assert.That(Parse("integral(x^3/(e^x-1), x, 0, +oo)"), Is.TypeOf<Entity.Integralf>());
Assert.That(Parse("limit(sin(x)/x, x, 0)"), Is.TypeOf<Entity.Limitf>());
Assert.That(Parse("+oo"), Is.TypeOf<Entity.Number.Real>());
}
}
[Test]
public void SomeSpellingsReadAsSomethingElse()
{
// The tools refuse these before parsing:
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("a != b").ToString(), Is.EqualTo("a! = b"), "!= is the factorial of a, equated to b.");
Assert.That(Parse("oo"), Is.TypeOf<Entity.Variable>(), "Only +oo and -oo are infinite; oo is a variable.");
Assert.That(Parse("x | y"), Is.TypeOf<Entity.Dividesf>(), "| is divisibility, since 2.6.0.");
Assert.That(Parse("2 = 2 = 2").ToString(), Is.EqualTo("2 = 2 and 2 = 2"), "A chain of equations is a conjunction.");
}
}
[Test]
public void UnreadableInputThrowsAParseException()
{
using (Assert.EnterMultipleScope())
{
// Under explicit parsing, a character the lexer drops can leave two operands side by side, as in 2·3:
foreach (var text in new[] { "2 + * 3", "(2 + 3", "", "√2", "x²", "2·3", "1,5", "2**3", "|x|", "\\frac{1}{2}" })
Assert.That(() => Parse(text), Throws.InstanceOf<ParseException>(), text);
Assert.Throws<FunctionArgumentCountException>(() => Parse("sin(1, 2)"));
Assert.Throws<FunctionArgumentCountException>(() => Parse("integral(x^2, x, 2)"), "An integral has two or four arguments.");
}
}
[Test]
public void AParseExceptionNamesLineAndColumn()
{
var exception = Assert.Throws<UnhandledParseException>(() => Parse("2 + * 3"))!;
using (Assert.EnterMultipleScope())
{
Assert.That(exception.Message, Does.StartWith("line 1:4 extraneous input '*' expecting {"), "The message lists every token the parser expected, over a thousand characters.");
Assert.That(exception.Message, Does.EndWith(", while parsing `2 + * 3`"));
}
}
[Test]
public void FreeVariablesLeaveOutBoundOnes()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("integral(x^2, x, 0, 1)").FreeVariables, Is.Empty);
Assert.That(Parse("integral(x*a, x, 0, 1)").FreeVariables, Is.EquivalentTo(new[] { MathS.Var("a") }));
Assert.That(Parse("pi + x").FreeVariables, Has.Some.InstanceOf<Entity.Constant>(), "The constants count among the free variables, see MathVariables.");
Assert.That(Parse("ln(0)").FreeVariables.Select(variable => variable.Name), Is.EqualTo(new[] { "e" }), "ln is the logarithm to the base e.");
Assert.That(Parse("x + 1").EvaluableNumerical, Is.False);
Assert.Throws<CannotEvalException>(() => Parse("x + 1").EvalNumerical());
}
}
[Test]
public void ValuesWithoutAMeaningAreNaN()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("1/0").Evaled.ToString(), Is.EqualTo("NaN"));
Assert.That(Parse("0/0").Evaled.ToString(), Is.EqualTo("NaN"));
Assert.That(Parse("(-1)!").Evaled.ToString(), Is.EqualTo("NaN"));
Assert.That(Parse("tan(pi/2)").Evaled.ToString(), Is.EqualTo("NaN"));
Assert.That(Parse("ln(0)").Evaled.ToString(), Is.EqualTo("-oo"));
Assert.That(Parse("sqrt(-1)").Evaled.ToString(), Is.EqualTo("i"));
}
}
[Test]
public void LatexComesFromLatexize()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("547 / 57576").Evaled.Latexize(), Is.EqualTo(@"\frac{547}{57576}"));
Assert.That(Parse("x^3/(e^x-1)").Latexize(), Is.EqualTo(@"\frac{{x}^{3}}{{\mathrm{e}}^{x}-1}"));
Assert.That(Parse("integral(x^3/(e^x-1), x, 0, +oo)").Latexize(), Is.EqualTo(@"\int_{0}^{\infty } \frac{{x}^{3}}{{\mathrm{e}}^{x}-1}\,\mathrm{d}x"));
Assert.That(Parse("(x-1)/(x-1)").Simplify().Latexize(), Is.EqualTo(@"1 \quad \text{for} \quad x-1 \neq 0"));
}
}
[Test]
public void CompiledFunctionsReturnNaNWhereTheyHaveNoValue()
{
var bose = Parse("x^3/(e^x-1)").Compile<double, double>(X);
var root = Parse("sqrt(x)").Compile<double, double>(X);
using (Assert.EnterMultipleScope())
{
Assert.That(bose(2.0), Is.EqualTo(8.0 / (Math.Exp(2.0) - 1.0)).Within(1e-15));
Assert.That(bose(0.0), Is.NaN, "0/0 at the lower bound of the integral; a quadrature must not evaluate there.");
Assert.That(root(-1.0), Is.NaN, "A compiled function stays real.");
Assert.That(Parse("erf(x)").Compile<double, double>(X)(1.0), Is.EqualTo(0.8427007929497149).Within(1e-15));
}
}
[Test]
public void SimplifyStatesWhereItsAnswerHolds()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("sin(x)^2 + cos(x)^2").Simplify(), Is.EqualTo(Entity.Number.Integer.Create(1)));
Assert.That(Parse("(x^2-1)/(x-1)").Simplify(), Is.TypeOf<Entity.Providedf>());
Assert.That(Parse("(x^2-1)/(x-1)").Simplify().ToString(), Is.EqualTo("x + 1 provided not x - 1 = 0"));
Assert.That(Parse("sqrt(x^2)").Simplify().ToString(), Is.EqualTo("sqrt(x ^ 2)"), "Over the complex numbers, sqrt(x^2) is not x.");
}
}
[Test]
public void ExpandAndFactorizeAreInverse()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("(x+1)^3").Expand().ToString(), Is.EqualTo("1 + 3 * x + 3 * x ^ 2 + x ^ 3"));
Assert.That(Parse("x^4 - 1").Factorize().ToString(), Is.EqualTo("(x + 1) * (x - 1) * (x ^ 2 + 1)"));
Assert.That(Parse("x^2 - 5*x + 6").Factorize().ToString(), Is.EqualTo("(x - 3) * (x - 2)"));
}
}
[Test]
public void ExpandLeavesAnExpressionWithTooManyTermsAsItIs()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MathS.Settings.MaxExpansionTermCount.Value, Is.EqualTo(2000));
Assert.That(Parse("(x+y+z+w)^60").Expand().ToString(), Is.EqualTo("(x + y + z + w) ^ 60"), "39,711 terms are above the limit.");
Assert.That(Parse("(x+y)^100").Expand(), Is.TypeOf<Entity.Sumf>());
}
}
[Test]
public void DifferentiateNeedsASimplifyAfterwards()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("x^5").Differentiate(X, 3).ToString(), Is.EqualTo("3 * x ^ 2 * 4 * 5"));
Assert.That(Parse("x^5").Differentiate(X, 3).Simplify().ToString(), Is.EqualTo("60 * x ^ 2"));
Assert.That(Parse("x^2*sin(x)").Differentiate(X).Simplify().ToString(), Is.EqualTo("x ^ 2 * cos(x) + 2 * x * sin(x)"));
}
}
[Test]
public void AnIndefiniteIntegralEndsInAConstantOrStaysAnIntegral()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("x^2").Integrate(X).ToString(), Is.EqualTo("x ^ 3 / 3 + C"));
Assert.That(Parse("C*x").Integrate(X).ToString(), Is.EqualTo("C * x ^ 2 / 2 + C_1"), "The constant takes a name the expression does not use.");
Assert.That(Parse("e^(-x^2)").Integrate(X).ToString(), Is.EqualTo("sqrt(pi) / 2 * erf(x) + C"));
Assert.That(Parse("sin(sin(x))").Integrate(X), Is.TypeOf<Entity.Integralf>(), "Without a closed form, the integral comes back.");
}
}
[Test]
public void ADefiniteIntegralIsExactThroughItsNode()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("integral(x^2, x, 0, 1)").InnerSimplified, Is.EqualTo(Entity.Number.Rational.Create(1, 3)));
Assert.That(Parse("integral(sin(x), x, 0, pi)").InnerSimplified, Is.EqualTo(Entity.Number.Integer.Create(2)));
Assert.That(Parse("integral(ln(x), x, 0, 1)").InnerSimplified, Is.EqualTo(Entity.Number.Integer.Create(-1)), "An integrable singularity at a bound.");
Assert.That(Parse("integral(1/(1+x^2), x, 0, +oo)").InnerSimplified.Simplify().ToString(), Is.EqualTo("pi / 2"));
Assert.That(Parse("integral(1/x, x, -1, 1)").InnerSimplified.ToString(), Is.EqualTo("NaN"), "It diverges across the pole.");
Assert.That(Parse("integral(x^3/(e^x-1), x, 0, +oo)").InnerSimplified, Is.TypeOf<Entity.Integralf>(), "No closed form is found for pi^4/15.");
Assert.That(Parse("integral(x^3/(e^x-1), x, 0, +oo)").Evaled.ToString(), Does.Contain("2.718281828459045"), "Evaled replaces the constants with decimals, even inside an integral it cannot take.");
}
}
[Test]
public void ALimitIsNaNWhereItDoesNotExist()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("sin(x)/x").Limit(X, 0), Is.EqualTo(Entity.Number.Integer.Create(1)));
Assert.That(Parse("1/x").Limit(X, 0, ApproachFrom.Right).ToString(), Is.EqualTo("+oo"));
Assert.That(Parse("1/x").Limit(X, 0, ApproachFrom.Left).ToString(), Is.EqualTo("-oo"));
Assert.That(Parse("1/x").Limit(X, 0, ApproachFrom.BothSides).ToString(), Is.EqualTo("NaN"));
Assert.That(Parse("(1 + 1/x)^x").Limit(X, Parse("+oo")), Is.TypeOf<Entity.Constant>());
Assert.That(Parse("gamma(x)*sin(x)^x").Limit(X, 0), Is.TypeOf<Entity.Limitf>(), "A limit it cannot take comes back as a limit.");
}
}
[Test]
public void SolveWorksOverTheComplexNumbers()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Parse("x^2 - 5*x + 6 = 0").Solve(X).ToString(), Is.EqualTo("{ 2, 3 }"));
Assert.That(Parse("x^2 + 1 = 0").Solve(X).ToString(), Is.EqualTo("{ i, -i }"));
using (MathS.Settings.Codomain.Set(Domain.Real))
Assert.That(Parse("x^2 + 1 = 0").Solve(X).ToString(), Is.EqualTo("{ i, -i }"), "The codomain does not restrict the solutions.");
Assert.That(Parse("x^2 - 2 = 0").Solve(X).ToString(), Is.EqualTo("{ sqrt(2), -sqrt(2) }"), "The solutions stay exact.");
Assert.That(Parse("sin(x) = 1/2").Solve(X).ToString(), Is.EqualTo("{ pi / 6 + 2 * pi * n_1, pi - pi / 6 + 2 * pi * n_1 }"), "n_1 stands for any integer.");
Assert.That(Parse("x^2 < 4").Solve(X), Is.TypeOf<Entity.Set.Interval>());
Assert.That(Parse("x + 1 = x").Solve(X).ToString(), Is.EqualTo("{ }"));
Assert.Throws<SolveRequiresStatementException>(() => Parse("x^2 - 4").Solve(X));
}
}
[Test]
public void WithoutNewtonSolveAnswersOnlyInClosedForm()
{
var withNewton = (Entity.Set.FiniteSet)Parse("x^5 + 3*x + 1 = 0").Solve(X);
using (Assert.EnterMultipleScope())
{
Assert.That(withNewton.Count, Is.EqualTo(5));
Assert.That(withNewton.All(root => root is Entity.Number.Complex { IsExact: false }), "Newton's roots are doubles, printed with every binary digit.");
using (MathS.Settings.AllowNewton.Set(false))
Assert.That(Parse("x^5 + 3*x + 1 = 0").Solve(X), Is.TypeOf<Entity.Set.ConditionalSet>());
Assert.That(Parse("x^200 + x + 1 = 0").Solve(X), Is.TypeOf<Entity.Set.ConditionalSet>());
}
}
[Test]
public void SolveHonorsTheCancellationTokenOfItsThread()
{
var task = Task.Run(() =>
{
using var source = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
MathS.Multithreading.SetLocalCancellationToken(source.Token);
return Parse("x^20 + x + 1 = 0").Solve(X);
});
var exception = Assert.Throws<AggregateException>(() => task.Wait(TimeSpan.FromSeconds(10)))!;
Assert.That(exception.InnerException, Is.InstanceOf<OperationCanceledException>(), "Without the token, this takes about 1.6 seconds.");
}
[Test]
public void DeepNestingNeedsALargeStack()
{
// About 500 nested parentheses overflow the stack of a thread pool thread, which ends the process.
// A thread of its own with 64 MB reads the deepest nesting a 2,000-character expression can hold:
Entity? result = null;
var thread = new Thread(() => result = Parse(new string('(', 1000) + "1" + new string(')', 1000)).Evaled, 64 * 1024 * 1024);
thread.Start();
thread.Join();
Assert.That(result, Is.EqualTo(Entity.Number.Integer.Create(1)));
}
}
@@ -0,0 +1,287 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
using Microsoft.Extensions.Logging.Abstractions;
using PeterO.Numbers;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class CalculateToolTests
{
private static JsonObject Calculate(string expression, int digits = 20) => CalculateTool.Calculate(expression, digits, CancellationToken.None);
private static string Refusal(string expression) => Assert.Throws<MathComputationException>(() => Calculate(expression))!.Message;
private static string Text(JsonObject result, string property) => result[property]!.GetValue<string>();
private static double RelativeError(JsonObject result, string expected)
{
var reference = EDecimal.FromString(expected);
return EDecimal.FromString(Text(result, "decimal")).Subtract(reference).Abs().Divide(reference.Abs(), EContext.ForPrecision(20)).ToDouble();
}
private static JsonElement Arguments(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
[Test]
public void KeepsAFractionExact()
{
var result = Calculate("547 / 57576");
using (Assert.EnterMultipleScope())
{
Assert.That(result["expression"]!.GetValue<string>(), Is.EqualTo("547 / 57576"), "The expression reads as written; its value is the fraction.");
Assert.That(result["exact"]!.GetValue<string>(), Is.EqualTo("547/57576"));
Assert.That(result["exact_latex"]!.GetValue<string>(), Is.EqualTo(@"\frac{547}{57576}"));
Assert.That(result["decimal"]!.GetValue<string>(), Is.EqualTo("0.0095004863137418368765"));
Assert.That(result["digits"]!.GetValue<int>(), Is.EqualTo(20));
Assert.That(result["approximate"]!.GetValue<bool>(), Is.False);
Assert.That(result.ContainsKey("estimated_error"), Is.False);
Assert.That(result.ContainsKey("issues"), Is.False);
}
}
[Test]
public void GivesAnIrrationalValueWithAllDigitsAskedFor()
{
var root = Calculate("sqrt(2)", 30);
var power = Calculate("pi^4/15", 60);
using (Assert.EnterMultipleScope())
{
Assert.That(root["decimal"]!.GetValue<string>(), Is.EqualTo("1.41421356237309504880168872421"));
Assert.That(root.ContainsKey("exact"), Is.False);
Assert.That(power["decimal"]!.GetValue<string>(), Is.EqualTo("6.49393940226682914909602217924700741664850571151236144609786"), "Without guard digits, the last digits would be off.");
Assert.That(power["digits"]!.GetValue<int>(), Is.EqualTo(60));
}
}
[Test]
public void DoesNotTakeANumberCloseToAnIntegerForIt()
{
// e^(pi*sqrt(163)) is 262537412640768743.99999999999925...
var result = Calculate("e^(pi*sqrt(163))", 30);
using (Assert.EnterMultipleScope())
{
Assert.That(result.ContainsKey("exact"), Is.False);
Assert.That(result["decimal"]!.GetValue<string>(), Is.EqualTo("262537412640768743.999999999999"));
}
}
[Test]
public void GivesComplexAndInfiniteValues()
{
var root = Calculate("sqrt(-4)");
var logarithm = Calculate("ln(0)");
using (Assert.EnterMultipleScope())
{
Assert.That(root["decimal"]!.GetValue<string>(), Is.EqualTo("2i"));
Assert.That(root.ContainsKey("exact"), Is.True);
Assert.That(logarithm["decimal"]!.GetValue<string>(), Is.EqualTo("-oo"));
Assert.That(logarithm["digits"]!.GetValue<int>(), Is.Zero);
Assert.That(logarithm["issues"]![0]!.GetValue<string>(), Is.EqualTo("The value is infinite."));
}
}
[Test]
public void ReadsEveryDecimalLiteralAsItsFraction()
{
// AngouriMath alone reads 6.62607015e-34 as an inexact decimal and 1e-60 as 0, see AngouriMathBehaviorTests.DowncastingWhileParsingLosesTinyLiterals:
var planck = Calculate("6.62607015e-34");
var tiny = Calculate("1e-60");
var equation = Calculate("1 + 1e-40 = 1");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(planck, "exact"), Is.EqualTo("132521403/200000000000000000000000000000000000000000"));
Assert.That(Text(planck, "decimal"), Is.EqualTo("6.62607015e-34"));
Assert.That(Text(tiny, "exact"), Is.EqualTo("1/1" + new string('0', 60)));
Assert.That(equation["exactly_equal"]!.GetValue<bool>(), Is.False, "Simplifying would drop an inexact 1e-40 from the sum.");
Assert.That(equation["numerically_equal"]!.GetValue<bool>(), Is.True, "Both sides agree in the 20 digits compared.");
}
}
[Test]
public void KeepsTheDigitsOfTinyValues()
{
// Each of these came out as 0 while AngouriMath downcast, see AngouriMathBehaviorTests.DowncastingTurnsTinyValuesIntoZero:
var photon = Calculate("6.62607015e-34 * 299792458 / 500e-9");
var thermal = Calculate("1.380649e-23 * 300");
var exponential = Calculate("e^(-100)");
var tinier = Calculate("exp(-1000)");
var fraction = Calculate("21/10^50 * pi");
var sum = Calculate("21/10^50 + 1e-60");
var exactFraction = Calculate("1/10^200");
var integral = Calculate("integral(1e-30*x, x, 0, 1)");
var quotient = Calculate("pi/10^200");
var simplifiedToZero = Calculate("1e-2000 * pi");
using (Assert.EnterMultipleScope())
{
Assert.That(RelativeError(quotient, "3.14159265358979323846264e-200"), Is.LessThan(1e-19), "AngouriMath rounds a quotient to decimal places, so the precision grows until the value shows.");
Assert.That(simplifiedToZero.ContainsKey("exact"), Is.False, "AngouriMath simplifies the value to 0, see AngouriMathBehaviorTests.DowncastingWhileParsingLosesTinyLiterals.");
Assert.That(RelativeError(simplifiedToZero, "3.14159265358979323846264e-2000"), Is.LessThan(1e-19));
Assert.That(Text(photon, "decimal"), Is.EqualTo("3.9728917142978574e-19"));
Assert.That(Text(thermal, "decimal"), Is.EqualTo("4.141947e-21"));
Assert.That(RelativeError(exponential, "3.72007597602083596295969580386e-44"), Is.LessThan(1e-19));
Assert.That(RelativeError(tinier, "5.07595889754945676529180947957e-435"), Is.LessThan(1e-19));
Assert.That(RelativeError(fraction, "6.5973445725385658007715511049e-49"), Is.LessThan(1e-19), "The fraction is rounded to decimal places unless the precision grows by its leading zeros.");
Assert.That(Text(sum, "decimal"), Is.EqualTo("2.10000000001e-49"));
Assert.That(Text(exactFraction, "exact"), Is.EqualTo("1/1" + new string('0', 200)));
Assert.That(Text(exactFraction, "decimal"), Is.EqualTo("1e-200"));
Assert.That(RelativeError(integral, "5e-31"), Is.LessThan(1e-13));
foreach (var result in new[] { photon, thermal, exponential, tinier, fraction, sum, exactFraction })
Assert.That(result["digits"]!.GetValue<int>(), Is.EqualTo(20));
}
}
[Test]
public void SaysWhenAZeroMayBeATinyValue()
{
// Simplifying gives 0, but the fraction 1e-2000 needs more digits than NumericEvaluator.MAX_PRECISION for a numerical check:
var result = Calculate("1e-2000 * pi - 1e-2000 * pi");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(result, "decimal"), Is.EqualTo("0"));
Assert.That(result["digits"]!.GetValue<int>(), Is.Zero);
Assert.That(result["issues"]!.AsArray().Select(issue => issue!.GetValue<string>()), Has.Some.StartsWith("The value evaluates to 0 with ").And.Some.Contains("cannot show that it is exactly 0"));
}
}
[Test]
public void IntegratesADefiniteIntegralNumerically()
{
var result = Calculate("integral(x^3/(e^x-1), x, 0, +oo)");
var digits = result["digits"]!.GetValue<int>();
var exact = Math.Pow(Math.PI, 4) / 15;
using (Assert.EnterMultipleScope())
{
Assert.That(digits, Is.InRange(12, 15), "A double carries no more, and the estimated error of the quadrature is conservative.");
Assert.That(double.Parse(result["decimal"]!.GetValue<string>(), System.Globalization.CultureInfo.InvariantCulture), Is.EqualTo(exact).Within(Math.Pow(10, 1 - digits) * exact), "Every digit reported is right.");
Assert.That(result["approximate"]!.GetValue<bool>(), Is.True);
Assert.That(result.ContainsKey("estimated_error"), Is.True);
Assert.That(result.ContainsKey("exact"), Is.False);
Assert.That(result["issues"]!.AsArray().Select(issue => issue!.GetValue<string>()), Has.Some.Contains("was integrated numerically"));
Assert.That(result["issues"]!.AsArray().Select(issue => issue!.GetValue<string>()), Has.Some.StartsWith("Only "), "Fewer digits than asked for are certain.");
}
}
[Test]
public void ComputesAroundAnIntegral()
{
var result = Calculate("1 + integral(e^(-x^2), x, -oo, +oo)");
Assert.That(double.Parse(result["decimal"]!.GetValue<string>(), System.Globalization.CultureInfo.InvariantCulture), Is.EqualTo(1 + Math.Sqrt(Math.PI)).Within(1e-13));
}
[Test]
public void ChecksTheExampleEquation()
{
var result = Calculate("integral(x^3/(e^x-1), x, 0, +oo) = pi^4/15");
using (Assert.EnterMultipleScope())
{
Assert.That(result["numerically_equal"]!.GetValue<bool>(), Is.True);
Assert.That(result["matching_digits"]!.GetValue<int>(), Is.InRange(12, 15));
Assert.That(result["left"]!["approximate"]!.GetValue<bool>(), Is.True);
Assert.That(result["right"]!["expression"]!.GetValue<string>(), Is.EqualTo("pi ^ 4 / 15"));
Assert.That(result.ContainsKey("exactly_equal"), Is.False, "Only two exact sides settle an equation.");
}
}
[Test]
public void SettlesAnEquationOfExactValues()
{
var equal = Calculate("2 + 2 = 4");
var close = Calculate("1/3 = 0.333");
var beyondTheDigits = Calculate("1/3 = 333333333333333333333333333333/1000000000000000000000000000000");
using (Assert.EnterMultipleScope())
{
Assert.That(equal["exactly_equal"]!.GetValue<bool>(), Is.True);
Assert.That(equal["numerically_equal"]!.GetValue<bool>(), Is.True);
Assert.That(close["exactly_equal"]!.GetValue<bool>(), Is.False);
Assert.That(close["numerically_equal"]!.GetValue<bool>(), Is.False);
Assert.That(close["matching_digits"]!.GetValue<int>(), Is.EqualTo(3));
Assert.That(beyondTheDigits["numerically_equal"]!.GetValue<bool>(), Is.True, "Twenty digits agree.");
Assert.That(beyondTheDigits["exactly_equal"]!.GetValue<bool>(), Is.False, "The exact values differ all the same.");
}
}
[Test]
public void RefusesWhatHasNoNumericValue()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Refusal("1/0"), Does.Contain("has no value: it evaluates to NaN"));
Assert.That(Refusal("x + 1"), Does.Contain("contains the variable x, so it has no numeric value. compute_symbolically works with variables."));
Assert.That(Refusal("a*b"), Does.Contain("contains the variables a, b,"));
Assert.That(Refusal("1 < 2"), Does.Contain("is a statement, such as a comparison"));
Assert.That(Refusal("(10^9)!"), Does.Contain("digits as an exact number"));
Assert.That(() => Calculate("integral(x^2, x)"), Throws.InstanceOf<MathComputationException>());
Assert.That(Refusal("sum(1/k, k, 1, 1000)"), Is.EqualTo("'sum(1 / k, k, 1, 1000)' has no value the tool can compute as a single number, as for a sum without a closed form, an infinite sum, or a matrix."), "AngouriMath sums only in closed form; its own message tells a developer to call EvaluableNumerical.");
Assert.That(Refusal("sum(1/k^2, k, 1, +oo)"), Does.Contain("has no value the tool can compute as a single number"));
Assert.That(Refusal("[1, 2] + [3, 4]"), Does.Contain("has no value the tool can compute as a single number"));
}
}
[Test]
public void ComputesDivisorsMultiplesAndSumsInClosedForm()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Text(Calculate("gcd(12, 18)"), "exact"), Is.EqualTo("6"));
Assert.That(Text(Calculate("lcm(4, 6, 10)"), "exact"), Is.EqualTo("60"), "Three arguments nest into two calls.");
Assert.That(Text(Calculate("gcd(2^100, 6^50)"), "exact"), Is.EqualTo("1125899906842624"));
Assert.That(Text(Calculate("sum(k^2, k, 1, 100)"), "exact"), Is.EqualTo("338350"));
Assert.That(Text(Calculate("sum(k, k, 1, 10^7)"), "exact"), Is.EqualTo("50000005000000"), "AngouriMath sums in closed form, never term by term.");
Assert.That(Text(Calculate("sum(k, k, 10, 1)"), "exact"), Is.EqualTo("0"), "An empty sum is 0.");
}
}
[Test]
public void RefusesAnIntegralItCannotIntegrate()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Refusal("integral(1/x, x, -1, 1)"), Does.StartWith("The integrand of 'integral(1 / x, x, -1, 1)' has no finite value at x = 0, inside the interval of integration."));
Assert.That(Refusal("integral(sin(x)/x, x, 0, +oo)"), Does.StartWith("The numerical integration of 'integral(sin(x) / x, x, 0, +oo)' did not settle."));
Assert.That(Refusal("integral(integral(x*y, y, 0, 1), x, 0, 1)"), Does.Contain("depends on the variable of an outer integral"));
}
}
[Test]
public async Task ExecutesThroughTheComputationService()
{
var tool = new CalculateTool(null!, new MathComputationService(NullLogger<MathComputationService>.Instance));
var result = await tool.ExecuteAsync(Arguments("""{"expression": "547 / 57576", "digits": 5}"""), null!);
Assert.That(result.JsonContent!["decimal"]!.GetValue<string>(), Is.EqualTo("0.0095005"));
}
[Test]
public void RefusesDigitsOutsideTheRange()
{
var tool = new CalculateTool(null!, new MathComputationService(NullLogger<MathComputationService>.Instance));
var tooMany = Arguments("""{"expression": "1/3", "digits": 101}""");
var none = Arguments("""{"expression": "1/3", "digits": 0}""");
using (Assert.EnterMultipleScope())
{
Assert.That(() => tool.ExecuteAsync(tooMany, null!), Throws.ArgumentException.With.Message.EqualTo("Argument 'digits' must be at most 100, but was 101. Leave it out to get 20 significant digits."));
Assert.That(() => tool.ExecuteAsync(none, null!), Throws.ArgumentException);
}
}
[Test]
public void TellsTheModelNothingAboutTheLibrary()
{
var definition = new CalculateTool(null!, new MathComputationService(NullLogger<MathComputationService>.Instance)).GetDefinition();
using (Assert.EnterMultipleScope())
{
Assert.That(definition.SystemPromptInstructions, Does.Not.Contain("AngouriMath"), "A model would take the name for something it may call or look up.");
Assert.That(definition.Function.DescriptionForLLM, Does.Not.Contain("AngouriMath"));
Assert.That(definition.Function.Parameters.GetRawText(), Does.Not.Contain("AngouriMath"));
}
}
}
@@ -0,0 +1,259 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
using AngouriMath.Core;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.Mathematics;
/// <summary>
/// Checks compute_symbolically through the computation service, so it runs with the settings of the tools: explicit
/// parsing, and solving without Newton's method.
/// </summary>
[TestFixture]
public sealed class ComputeSymbolicallyToolTests
{
private static readonly MathComputationService SERVICE = new(NullLogger<MathComputationService>.Instance);
private static Task<JsonObject> Compute(SymbolicOperation operation, string expression, string? variable = null, int order = 1, string? lowerBound = null, string? upperBound = null, string? approach = null, ApproachFrom direction = ApproachFrom.BothSides) =>
SERVICE.ComputeAsync(_ => ComputeSymbolicallyTool.Compute(new SymbolicRequest(operation, expression, variable, order, lowerBound, upperBound, approach, direction)));
private static string Text(JsonObject json, string property) => json[property]!.GetValue<string>();
private static IEnumerable<string> Issues(JsonObject json) => json["issues"]?.AsArray().Select(issue => issue!.GetValue<string>()) ?? [];
private static JsonElement Arguments(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
private static string RequestRefusal(string json) => Assert.Throws<ArgumentException>(() => ComputeSymbolicallyTool.ReadRequest(Arguments(json)))!.Message;
[Test]
public async Task SimplifiesAndStatesTheCondition()
{
var identity = await Compute(SymbolicOperation.SIMPLIFY, "sin(x)^2 + cos(x)^2");
var quotient = await Compute(SymbolicOperation.SIMPLIFY, "(x^2 - 1)/(x - 1)");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(identity, "result"), Is.EqualTo("1"));
Assert.That(Text(identity, "verification"), Is.EqualTo("passed"));
Assert.That(identity["complete"]!.GetValue<bool>(), Is.True);
Assert.That(Text(quotient, "result"), Is.EqualTo("x + 1"));
Assert.That(Text(quotient, "condition"), Is.EqualTo("not x - 1 = 0"));
Assert.That(Text(quotient, "verification"), Is.EqualTo("passed"));
}
}
[Test]
public async Task ExpandsAndFactors()
{
var expanded = await Compute(SymbolicOperation.EXPAND, "(x + 1)^3");
var unchanged = await Compute(SymbolicOperation.EXPAND, "(x + y + z + w)^60");
var factored = await Compute(SymbolicOperation.FACTOR, "x^4 - 1");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(expanded, "result"), Is.EqualTo("1 + 3 * x + 3 * x ^ 2 + x ^ 3"));
Assert.That(Text(expanded, "verification"), Is.EqualTo("passed"));
Assert.That(Issues(unchanged), Has.Some.StartsWith("The tool left the expression as it is"));
Assert.That(Text(factored, "result"), Is.EqualTo("(x + 1) * (x - 1) * (x ^ 2 + 1)"));
Assert.That(Text(factored, "verification"), Is.EqualTo("passed"));
}
}
[Test]
public async Task DifferentiatesAndChecksEachOrder()
{
var product = await Compute(SymbolicOperation.DIFFERENTIATE, "x^2*sin(x)");
var third = await Compute(SymbolicOperation.DIFFERENTIATE, "x^5", order: 3);
var parameter = await Compute(SymbolicOperation.DIFFERENTIATE, "a*x^2", "x");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(product, "result"), Is.EqualTo("x ^ 2 * cos(x) + 2 * x * sin(x)"));
Assert.That(Text(product, "variable"), Is.EqualTo("x"));
Assert.That(Text(product, "verification"), Is.EqualTo("passed"));
Assert.That(Text(third, "result"), Is.EqualTo("60 * x ^ 2"));
Assert.That(third["order"]!.GetValue<int>(), Is.EqualTo(3));
Assert.That(Text(third, "verification"), Is.EqualTo("passed"));
Assert.That(Text(parameter, "verification"), Is.EqualTo("passed"));
}
}
[Test]
public async Task IntegratesWithoutBounds()
{
var power = await Compute(SymbolicOperation.INTEGRATE, "x^2");
var withoutClosedForm = await Compute(SymbolicOperation.INTEGRATE, "sin(sin(x))");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(power, "result"), Is.EqualTo("x ^ 3 / 3 + C"));
Assert.That(Text(power, "verification"), Is.EqualTo("passed"));
Assert.That(Issues(power), Has.Member("C stands for any constant."));
Assert.That(withoutClosedForm["complete"]!.GetValue<bool>(), Is.False);
Assert.That(Text(withoutClosedForm, "verification"), Is.EqualTo("not_possible"));
Assert.That(Issues(withoutClosedForm), Has.Member("The tool found no antiderivative in closed form."));
}
}
[Test]
public async Task IntegratesWithBoundsAndChecksByQuadrature()
{
var polynomial = await Compute(SymbolicOperation.INTEGRATE, "x^2", lowerBound: "0", upperBound: "1");
var arctangent = await Compute(SymbolicOperation.INTEGRATE, "1/(1 + x^2)", lowerBound: "0", upperBound: "+oo");
var bose = await Compute(SymbolicOperation.INTEGRATE, "x^3/(e^x - 1)", lowerBound: "0", upperBound: "+oo");
var pole = await Compute(SymbolicOperation.INTEGRATE, "1/x", lowerBound: "-1", upperBound: "1");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(polynomial, "result"), Is.EqualTo("1/3"));
Assert.That(Text(polynomial, "verification"), Is.EqualTo("passed"));
Assert.That(Text(arctangent, "result"), Is.EqualTo("pi / 2"));
Assert.That(Text(arctangent, "verification"), Is.EqualTo("passed"));
Assert.That(bose["complete"]!.GetValue<bool>(), Is.False, "AngouriMath finds no closed form for pi^4/15.");
Assert.That(Issues(bose), Has.Member("The tool found no closed form for this integral."));
Assert.That(Text(pole, "result"), Is.EqualTo("NaN"));
Assert.That(Issues(pole), Has.Some.StartsWith("The result is NaN: the integral has no value"));
Assert.That(Text(pole, "verification"), Is.EqualTo("not_possible"));
}
}
[Test]
public async Task TakesLimits()
{
var sinc = await Compute(SymbolicOperation.LIMIT, "sin(x)/x", approach: "0");
var fromTheRight = await Compute(SymbolicOperation.LIMIT, "1/x", approach: "0", direction: ApproachFrom.Right);
var fromBothSides = await Compute(SymbolicOperation.LIMIT, "1/x", approach: "0");
using (Assert.EnterMultipleScope())
{
Assert.That(Text(sinc, "result"), Is.EqualTo("1"));
Assert.That(Text(sinc, "direction"), Is.EqualTo("both"));
Assert.That(Text(sinc, "verification"), Is.EqualTo("not_possible"));
Assert.That(Text(fromTheRight, "result"), Is.EqualTo("+oo"));
Assert.That(Text(fromTheRight, "direction"), Is.EqualTo("right"));
Assert.That(Text(fromBothSides, "result"), Is.EqualTo("NaN"));
Assert.That(Issues(fromBothSides), Has.Some.StartsWith("The result is NaN, which the tool gives where the limit does not exist"));
}
}
[Test]
public async Task SolvesAndChecksEverySolution()
{
var quadratic = await Compute(SymbolicOperation.SOLVE, "x^2 - 5*x + 6 = 0");
var complex = await Compute(SymbolicOperation.SOLVE, "x^2 + 1 = 0");
var periodic = await Compute(SymbolicOperation.SOLVE, "sin(x) = 1/2");
var withoutEquals = await Compute(SymbolicOperation.SOLVE, "x^2 - 4");
var tiny = await Compute(SymbolicOperation.SOLVE, "1.5e30*x = 3");
using (Assert.EnterMultipleScope())
{
Assert.That(quadratic["solutions"]!.AsArray().Select(solution => solution!["value"]!.GetValue<string>()), Is.EqualTo(new[] { "2", "3" }));
Assert.That(tiny["solutions"]![0]!["decimal"]!.GetValue<string>(), Is.EqualTo("2e-30"), "The decimal of a tiny solution keeps its digits, see NumericEvaluator.EvaluateAt.");
Assert.That(quadratic["solutions"]![0]!["real"]!.GetValue<bool>(), Is.True);
Assert.That(Text(quadratic, "verification"), Is.EqualTo("passed"));
Assert.That(complex["solutions"]![0]!["real"]!.GetValue<bool>(), Is.False);
Assert.That(Text(complex, "verification"), Is.EqualTo("passed"));
Assert.That(Text(periodic, "verification"), Is.EqualTo("passed"), "n_1 takes integer values in the check.");
Assert.That(Issues(periodic), Has.Member("n_1 stands for any integer."));
Assert.That(Issues(withoutEquals), Has.Member("The expression has no '=', so it was solved for expression = 0."));
Assert.That(withoutEquals["solutions"]!.AsArray(), Has.Count.EqualTo(2));
}
}
[Test]
public async Task WorksWithSumsAndDivisors()
{
var gauss = await Compute(SymbolicOperation.SOLVE, "sum(k, k, 1, n) = 55", "n");
var derivative = await Compute(SymbolicOperation.DIFFERENTIATE, "sum(k*x^k, k, 1, 3)", "x");
var divisor = await Compute(SymbolicOperation.SIMPLIFY, "gcd(12, 18) * x");
using (Assert.EnterMultipleScope())
{
Assert.That(gauss["solutions"]!.AsArray().Select(solution => solution!["value"]!.GetValue<string>()), Is.EqualTo(new[] { "10" }), "The closed form holds for n >= 0 only, so -11 is no solution.");
Assert.That(Text(gauss, "verification"), Is.EqualTo("passed"));
Assert.That(Text(derivative, "result"), Is.EqualTo("1 + 4 * x + 9 * x ^ 2"));
Assert.That(Text(derivative, "verification"), Is.EqualTo("passed"));
Assert.That(Text(divisor, "result"), Is.EqualTo("6 * x"));
}
}
[Test]
public async Task SaysWhenThereIsNoSolutionInClosedForm()
{
var quintic = await Compute(SymbolicOperation.SOLVE, "x^5 + 3*x + 1 = 0");
var none = await Compute(SymbolicOperation.SOLVE, "x + 1 = x");
var inequality = await Compute(SymbolicOperation.SOLVE, "x^2 < 4");
using (Assert.EnterMultipleScope())
{
Assert.That(quintic["complete"]!.GetValue<bool>(), Is.False, "Without Newton's method, there are no approximate roots.");
Assert.That(Text(quintic, "verification"), Is.EqualTo("not_possible"));
Assert.That(none["solutions"]!.AsArray(), Is.Empty);
Assert.That(Issues(none), Has.Member("There is no solution."));
Assert.That(Text(inequality, "result"), Is.EqualTo("(-2; 2)"));
Assert.That(inequality.ContainsKey("solutions"), Is.False);
}
}
[Test]
public void RefusesWhatTheOperationCannotTake()
{
using (Assert.EnterMultipleScope())
{
Assert.That(async () => await Compute(SymbolicOperation.SIMPLIFY, "x = 2"), Throws.InstanceOf<MathComputationException>().With.Message.EqualTo("Argument 'expression' is a statement, such as an equation or a comparison, which only the operation 'solve' takes."));
Assert.That(async () => await Compute(SymbolicOperation.DIFFERENTIATE, "a*x^2"), Throws.InstanceOf<MathComputationException>().With.Message.EqualTo("Argument 'expression' has the variables a, x, so argument 'variable' has to say which one to differentiate by."));
Assert.That(async () => await Compute(SymbolicOperation.INTEGRATE, "2"), Throws.InstanceOf<MathComputationException>().With.Message.EqualTo("Argument 'expression' has no variable to integrate over."));
Assert.That(async () => await Compute(SymbolicOperation.SOLVE, "x^2 = 2", "pi"), Throws.InstanceOf<MathComputationException>().With.Message.EqualTo("Argument 'variable' must name a variable, such as x, but was 'pi'."));
Assert.That(async () => await Compute(SymbolicOperation.EXPAND, "(x + y)^500"), Throws.InstanceOf<MathComputationException>().With.Message.Contains("raises a sum to the power of 500"));
}
}
[Test]
public void RefusesArgumentsTheOperationDoesNotUse()
{
using (Assert.EnterMultipleScope())
{
Assert.That(RequestRefusal("""{"operation": "simplify", "expression": "x", "variable": "x"}"""), Is.EqualTo("Argument 'variable' is not used by the operation 'simplify'. Leave it out."));
Assert.That(RequestRefusal("""{"operation": "integrate", "expression": "x", "order": 2}"""), Is.EqualTo("Argument 'order' is not used by the operation 'integrate'. Leave it out."));
Assert.That(RequestRefusal("""{"operation": "integrate", "expression": "x", "lower_bound": "0"}"""), Is.EqualTo("Argument 'lower_bound' needs argument 'upper_bound' as well, since a definite integral has two bounds. Leave both out for an antiderivative."));
Assert.That(RequestRefusal("""{"operation": "limit", "expression": "1/x"}"""), Does.StartWith("Argument 'approach' is required for the operation 'limit'"));
Assert.That(RequestRefusal("""{"operation": "solve", "expression": "x = 1", "direction": "left"}"""), Is.EqualTo("Argument 'direction' is not used by the operation 'solve'. Leave it out."));
Assert.That(RequestRefusal("""{"operation": "differentiate", "expression": "x", "order": 11}"""), Is.EqualTo("Argument 'order' must be at most 10, but was 11. Leave it out for the first derivative."));
Assert.That(RequestRefusal("""{"operation": "derive", "expression": "x"}"""), Is.EqualTo("Argument 'operation' must be one of simplify, expand, factor, differentiate, integrate, limit, solve, but was 'derive'."));
}
}
[Test]
public void ReadsTheArgumentsOfALimit()
{
var request = ComputeSymbolicallyTool.ReadRequest(Arguments("""{"operation": "limit", "expression": "1/x", "variable": null, "approach": "0", "direction": "left"}"""));
using (Assert.EnterMultipleScope())
{
Assert.That(request.Operation, Is.EqualTo(SymbolicOperation.LIMIT));
Assert.That(request.Variable, Is.Null, "In strict mode, a model passes null for an argument it leaves out.");
Assert.That(request.Approach, Is.EqualTo("0"));
Assert.That(request.Direction, Is.EqualTo(ApproachFrom.Left));
Assert.That(request.Order, Is.EqualTo(1));
}
}
[Test]
public async Task ExecutesThroughTheComputationService()
{
var tool = new ComputeSymbolicallyTool(null!, SERVICE);
var result = await tool.ExecuteAsync(Arguments("""{"operation": "factor", "expression": "x^2 - 5*x + 6"}"""), null!);
Assert.That(result.JsonContent!["result"]!.GetValue<string>(), Is.EqualTo("(x - 3) * (x - 2)"));
}
[Test]
public void TellsTheModelNothingAboutTheLibrary()
{
var definition = new ComputeSymbolicallyTool(null!, SERVICE).GetDefinition();
using (Assert.EnterMultipleScope())
{
Assert.That(definition.SystemPromptInstructions, Does.Not.Contain("AngouriMath"), "A model would take the name for something it may call or look up.");
Assert.That(definition.Function.DescriptionForLLM, Does.Not.Contain("AngouriMath"));
Assert.That(definition.Function.Parameters.GetRawText(), Does.Not.Contain("AngouriMath"));
}
}
}
@@ -0,0 +1,129 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class DoubleExponentialQuadratureTests
{
private static Func<double, double> Compiled(string expression) => MathExpressionReader.Read(expression, "expression").Compile<double, double>(MathS.Var("x"));
/// <summary>
/// Checks a converged result against the exact value, and that its estimated error is honest.
/// </summary>
private static void AssertIntegral(Func<double, double> integrand, double lower, double upper, double exact, double relativeTolerance = 1e-13)
{
var result = DoubleExponentialQuadrature.Integrate(integrand, lower, upper);
var actualError = Math.Abs(result.Value - exact);
using (Assert.EnterMultipleScope())
{
Assert.That(result.Status, Is.EqualTo(QuadratureStatus.CONVERGED));
Assert.That(result.Value, Is.EqualTo(exact).Within(relativeTolerance * Math.Abs(exact)));
Assert.That(actualError, Is.LessThanOrEqualTo(Math.Max(result.EstimatedError, 1e-15 * Math.Abs(exact))), "The estimated error must not claim more digits than the value has.");
}
}
[Test]
public void IntegratesOverAFiniteInterval()
{
AssertIntegral(x => x * x, 0, 1, 1.0 / 3);
AssertIntegral(Math.Sin, 0, Math.PI, 2);
AssertIntegral(Math.Exp, -1, 2, Math.Exp(2) - Math.Exp(-1));
}
[Test]
public void IntegratesASingularityAtTheLowerBoundZero()
{
AssertIntegral(Math.Log, 0, 1, -1);
AssertIntegral(x => 1 / Math.Sqrt(x), 0, 1, 2);
}
[Test]
public void ReachesASingularityAtAnotherBoundWithFewerDigits()
{
var result = DoubleExponentialQuadrature.Integrate(x => 1 / Math.Sqrt(1 - x), 0, 1);
using (Assert.EnterMultipleScope())
{
Assert.That(result.Status, Is.EqualTo(QuadratureStatus.CONVERGED));
Assert.That(result.Value, Is.EqualTo(2).Within(1e-6));
Assert.That(Math.Abs(result.Value - 2), Is.LessThanOrEqualTo(result.EstimatedError), "Near 1, a double carries few digits of the distance to 1; the estimate has to say so.");
}
}
[Test]
public void IntegratesOverInfiniteIntervals()
{
AssertIntegral(x => Math.Exp(-x), 0, double.PositiveInfinity, 1);
AssertIntegral(Math.Exp, double.NegativeInfinity, 0, 1);
AssertIntegral(x => 1 / (1 + x * x), 0, double.PositiveInfinity, Math.PI / 2);
AssertIntegral(x => Math.Exp(-x * x), double.NegativeInfinity, double.PositiveInfinity, Math.Sqrt(Math.PI));
AssertIntegral(x => Math.Exp(-x), 2, double.PositiveInfinity, Math.Exp(-2));
}
[Test]
public void IntegratesTheExampleOfTheBoseIntegral()
{
// The compiled integrand is 0/0 at 0, and has no finite value for x below about 1e-16, where e^x - 1 is 0 in doubles:
var bose = Compiled("x^3/(e^x-1)");
Assert.That(double.IsFinite(bose(1e-17)), Is.False);
AssertIntegral(bose, 0, double.PositiveInfinity, Math.Pow(Math.PI, 4) / 15);
}
[Test]
public void ReportsAnIntegralWhichDoesNotSettle()
{
using (Assert.EnterMultipleScope())
{
Assert.That(DoubleExponentialQuadrature.Integrate(x => Math.Sin(x) / x, 0, double.PositiveInfinity).Status, Is.EqualTo(QuadratureStatus.NOT_CONVERGED), "sin(x)/x converges only conditionally, which the substitution cannot settle.");
Assert.That(DoubleExponentialQuadrature.Integrate(x => 1 / x, 0, 1).Status, Is.EqualTo(QuadratureStatus.NOT_CONVERGED), "1/x diverges at 0.");
Assert.That(DoubleExponentialQuadrature.Integrate(x => 1 / x, 1, double.PositiveInfinity).Status, Is.EqualTo(QuadratureStatus.NOT_CONVERGED), "1/x diverges at infinity.");
Assert.That(DoubleExponentialQuadrature.Integrate(Math.Sin, 0, double.PositiveInfinity).Status, Is.EqualTo(QuadratureStatus.NOT_CONVERGED));
}
}
[Test]
public void ReportsWhereTheIntegrandHasNoValue()
{
var pole = DoubleExponentialQuadrature.Integrate(x => 1 / x, -1, 1);
var root = DoubleExponentialQuadrature.Integrate(Compiled("sqrt(x - 1)"), 0, 2);
using (Assert.EnterMultipleScope())
{
Assert.That(pole.Status, Is.EqualTo(QuadratureStatus.NO_VALUE));
Assert.That(pole.FailurePoint, Is.Zero, "The pole lies in the middle of the interval.");
Assert.That(root.Status, Is.EqualTo(QuadratureStatus.NO_VALUE));
Assert.That(root.FailurePoint, Is.LessThan(1), "A compiled function stays real, so sqrt has no value below 1.");
}
}
[Test]
public void ChangesTheSignForReversedBounds()
{
var reversed = DoubleExponentialQuadrature.Integrate(x => x * x, 1, 0);
var empty = DoubleExponentialQuadrature.Integrate(x => x * x, 2, 2);
using (Assert.EnterMultipleScope())
{
Assert.That(reversed.Value, Is.EqualTo(-1.0 / 3).Within(1e-15));
Assert.That(empty.Value, Is.Zero);
Assert.That(empty.Status, Is.EqualTo(QuadratureStatus.CONVERGED));
}
}
[Test]
public void SettlesAnIntegralOfZero()
{
var result = DoubleExponentialQuadrature.Integrate(Math.Sin, -1, 1);
using (Assert.EnterMultipleScope())
{
Assert.That(result.Status, Is.EqualTo(QuadratureStatus.CONVERGED));
Assert.That(result.Value, Is.EqualTo(0).Within(1e-15));
}
}
[Test]
public void StopsAtTheCancellationToken()
{
var canceled = new CancellationToken(canceled: true);
Assert.Throws<OperationCanceledException>(() => DoubleExponentialQuadrature.Integrate(x => Math.Sin(x) / x, 0, double.PositiveInfinity, canceled));
}
}
@@ -0,0 +1,119 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class MathComputationServiceTests
{
private static readonly TimeSpan SHORT_BUDGET = TimeSpan.FromMilliseconds(200);
private static MathComputationService CreateService(TimeSpan? budget = null) => new(NullLogger<MathComputationService>.Instance)
{
TimeBudget = budget ?? MathComputationService.DEFAULT_TIME_BUDGET,
};
/// <summary>
/// A computation which checks its token, and nothing else.
/// </summary>
private static int SpinUntilCanceled(CancellationToken token)
{
while (!token.IsCancellationRequested)
Thread.SpinWait(100);
token.ThrowIfCancellationRequested();
return 0;
}
[Test]
public async Task AComputationRunsWithTheSettingsOfTheTools()
{
var service = CreateService();
var settings = await service.ComputeAsync(_ => new[]
{
Thread.CurrentThread.Name,
MathS.Settings.ExplicitParsingOnly.Value.ToString(),
MathS.Settings.AllowNewton.Value.ToString(),
});
using (Assert.EnterMultipleScope())
{
Assert.That(settings, Is.EqualTo(new[] { "Mathematics", "True", "False" }));
Assert.That(MathS.Settings.ExplicitParsingOnly.Value, Is.False, "The settings stay with the computation.");
}
}
[Test]
public async Task TheLongestExpressionsDoNotOverflowTheStack()
{
// Each of these holds as deep a tree as 2,000 characters allow; on a thread pool thread,
// the first one would end the test run:
var service = CreateService();
var parentheses = new string('(', 999) + "1" + new string(')', 999);
var powerTower = string.Join("^", Enumerable.Repeat("x", 1000));
var sineChain = string.Concat(Enumerable.Repeat("sin(", 399)) + "1" + new string(')', 399);
using (Assert.EnterMultipleScope())
{
Assert.That(await service.ComputeAsync(_ => MathExpressionReader.Read(parentheses, "expression").Evaled), Is.EqualTo(Entity.Number.Integer.Create(1)));
Assert.That(await service.ComputeAsync(_ => MathExpressionReader.Read(powerTower, "expression").Simplify().ToString().Length), Is.GreaterThan(1000));
Assert.That(await service.ComputeAsync(_ => MathExpressionReader.Read(sineChain, "expression").EvalNumerical().ToString()), Does.StartWith("0.08"));
}
}
[Test]
public async Task AComputationWhichChecksItsTokenStopsAtTheBudget()
{
var service = CreateService(SHORT_BUDGET);
var exception = (await Assert.ThrowsAsync<MathComputationException>(() => service.ComputeAsync(SpinUntilCanceled)))!;
Assert.That(exception.Message, Is.EqualTo("The computation did not finish within 0.2 seconds."));
}
[Test]
public async Task AComputationWhichIgnoresItsTokenKeepsTheSlotUntilItEnds()
{
// Not disposed: the stuck computation may still be waiting for it when the test ends.
var service = CreateService(SHORT_BUDGET);
var release = new ManualResetEventSlim();
var stuck = (await Assert.ThrowsAsync<MathComputationException>(() => service.ComputeAsync(_ =>
{
release.Wait(CancellationToken.None);
return 1;
})))!;
var waiting = (await Assert.ThrowsAsync<MathComputationException>(() => service.ComputeAsync(_ => 2)))!;
release.Set();
using (Assert.EnterMultipleScope())
{
Assert.That(stuck.Message, Does.StartWith("The computation did not finish within 0.2 seconds and could not be stopped."));
Assert.That(waiting.Message, Is.EqualTo("Another computation is still running, so this one could not start within 0.2 seconds."));
Assert.That(await service.ComputeAsync(_ => 3), Is.EqualTo(3), "Once the stuck computation ends, the slot is free again.");
}
}
[Test]
public void TheRequestCancelsAComputation()
{
var service = CreateService();
var request = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
Assert.That(() => service.ComputeAsync(SpinUntilCanceled, request.Token), Throws.InstanceOf<OperationCanceledException>());
}
[Test]
public async Task AnExceptionOfAngouriMathBecomesAMessageAndFreesTheSlot()
{
var service = CreateService();
var exception = (await Assert.ThrowsAsync<MathComputationException>(() => service.ComputeAsync(_ => MathExpressionReader.Read("x + 1", "expression").EvalNumerical())))!;
using (Assert.EnterMultipleScope())
{
Assert.That(exception.Message, Does.StartWith("The tool could not compute this:`x + 1` evaluates to `x + 1`"));
Assert.That(await service.ComputeAsync(_ => 4), Is.EqualTo(4));
}
}
}
@@ -0,0 +1,64 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class MathExpressionReaderTests
{
private static string Refusal(string text, string argumentName = "expression") => Assert.Throws<MathComputationException>(() => MathExpressionReader.Read(text, argumentName))!.Message;
[Test]
public void ReadsTheSyntaxOfTheTools()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MathExpressionReader.Read("integral(x^3/(e^x-1), x, 0, +oo)", "expression"), Is.TypeOf<Entity.Integralf>());
Assert.That(MathExpressionReader.Read("x^2 = 4", "expression"), Is.TypeOf<Entity.Equalsf>());
Assert.That(MathExpressionReader.Read("x <= 2", "expression"), Is.TypeOf<Entity.LessOrEqualf>(), "The equals sign of <= is no equation.");
Assert.That(MathExpressionReader.Read("x >= 2", "expression"), Is.TypeOf<Entity.GreaterOrEqualf>());
Assert.That(MathExpressionReader.Read("a <> b", "expression").ToString(), Is.EqualTo("not a = b"));
Assert.That(MathExpressionReader.Read(new string('1', MathLimits.MAX_EXPRESSION_LENGTH), "expression"), Is.InstanceOf<Entity.Number.Integer>());
}
}
[Test]
public void RefusesWhatItCannotReadAsMeant()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Refusal(" "), Is.EqualTo("Argument 'expression' is empty."));
Assert.That(Refusal(new string('1', MathLimits.MAX_EXPRESSION_LENGTH + 1)), Is.EqualTo("Argument 'expression' has 2,001 characters, but at most 2,000 are read."));
Assert.That(Refusal("a != b"), Is.EqualTo("Argument 'expression' contains '!=', which the tool reads as a factorial followed by '='. Not-equal is written '<>'."));
Assert.That(Refusal("|x - 1|"), Does.StartWith("Argument 'expression' contains '|', which the tool reads as divisibility."));
Assert.That(Refusal("2 = 2 = 2"), Does.StartWith("Argument 'expression' contains 2 equals signs, but an expression holds one equation at most."));
Assert.That(Refusal("oo", "approach"), Is.EqualTo("Argument 'approach' uses 'oo', which the tool reads as a variable. Infinity is written +oo or -oo."));
Assert.That(Refusal("1/oo"), Does.Contain("uses 'oo'"));
}
}
[Test]
public void NamesTheMissingOperator()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Refusal("2x"), Is.EqualTo("Argument 'expression' has no operator between '2' and 'x' at column 2. Multiplication is written '*', as in 2*x, and a function takes its argument in parentheses, as in sin(x)."));
Assert.That(Refusal("sin x"), Does.StartWith("Argument 'expression' has no operator between 'sin' and 'x' at column 5."));
Assert.That(Refusal("foo(3)"), Is.EqualTo("Argument 'expression' has no operator between 'foo' and '(' at column 4. The tool knows no function named 'foo', so it would read the name as a variable times the parentheses. Multiplication is written '*'."));
}
}
[Test]
public void ShortensAParseErrorToItsColumnAndCause()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Refusal("2 + * 3"), Is.EqualTo("Argument 'expression' cannot be read at column 5: extraneous input '*'."));
Assert.That(Refusal("(2 + 3"), Is.EqualTo("Argument 'expression' cannot be read at column 7: no viable alternative at input '(2+3'."));
Assert.That(Refusal(@"\frac{1}{2}"), Does.StartWith("Argument 'expression' cannot be read at column 1:"));
Assert.That(Refusal("sin(1, 2)"), Is.EqualTo("Argument 'expression' cannot be read: sin should have exactly 1 argument but 2 arguments are provided."));
Assert.That(Refusal("integral(x^2, x, 2)", "lower_bound"), Does.StartWith("Argument 'lower_bound' cannot be read: integral should have exactly 4 arguments or 2 arguments"));
}
}
}
@@ -0,0 +1,79 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
using PeterO.Numbers;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class MathFormattingTests
{
private static string Format(string value, int digits) => MathFormatting.FormatDecimal(EDecimal.FromString(value), digits);
[Test]
public void WritesDecimalsWithTheirSignificantDigits()
{
var quotient = Entity.Number.Rational.Create(547, 57576).EDecimal;
using (Assert.EnterMultipleScope())
{
Assert.That(MathFormatting.FormatDecimal(quotient, 20), Is.EqualTo("0.0095004863137418368765"));
Assert.That(MathFormatting.FormatDecimal(quotient, 3), Is.EqualTo("0.0095"), "0.00950 at three digits; trailing zeros are left out.");
Assert.That(Format("0.5", 20), Is.EqualTo("0.5"));
Assert.That(Format("120", 20), Is.EqualTo("120"));
Assert.That(Format("1234567.891", 4), Is.EqualTo("1235000"));
Assert.That(Format("-0.000001234", 20), Is.EqualTo("-0.000001234"));
Assert.That(Format("0", 20), Is.EqualTo("0"));
}
}
[Test]
public void WritesVeryLargeAndVerySmallDecimalsInScientificNotation()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Format("602214076000000000000000", 20), Is.EqualTo("6.02214076e+23"));
Assert.That(Format("100000000000000000000", 20), Is.EqualTo("100000000000000000000"), "1e20 is still written out.");
Assert.That(Format("1E-60", 20), Is.EqualTo("1e-60"));
Assert.That(Format("-1.5E-7", 20), Is.EqualTo("-1.5e-7"));
}
}
[Test]
public void WritesValuesWithoutAMeaningAsTheToolsReadThem()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MathFormatting.FormatDecimal(double.NaN, 15), Is.EqualTo("NaN"));
Assert.That(MathFormatting.FormatDecimal(double.PositiveInfinity, 15), Is.EqualTo("+oo"));
Assert.That(MathFormatting.FormatDecimal(EDecimal.NegativeInfinity, 15), Is.EqualTo("-oo"));
Assert.That(MathFormatting.FormatDecimal(Math.PI, 15), Is.EqualTo("3.14159265358979"));
}
}
[Test]
public void CountsTheDigitsTwoValuesShare()
{
using (Assert.EnterMultipleScope())
{
Assert.That(MathFormatting.CountMatchingDigits(EDecimal.FromString("1.0000001"), EDecimal.FromString("1.0000002"), 20), Is.EqualTo(7));
Assert.That(MathFormatting.CountMatchingDigits(EDecimal.FromString("0.99999"), EDecimal.FromString("1.00001"), 20), Is.EqualTo(4), "No digit is the same, but the values are close.");
Assert.That(MathFormatting.CountMatchingDigits(EDecimal.FromString("2.5"), EDecimal.FromString("2.5"), 20), Is.EqualTo(20));
Assert.That(MathFormatting.CountMatchingDigits(EDecimal.FromString("1"), EDecimal.FromString("-1"), 20), Is.Zero);
Assert.That(MathFormatting.CountMatchingDigits(EDecimal.NaN, EDecimal.NaN, 20), Is.Zero);
}
}
[Test]
public void RefusesAResultBeyondTheLimit()
{
var text = new string('1', MathLimits.MAX_RESULT_LENGTH + 1);
using (Assert.EnterMultipleScope())
{
Assert.That(MathFormatting.EnsureLength(new string('1', MathLimits.MAX_RESULT_LENGTH), "The result"), Has.Length.EqualTo(MathLimits.MAX_RESULT_LENGTH));
Assert.That(() => MathFormatting.EnsureLength(text, "The result"), Throws.InstanceOf<MathComputationException>().With.Message.EqualTo("The result has 20,001 characters, but at most 20,000 are returned."));
Assert.That(MathFormatting.ToLatex(MathExpressionReader.Read("547 / 57576", "expression"), "The LaTeX"), Is.EqualTo(@"\frac{547}{57576}"));
Assert.That(MathFormatting.ToText(MathExpressionReader.Read("x^3/(e^x-1)", "expression"), "The result"), Is.EqualTo("x ^ 3 / (e ^ x - 1)"));
}
}
}
@@ -0,0 +1,69 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
namespace AIStudio.Tests.Tools.Mathematics;
/// <summary>
/// Checks that the guards refuse what AngouriMath could not stop, and nothing it computes quickly.
/// </summary>
/// <remarks>
/// None of the refused expressions is computed here: each of them would run on long past the test.
/// </remarks>
[TestFixture]
public sealed class MathGuardsTests
{
private static Entity Read(string text) => MathExpressionReader.Read(text, "expression");
private static string ExactSizeRefusal(string text) => Assert.Throws<MathComputationException>(() => MathGuards.CheckExactSize(Read(text)))!.Message;
[Test]
public void RefusesAPowerWithTooManyDigits()
{
using (Assert.EnterMultipleScope())
{
Assert.That(ExactSizeRefusal("9^9^9"), Is.EqualTo("'9 ^ 9 ^ 9' would have about 369,693,100 digits as an exact number; its decimal logarithm is about 369693099.632. The tool computes such a number in full and cannot stop midway, so exact numbers are limited to 20,000 digits."));
Assert.That(ExactSizeRefusal("9^9^9^9"), Does.StartWith("'9 ^ 9 ^ 9' would have"), "The inner power is judged first, before its value is needed.");
Assert.That(ExactSizeRefusal("1 + 10^20001"), Does.StartWith("'10 ^ 20001' would have about 20,001 digits"));
Assert.That(ExactSizeRefusal("(1/2)^100000"), Does.Contain("would have about 30,103 digits"), "Numerator and denominator count.");
Assert.That(ExactSizeRefusal("sqrt(2)^1000000"), Does.Contain("would have about"), "sqrt(2)^1000000 is the exact number 2^500000.");
Assert.That(ExactSizeRefusal("x^(9^9^9)"), Does.StartWith("'9 ^ 9 ^ 9' would have"), "An exponent is a number even when the base is a variable.");
Assert.That(ExactSizeRefusal("(e^0 + 1)^(10^9)"), Does.Contain("would have about 301,029,996 digits"), "A constant is no variable: the base is the number 2.");
}
}
[Test]
public void RefusesAFactorialWithTooManyDigits()
{
using (Assert.EnterMultipleScope())
{
Assert.That(ExactSizeRefusal("(10^9)!"), Does.StartWith("'(10 ^ 9)!' would have about 8,565,705,"));
Assert.That(ExactSizeRefusal("10000!"), Does.StartWith("'10000!' would have about 35,660 digits"));
Assert.That(ExactSizeRefusal("(10!)!"), Does.StartWith("'(10!)!' would have about"));
}
}
[Test]
public void LetsThroughWhatIsComputedQuickly()
{
using (Assert.EnterMultipleScope())
{
foreach (var text in new[] { "2^10000", "10^19999", "5000!", "0.5!", "(3!)!", "x^1000000", "2^x", "pi^1000000", "e^(10^6)", "1^(10^100)", "0^(10^100)", "547 / 57576", "integral(x^3/(e^x-1), x, 0, +oo)" })
Assert.DoesNotThrow(() => MathGuards.CheckExactSize(Read(text)), text);
}
}
[Test]
public void RefusesTheExpansionOfAHighPowerOfASum()
{
using (Assert.EnterMultipleScope())
{
var refusal = Assert.Throws<MathComputationException>(() => MathGuards.CheckExpansion(Read("1 + (x + y)^500")))!;
Assert.That(refusal.Message, Is.EqualTo("'(x + y) ^ 500' raises a sum to the power of 500. Where the tool may expand, a sum is raised to exponents up to 200 only, since it cannot stop a larger expansion midway."));
Assert.Throws<MathComputationException>(() => MathGuards.CheckExpansion(Read("(x - 1)^(-300)")));
foreach (var text in new[] { "(x + y)^200", "x^500", "(x * y)^500", "(x + y)^n" })
Assert.DoesNotThrow(() => MathGuards.CheckExpansion(Read(text)), text);
}
}
}
@@ -0,0 +1,123 @@
using AIStudio.Tools.Mathematics;
using AngouriMath;
namespace AIStudio.Tests.Tools.Mathematics;
[TestFixture]
public sealed class NumericCheckTests
{
private static NumericCheckOutcome Check(string left, string right) => NumericCheck.AreEqual(MathExpressionReader.Read(left, "left"), MathExpressionReader.Read(right, "right"));
[Test]
public void PassesWhatIsEqual()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Check("sin(x)^2 + cos(x)^2", "1"), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(Check("(x + y)^2", "x^2 + 2*x*y + y^2"), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(Check("x^4 - 1", "(x + 1)*(x - 1)*(x^2 + 1)"), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(Check("547 / 57576", "1094 / 115152"), Is.EqualTo(NumericCheckOutcome.PASSED));
}
}
[Test]
public void PassesAnExpansionWhoseTermsCancel()
{
// At the sample point x = 1.47, the terms of the expanded form reach about 10^92 and add up to
// about 10^-66; in doubles, or at 50 digits, nothing of the value would be left:
var power = MathExpressionReader.Read("(x - 1)^200", "expression");
Assert.That(NumericCheck.AreEqual(power, power.Expand()), Is.EqualTo(NumericCheckOutcome.PASSED));
}
[Test]
public void FailsWhatDiffers()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Check("x^2", "x^3"), Is.EqualTo(NumericCheckOutcome.FAILED));
Assert.That(Check("(x + y)^2", "x^2 + y^2"), Is.EqualTo(NumericCheckOutcome.FAILED));
Assert.That(Check("pi^4/15", "6.4939394022668"), Is.EqualTo(NumericCheckOutcome.FAILED), "Fourteen digits are not equality.");
Assert.That(Check("sin(x)", "sin(x + 2*pi)"), Is.EqualTo(NumericCheckOutcome.PASSED), "pi is a constant, not a variable which takes the values of the sample points.");
Assert.That(Check("e^x", "x + 1"), Is.EqualTo(NumericCheckOutcome.FAILED));
}
}
[Test]
public void TellsTinyValuesApart()
{
// With an absolute tolerance of 1e-30, any two values below it would pass for equal:
var x = MathS.Var("x");
using (Assert.EnterMultipleScope())
{
Assert.That(Check("6.62607015e-34 * x", "6.62607015e-35 * x"), Is.EqualTo(NumericCheckOutcome.FAILED));
Assert.That(Check("6.62607015e-34 * sin(x)", "6.62607015e-34 * sin(x) * (1 + 1e-20)"), Is.EqualTo(NumericCheckOutcome.FAILED));
Assert.That(Check("6.62607015e-34 * x^2", "x * 6.62607015e-34 * x"), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(Check("sin(2*x) - 2*sin(x)*cos(x)", "0"), Is.EqualTo(NumericCheckOutcome.PASSED), "Against 0, the tolerance stays absolute, since terms which cancel leave their rounding noise.");
Assert.That(NumericCheck.IsDerivativeOf(MathExpressionReader.Read("1.32521403e-33 * x", "derivative"), MathExpressionReader.Read("6.62607015e-34 * x^2", "function"), x), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(NumericCheck.IsDerivativeOf(MathExpressionReader.Read("6.62607015e-34 * x", "derivative"), MathExpressionReader.Read("6.62607015e-34 * x^2", "function"), x), Is.EqualTo(NumericCheckOutcome.FAILED));
}
}
[Test]
public void ComparesWhereAConditionHolds()
{
var simplified = MathExpressionReader.Read("(x^2 - 1)/(x - 1)", "expression").Simplify();
using (Assert.EnterMultipleScope())
{
Assert.That(simplified, Is.TypeOf<Entity.Providedf>());
Assert.That(NumericCheck.AreEqual(simplified, MathExpressionReader.Read("x + 1", "expression")), Is.EqualTo(NumericCheckOutcome.PASSED));
}
}
[Test]
public void IsNotPossibleWithoutValues()
{
using (Assert.EnterMultipleScope())
{
Assert.That(Check("integral(sin(sin(x)), x)", "x"), Is.EqualTo(NumericCheckOutcome.NOT_POSSIBLE), "An integral without a closed form has no value.");
Assert.That(Check("1/(x - x)", "1/(y - y)"), Is.EqualTo(NumericCheckOutcome.NOT_POSSIBLE), "No sample point has a value.");
}
}
[Test]
public void ChecksADerivativeByItsCentralDifference()
{
var x = MathS.Var("x");
using (Assert.EnterMultipleScope())
{
Assert.That(NumericCheck.IsDerivativeOf(MathExpressionReader.Read("2*x*sin(x) + x^2*cos(x)", "derivative"), MathExpressionReader.Read("x^2*sin(x)", "function"), x), Is.EqualTo(NumericCheckOutcome.PASSED));
Assert.That(NumericCheck.IsDerivativeOf(MathExpressionReader.Read("2*x + 1", "derivative"), MathExpressionReader.Read("x^2", "function"), x), Is.EqualTo(NumericCheckOutcome.FAILED));
Assert.That(NumericCheck.IsDerivativeOf(MathExpressionReader.Read("a", "derivative"), MathExpressionReader.Read("a*x + C", "function"), x), Is.EqualTo(NumericCheckOutcome.PASSED), "Other variables, the constant of integration among them, take sample values.");
}
}
[Test]
public void GivesAnIntegerParameterIntegerValues()
{
var parameter = MathS.Var("n_1");
var values = Enumerable.Range(0, 6).Select(point => NumericCheck.SampleAssignment(point, [parameter])[parameter]).Distinct().ToList();
using (Assert.EnterMultipleScope())
{
Assert.That(values, Is.EquivalentTo(new Entity[] { -1, 0, 1 }));
Assert.That(NumericCheck.IsIntegerParameter(MathS.Var("n_12")), Is.True);
Assert.That(NumericCheck.IsIntegerParameter(MathS.Var("n")), Is.False);
Assert.That(NumericCheck.IsIntegerParameter(MathS.Var("n_x")), Is.False);
}
}
[Test]
public void TheSamplePointsAreFixedAndDifferForEachVariable()
{
var variables = new[] { MathS.Var("x"), MathS.Var("y"), MathS.Var("z") };
var first = NumericCheck.SampleAssignment(0, variables);
var again = NumericCheck.SampleAssignment(0, variables);
using (Assert.EnterMultipleScope())
{
Assert.That(again, Is.EqualTo(first));
Assert.That(first.Values.Distinct().Count(), Is.EqualTo(3));
Assert.That(first[variables[0]], Is.EqualTo(Entity.Number.Rational.Create(61, 100)));
Assert.That(NumericCheck.SampleAssignment(1, variables)[variables[0]], Is.Not.EqualTo(first[variables[0]]));
}
}
}
@@ -1,6 +1,11 @@
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.ToolCalling;
@@ -188,6 +193,39 @@ public sealed class ToolCollectionTests : ToolRegistryTestBase
}
}
[Test]
public void TheMathematicsCollectionGathersTheToolsWhichCompute()
{
var definition = new MathematicsToolCollection().GetDefinition();
using (Assert.EnterMultipleScope())
{
Assert.That(definition.Id, Is.EqualTo(ToolSelectionRules.MATHEMATICS_COLLECTION_ID));
Assert.That(definition.ToolIds, Is.EqualTo(new[] { ToolSelectionRules.CALCULATE_TOOL_ID, ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID }));
Assert.That(definition.MinimumProviderConfidence, Is.EqualTo(ConfidenceLevel.NONE), "The tools see nothing but what the model writes into their arguments.");
}
}
[Test]
public async Task TheMathematicsCollectionAppearsOnlyWithItsPreview()
{
var computationService = new MathComputationService(NullLogger<MathComputationService>.Instance);
IToolImplementation[] tools = [new CalculateTool(this.SettingsManager, computationService), new ComputeSymbolicallyTool(this.SettingsManager, computationService)];
var registry = new ToolRegistry(tools, [new CodeToolDefinitionSource(tools)], [new MathematicsToolCollection()], this.SettingsManager, this.CreateToolSettingsService(), NullLogger<ToolRegistry>.Instance);
var withoutPreview = await registry.GetCatalogAsync(AIStudio.Tools.Components.CHAT);
this.SettingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_MATHEMATICS_2026);
var withPreview = await registry.GetCatalogAsync(AIStudio.Tools.Components.CHAT);
using (Assert.EnterMultipleScope())
{
Assert.That(withoutPreview, Is.Empty);
Assert.That(withPreview.Select(item => item.Id), Is.EqualTo(new[] { ToolSelectionRules.MATHEMATICS_COLLECTION_ID }), "Both tools appear as one collection.");
Assert.That(registry.GetCollectionId(ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID), Is.EqualTo(ToolSelectionRules.MATHEMATICS_COLLECTION_ID));
Assert.That(registry.ExpandSelection([ToolSelectionRules.MATHEMATICS_COLLECTION_ID]), Is.EquivalentTo(new[] { ToolSelectionRules.CALCULATE_TOOL_ID, ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID }));
}
}
private Dictionary<string, string> StoredLevels => this.SettingsManager.ConfigurationData.Tools.MinimumProviderConfidenceByToolId;
/// <summary>