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,155 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.PluginSystem;
using AngouriMath;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
/// <summary>
/// Evaluates an expression numerically, exactly where it can, and checks an equation by comparing its sides.
/// </summary>
/// <remarks>
/// Models guess digits, slip with fractions, and assert identities they never checked. This tool
/// computes locally with AngouriMath: a rational value stays exact, every other decimal carries only
/// the digits the computation vouches for, and a definite integral is integrated numerically, also
/// over an infinite interval, see NumericEvaluator.<br/><br/>
/// Its result holds nothing but the expression of the model, as AngouriMath read it, and values
/// computed from it, so it opens no way for content from outside and needs no prompt injection
/// filter. Nothing leaves AI Studio either. It belongs to the Mathematics collection, see
/// MathematicsToolCollection.
/// </remarks>
public sealed class CalculateTool(SettingsManager settingsManager, MathComputationService computationService) : IToolImplementation
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(CalculateTool).Namespace, nameof(CalculateTool));
private const string EXPRESSION_ARGUMENT = "expression";
private const string DIGITS_ARGUMENT = "digits";
private const int DEFAULT_DIGITS = 20;
/// <summary>
/// The syntax both Mathematics tools read, described for the model.
/// </summary>
internal const string SYNTAX = "Plain syntax, not LaTeX: + - * / ^ and ! for the factorial, with every multiplication written as *, e.g., 2*pi*x. Functions take their arguments in parentheses: sqrt, exp, ln, log(x) to base 10, log(b, x) to base b, sin, cos, tan, arcsin, arccos, arctan, sinh, cosh, tanh, abs, floor, ceil, gamma, binomial(n, k), gcd(a, b), lcm(a, b), erf. Constants are pi, e, and i; infinity is +oo or -oo. A definite integral is integral(f, x, a, b), a limit limit(f, x, a), and a sum sum(f, k, a, b) over the integers k from a to b. A sum is computed only in closed form, such as for powers of k; any other sum has to be written out term by term. Not-equal is written <>, and | is not an operator.";
public string ImplementationKey => ToolSelectionRules.CALCULATE_TOOL_ID;
public ToolDefinition GetDefinition() => new()
{
Id = ToolSelectionRules.CALCULATE_TOOL_ID,
ImplementationKey = ToolSelectionRules.CALCULATE_TOOL_ID,
// No minimum confidence of its own: the Mathematics collection states it, see MathematicsToolCollection.
SystemPromptInstructions = """
Use `calculate` for every number beyond simple mental arithmetic, instead of working out digits yourself, and to check a numerical claim before you make it.
- It reads plain syntax, not LaTeX. Translate LaTeX first, write every multiplication with `*`, and give each function its arguments in parentheses, e.g., `2*pi*sqrt(x)` for `2\pi\sqrt{x}`.
- `expression` in the result shows how your input was read. Compare it with what you meant.
- Give the decimal with the digits returned, never more. `exact` is the exact value of a rational result.
- When `approximate` is true, a definite integral was integrated numerically: say so, together with its `estimated_error`.
- For an equation, `numerically_equal` means that both sides agree in all `matching_digits`. That supports the equation, but does not prove it; say so. Only when both sides are exact, `exactly_equal` settles it.
- When the tool refuses an expression, its message says how the tool reads it and how it is written instead.
""",
Function = new()
{
Name = ToolSelectionRules.CALCULATE_TOOL_ID,
DescriptionForLLM = "Evaluate a mathematical expression locally, exactly where possible: fractions stay exact, and a decimal carries only the digits the computation vouches for. Definite integrals, also over infinite intervals, are integrated numerically. An expression with one '=' is an equation, whose two sides are evaluated and compared.",
Parameters = ToolParameterSchemaBuilder.Create()
.RequiredString(EXPRESSION_ARGUMENT, $"The expression, without variables except those bound by an integral or a limit. {SYNTAX} Example: integral(x^3/(e^x - 1), x, 0, +oo) = pi^4/15")
.OptionalInteger(DIGITS_ARGUMENT, $"Optional: how many significant digits the decimal should have, from 1 to {MathLimits.MAX_DIGITS}. Fewer come back when the computation cannot vouch for more, e.g., about 14 after a numerical integration. Leave it out to get {DEFAULT_DIGITS}.")
.Build(),
},
};
public string Icon => Icons.Material.Filled.Calculate;
public bool IsAvailable => PreviewFeatures.PRE_MATHEMATICS_2026.IsEnabled(settingsManager);
// Everything is computed locally:
public ToolOutboundData OutboundData => ToolOutboundData.NONE;
public IReadOnlySet<string> SensitiveTraceArgumentNames => new HashSet<string>(StringComparer.Ordinal);
public string GetDisplayName() => TB("Calculate");
public string GetDescription() => TB("Lets the AI calculate exactly instead of guessing digits, e.g., fractions, roots, or integrals, and check an equation. Everything is computed on your computer.");
public async Task<ToolExecutionResult> ExecuteAsync(JsonElement arguments, ToolExecutionContext context, CancellationToken token = default)
{
var text = ToolArgumentReader.ReadRequiredString(arguments, EXPRESSION_ARGUMENT);
var digits = ReadDigits(arguments);
var result = await computationService.ComputeAsync(computationToken => Calculate(text, digits, computationToken), token);
return new ToolExecutionResult { JsonContent = result };
}
private static int ReadDigits(JsonElement arguments)
{
var digits = ToolArgumentReader.ReadOptionalPositiveInt(arguments, DIGITS_ARGUMENT, $"to get {DEFAULT_DIGITS} significant digits");
if (digits is null)
return DEFAULT_DIGITS;
if (digits > MathLimits.MAX_DIGITS)
throw new ArgumentException($"Argument '{DIGITS_ARGUMENT}' must be at most {MathLimits.MAX_DIGITS}, but was {digits}. Leave it out to get {DEFAULT_DIGITS} significant digits.");
return digits.Value;
}
/// <summary>
/// Evaluates the expression, or both sides of an equation, and writes the result for the model.
/// </summary>
internal static JsonObject Calculate(string text, int digits, CancellationToken token)
{
var expression = MathExpressionReader.Read(text, EXPRESSION_ARGUMENT);
var result = new JsonObject
{
["expression"] = MathFormatting.ToText(expression, "The expression as read"),
["latex"] = MathFormatting.ToLatex(expression, "The LaTeX of the expression"),
};
if (expression is not Entity.Equalsf equation)
{
var value = NumericEvaluator.Evaluate(expression, digits, token);
Describe(value, result);
return result;
}
var left = NumericEvaluator.Evaluate(equation.Left, digits, token);
var right = NumericEvaluator.Evaluate(equation.Right, digits, token);
var certainDigits = Math.Min(left.Digits, right.Digits);
var matchingDigits = Math.Min(MathFormatting.CountMatchingDigits(left.Value, right.Value, digits), certainDigits);
result["left"] = Describe(left, new JsonObject { ["expression"] = MathFormatting.ToText(equation.Left, "The left side") });
result["right"] = Describe(right, new JsonObject { ["expression"] = MathFormatting.ToText(equation.Right, "The right side") });
result["numerically_equal"] = certainDigits > 0 && matchingDigits >= certainDigits;
result["matching_digits"] = matchingDigits;
// Two exact values settle the question, also where they differ only beyond the digits compared:
if (left.Exact is not null && right.Exact is not null)
result["exactly_equal"] = left.Exact.Equals(right.Exact);
return result;
}
private static JsonObject Describe(NumericValue value, JsonObject json)
{
if (value.Exact is not null)
{
json["exact"] = MathFormatting.ToText(value.Exact, "The exact value");
json["exact_latex"] = MathFormatting.ToLatex(value.Exact, "The LaTeX of the exact value");
}
json["decimal"] = MathFormatting.FormatNumber(value.Value, Math.Max(value.Digits, 1));
json["digits"] = value.Digits;
json["approximate"] = value.Approximate;
if (value.EstimatedError is { } estimatedError)
json["estimated_error"] = MathFormatting.FormatDecimal(estimatedError, 2);
if (value.Issues.Count > 0)
json["issues"] = new JsonArray([..value.Issues.Select(issue => JsonValue.Create(issue))]);
return json;
}
}
@@ -0,0 +1,506 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Mathematics;
using AIStudio.Tools.PluginSystem;
using AngouriMath;
using AngouriMath.Core;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
/// <summary>
/// Computes with formulas: simplifies, expands, factors, differentiates, integrates, takes limits, and solves.
/// </summary>
/// <remarks>
/// AngouriMath does the algebra locally, and the tool checks each answer numerically wherever it can,
/// see NumericCheck: a symbolic answer is only as good as the library which gave it, and AngouriMath
/// changes answers between releases. Where AngouriMath finds no closed form, the result says so
/// instead of passing the unsolved problem off as an answer.<br/><br/>
/// The arguments are checked strictly against the operation: an argument which the operation does
/// not use is refused rather than ignored, since the model meant something by it.<br/><br/>
/// Like Calculate, its result holds nothing but the expression of the model and what was computed
/// from it, so it needs no prompt injection filter, and nothing leaves AI Studio. It belongs to the
/// Mathematics collection, see MathematicsToolCollection.
/// </remarks>
public sealed class ComputeSymbolicallyTool(SettingsManager settingsManager, MathComputationService computationService) : IToolImplementation
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ComputeSymbolicallyTool).Namespace, nameof(ComputeSymbolicallyTool));
private const string OPERATION_ARGUMENT = "operation";
private const string EXPRESSION_ARGUMENT = "expression";
private const string VARIABLE_ARGUMENT = "variable";
private const string ORDER_ARGUMENT = "order";
private const string LOWER_BOUND_ARGUMENT = "lower_bound";
private const string UPPER_BOUND_ARGUMENT = "upper_bound";
private const string APPROACH_ARGUMENT = "approach";
private const string DIRECTION_ARGUMENT = "direction";
private const int MAX_ORDER = 10;
private const int SOLUTION_DIGITS = 15;
private const int MAX_ECHO_LENGTH = 40;
/// <summary>
/// The relative difference within which a definite integral agrees with its numerical integration,
/// at least: a double carries no more.
/// </summary>
private const double MIN_QUADRATURE_TOLERANCE = 1e-12;
/// <summary>
/// The precision of a value which ends up as a double, such as a bound of an integral.
/// </summary>
private const int DOUBLE_PRECISION = 30;
private static readonly Dictionary<string, SymbolicOperation> OPERATIONS = new(StringComparer.Ordinal)
{
["simplify"] = SymbolicOperation.SIMPLIFY,
["expand"] = SymbolicOperation.EXPAND,
["factor"] = SymbolicOperation.FACTOR,
["differentiate"] = SymbolicOperation.DIFFERENTIATE,
["integrate"] = SymbolicOperation.INTEGRATE,
["limit"] = SymbolicOperation.LIMIT,
["solve"] = SymbolicOperation.SOLVE,
};
private static readonly Dictionary<string, ApproachFrom> DIRECTIONS = new(StringComparer.Ordinal)
{
["both"] = ApproachFrom.BothSides,
["left"] = ApproachFrom.Left,
["right"] = ApproachFrom.Right,
};
private static readonly string[] OPERATION_NAMES = [..OPERATIONS.Keys];
private static readonly string[] DIRECTION_NAMES = [..DIRECTIONS.Keys];
public string ImplementationKey => ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID;
public ToolDefinition GetDefinition() => new()
{
Id = ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID,
ImplementationKey = ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID,
// No minimum confidence of its own: the Mathematics collection states it, see MathematicsToolCollection.
SystemPromptInstructions = """
Use `compute_symbolically` to simplify, expand, or factor an expression, to differentiate or integrate it, to take a limit, or to solve an equation or an inequality, instead of doing the algebra yourself.
- It reads the same plain syntax as `calculate`, not LaTeX.
- `result` is the answer in plain syntax, and `result_latex` the same for your reply. A `condition` says where the result holds, e.g., `not x - 1 = 0`; mention it.
- When `complete` is false, the tool found no closed form, and `result` still holds the integral, the limit, or the equation. Never present it as an answer; `calculate` gives a definite integral numerically.
- `verification` says whether the result was checked numerically: `passed`, `failed`, or `not_possible`. Never present a result with `failed` as correct, and say when the check was `not_possible`.
- Solutions may be complex; `real` tells for each one with a value. n_1 stands for any integer, r_1 for any real number, and C for any constant.
""",
Function = new()
{
Name = ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID,
DescriptionForLLM = "Compute with formulas locally: simplify, expand, or factor an expression, differentiate it, integrate it with or without bounds, take a limit, or solve an equation or inequality. Each result is checked numerically where possible, and says when no closed form was found.",
Parameters = ToolParameterSchemaBuilder.Create()
.RequiredEnum(OPERATION_ARGUMENT, "What to do with the expression.", OPERATION_NAMES)
.RequiredString(EXPRESSION_ARGUMENT, $"The expression, or for solve the equation or inequality. {CalculateTool.SYNTAX} Example: x^2 - 5*x + 6 = 0")
.OptionalString(VARIABLE_ARGUMENT, "For differentiate, integrate, limit, and solve: the variable to differentiate by, integrate over, approach in, or solve for, e.g., x. Leave it out when the expression has only one variable, and always for simplify, expand, and factor.")
.OptionalInteger(ORDER_ARGUMENT, $"Only for differentiate: how often to differentiate, from 1 to {MAX_ORDER}. Leave it out for the first derivative.")
.OptionalString(LOWER_BOUND_ARGUMENT, "Only for integrate: the lower bound of a definite integral, such as 0 or -oo. Give both bounds or none; leave both out for an antiderivative.")
.OptionalString(UPPER_BOUND_ARGUMENT, "Only for integrate: the upper bound of a definite integral, such as pi or +oo.")
.OptionalString(APPROACH_ARGUMENT, "Only for limit, and required there: the point the variable approaches, such as 0 or +oo.")
.OptionalEnum(DIRECTION_ARGUMENT, "Only for limit: approach from both sides, from the left, or from the right. Leave it out for both sides.", DIRECTION_NAMES)
.Build(),
},
};
public string Icon => Icons.Material.Filled.Functions;
public bool IsAvailable => PreviewFeatures.PRE_MATHEMATICS_2026.IsEnabled(settingsManager);
// Everything is computed locally:
public ToolOutboundData OutboundData => ToolOutboundData.NONE;
public IReadOnlySet<string> SensitiveTraceArgumentNames => new HashSet<string>(StringComparer.Ordinal);
public string GetDisplayName() => TB("Compute Symbolically");
public string GetDescription() => TB("Lets the AI work with formulas: simplify and factor them, differentiate and integrate them, take limits, and solve equations. Every result is checked numerically where possible, right on your computer.");
public async Task<ToolExecutionResult> ExecuteAsync(JsonElement arguments, ToolExecutionContext context, CancellationToken token = default)
{
var request = ReadRequest(arguments);
var result = await computationService.ComputeAsync(_ => Compute(request), token);
return new ToolExecutionResult { JsonContent = result };
}
/// <summary>
/// Reads the arguments, and refuses each one which the operation does not use.
/// </summary>
internal static SymbolicRequest ReadRequest(JsonElement arguments)
{
var operationName = ToolArgumentReader.ReadRequiredString(arguments, OPERATION_ARGUMENT);
if (!OPERATIONS.TryGetValue(operationName, out var operation))
throw new ArgumentException($"Argument '{OPERATION_ARGUMENT}' must be one of {string.Join(", ", OPERATION_NAMES)}, but was '{operationName.Shorten(MAX_ECHO_LENGTH)}'.");
var expression = ToolArgumentReader.ReadRequiredString(arguments, EXPRESSION_ARGUMENT);
var variable = ToolArgumentReader.ReadOptionalString(arguments, VARIABLE_ARGUMENT, "to take the only variable of the expression");
var order = ToolArgumentReader.ReadOptionalPositiveInt(arguments, ORDER_ARGUMENT, "for the first derivative");
var lowerBound = ToolArgumentReader.ReadOptionalString(arguments, LOWER_BOUND_ARGUMENT, "together with the upper bound for an antiderivative");
var upperBound = ToolArgumentReader.ReadOptionalString(arguments, UPPER_BOUND_ARGUMENT, "together with the lower bound for an antiderivative");
var approach = ToolArgumentReader.ReadOptionalString(arguments, APPROACH_ARGUMENT, "for any operation but limit");
var direction = ToolArgumentReader.ReadOptionalChoice(arguments, DIRECTION_ARGUMENT, DIRECTION_NAMES, "to approach from both sides");
if (variable is not null && operation is SymbolicOperation.SIMPLIFY or SymbolicOperation.EXPAND or SymbolicOperation.FACTOR)
throw NotUsed(VARIABLE_ARGUMENT, operationName);
if (variable is "")
throw new ArgumentException($"Argument '{VARIABLE_ARGUMENT}' must name a variable, such as x, but was empty. Leave it out to take the only variable of the expression.");
if (order is not null && operation is not SymbolicOperation.DIFFERENTIATE)
throw NotUsed(ORDER_ARGUMENT, operationName);
if (order > MAX_ORDER)
throw new ArgumentException($"Argument '{ORDER_ARGUMENT}' must be at most {MAX_ORDER}, but was {order}. Leave it out for the first derivative.");
if ((lowerBound is not null || upperBound is not null) && operation is not SymbolicOperation.INTEGRATE)
throw NotUsed(lowerBound is not null ? LOWER_BOUND_ARGUMENT : UPPER_BOUND_ARGUMENT, operationName);
if ((lowerBound is null) != (upperBound is null))
throw new ArgumentException($"Argument '{(lowerBound is null ? UPPER_BOUND_ARGUMENT : LOWER_BOUND_ARGUMENT)}' needs argument '{(lowerBound is null ? LOWER_BOUND_ARGUMENT : UPPER_BOUND_ARGUMENT)}' as well, since a definite integral has two bounds. Leave both out for an antiderivative.");
if (approach is not null && operation is not SymbolicOperation.LIMIT)
throw NotUsed(APPROACH_ARGUMENT, operationName);
if (approach is null && operation is SymbolicOperation.LIMIT)
throw new ArgumentException($"Argument '{APPROACH_ARGUMENT}' is required for the operation 'limit': it names the point the variable approaches, such as 0 or +oo.");
if (direction is not null && operation is not SymbolicOperation.LIMIT)
throw NotUsed(DIRECTION_ARGUMENT, operationName);
return new(operation, expression, variable, order ?? 1, lowerBound, upperBound, approach, direction is null ? ApproachFrom.BothSides : DIRECTIONS[direction]);
}
private static ArgumentException NotUsed(string argument, string operationName) => new($"Argument '{argument}' is not used by the operation '{operationName}'. Leave it out.");
/// <summary>
/// Carries out the operation and writes the result for the model.
/// </summary>
internal static JsonObject Compute(SymbolicRequest request)
{
var expression = MathExpressionReader.Read(request.Expression, EXPRESSION_ARGUMENT);
MathGuards.CheckExactSize(expression);
MathGuards.CheckExpansion(expression);
if (expression is Entity.Statement && request.Operation is not SymbolicOperation.SOLVE)
throw new MathComputationException($"Argument '{EXPRESSION_ARGUMENT}' is a statement, such as an equation or a comparison, which only the operation 'solve' takes.");
var json = new JsonObject
{
["operation"] = OPERATIONS.First(pair => pair.Value == request.Operation).Key,
["expression"] = MathFormatting.ToText(expression, "The expression as read"),
["latex"] = MathFormatting.ToLatex(expression, "The LaTeX of the expression"),
};
var issues = new List<string>();
switch (request.Operation)
{
case SymbolicOperation.SIMPLIFY:
Transformed(json, issues, expression, expression.Simplify(), "The tool found nothing to simplify.");
break;
case SymbolicOperation.EXPAND:
Transformed(json, issues, expression, expression.Expand(), "The tool left the expression as it is, e.g., since its expansion would have more than 2,000 terms.");
break;
case SymbolicOperation.FACTOR:
Transformed(json, issues, expression, expression.Factorize(), "The tool found no factorization.");
break;
case SymbolicOperation.DIFFERENTIATE:
Differentiate(json, issues, expression, VariableOf(request, expression, "differentiate by"), request.Order);
break;
case SymbolicOperation.INTEGRATE:
Integrate(json, issues, expression, VariableOf(request, expression, "integrate over"), request);
break;
case SymbolicOperation.LIMIT:
Limit(json, issues, expression, VariableOf(request, expression, "take the limit in"), request);
break;
case SymbolicOperation.SOLVE:
Solve(json, issues, expression, VariableOf(request, expression, "solve for"));
break;
}
if (issues.Count > 0)
json["issues"] = new JsonArray([..issues.Select(issue => JsonValue.Create(issue))]);
return json;
}
/// <summary>
/// The variable of the operation: the one the model named, or else the only one of the expression.
/// </summary>
private static Entity.Variable VariableOf(SymbolicRequest request, Entity expression, string verb)
{
if (request.Variable is not null)
{
var named = MathExpressionReader.Read(request.Variable, VARIABLE_ARGUMENT);
if (named is not Entity.Variable variable || named is Entity.Constant)
throw new MathComputationException($"Argument '{VARIABLE_ARGUMENT}' must name a variable, such as x, but was '{request.Variable.Shorten(MAX_ECHO_LENGTH)}'.");
return variable;
}
var variables = MathVariables.FreeIn(expression);
return variables.Count switch
{
1 => variables[0],
0 => throw new MathComputationException($"Argument '{EXPRESSION_ARGUMENT}' has no variable to {verb}."),
_ => throw new MathComputationException($"Argument '{EXPRESSION_ARGUMENT}' has the variables {string.Join(", ", variables.Select(variable => variable.Name))}, so argument '{VARIABLE_ARGUMENT}' has to say which one to {verb}."),
};
}
private static void Transformed(JsonObject json, List<string> issues, Entity expression, Entity result, string unchanged)
{
if (result.Equals(expression))
issues.Add(unchanged);
Result(json, issues, expression, result);
json["complete"] = true;
json["verification"] = Verification(NumericCheck.AreEqual(expression, result));
}
private static void Differentiate(JsonObject json, List<string> issues, Entity expression, Entity.Variable variable, int order)
{
// One order at a time, so each step is checked against the one before:
var derivative = expression;
var outcome = NumericCheckOutcome.PASSED;
for (var step = 0; step < order; step++)
{
var next = derivative.Differentiate(variable).Simplify();
outcome = Combined(outcome, NumericCheck.IsDerivativeOf(next, derivative, variable));
derivative = next;
}
json["variable"] = variable.Name;
json["order"] = order;
Result(json, issues, expression, derivative);
json["complete"] = true;
json["verification"] = Verification(outcome);
}
private static void Integrate(JsonObject json, List<string> issues, Entity expression, Entity.Variable variable, SymbolicRequest request)
{
json["variable"] = variable.Name;
if (request.LowerBound is null || request.UpperBound is null)
{
var antiderivative = expression.Integrate(variable);
var complete = !Contains<Entity.Integralf>(antiderivative);
Result(json, issues, expression, antiderivative);
json["complete"] = complete;
json["verification"] = Verification(complete ? NumericCheck.IsDerivativeOf(expression, antiderivative, variable) : NumericCheckOutcome.NOT_POSSIBLE);
if (!complete)
issues.Add("The tool found no antiderivative in closed form.");
return;
}
var lower = MathExpressionReader.Read(request.LowerBound, LOWER_BOUND_ARGUMENT);
var upper = MathExpressionReader.Read(request.UpperBound, UPPER_BOUND_ARGUMENT);
MathGuards.CheckExactSize(lower);
MathGuards.CheckExactSize(upper);
json["lower_bound"] = MathFormatting.ToText(lower, "The lower bound");
json["upper_bound"] = MathFormatting.ToText(upper, "The upper bound");
var integral = MathS.Integral(expression, variable, lower, upper).InnerSimplified.Simplify();
var closed = !Contains<Entity.Integralf>(integral);
Result(json, issues, expression, integral);
json["complete"] = closed;
if (!closed)
issues.Add("The tool found no closed form for this integral.");
else if (IsNaN(integral))
issues.Add("The result is NaN: the integral has no value, e.g., since it diverges across a pole.");
json["verification"] = Verification(closed ? CheckByQuadrature(integral, expression, variable, lower, upper) : NumericCheckOutcome.NOT_POSSIBLE);
}
/// <summary>
/// Compares the value of a definite integral with its numerical integration, where both are numbers.
/// </summary>
private static NumericCheckOutcome CheckByQuadrature(Entity integral, Entity integrand, Entity.Variable variable, Entity lower, Entity upper)
{
if (MathVariables.FreeIn(integrand).Any(free => free != variable) || MathVariables.HasAny(lower) || MathVariables.HasAny(upper) || MathVariables.HasAny(integral))
return NumericCheckOutcome.NOT_POSSIBLE;
try
{
var value = NumericEvaluator.EvaluateAt(integral, DOUBLE_PRECISION);
if (!value.ImaginaryPart.EDecimal.IsZero)
return NumericCheckOutcome.NOT_POSSIBLE;
var exact = value.RealPart.EDecimal.ToDouble();
var numeric = DoubleExponentialQuadrature.Integrate(integrand.Compile<double, double>(variable), BoundOf(lower), BoundOf(upper));
if (numeric.Status is not QuadratureStatus.CONVERGED || !double.IsFinite(exact))
return NumericCheckOutcome.NOT_POSSIBLE;
var tolerance = Math.Max(10 * numeric.EstimatedError, MIN_QUADRATURE_TOLERANCE * Math.Max(1, Math.Abs(exact)));
return Math.Abs(exact - numeric.Value) <= tolerance ? NumericCheckOutcome.PASSED : NumericCheckOutcome.FAILED;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return NumericCheckOutcome.NOT_POSSIBLE;
}
}
private static double BoundOf(Entity bound)
{
var value = NumericEvaluator.EvaluateAt(bound, DOUBLE_PRECISION).RealPart.EDecimal;
if (value.IsInfinity())
return value.IsNegative ? double.NegativeInfinity : double.PositiveInfinity;
return value.ToDouble();
}
private static void Limit(JsonObject json, List<string> issues, Entity expression, Entity.Variable variable, SymbolicRequest request)
{
var approach = MathExpressionReader.Read(request.Approach!, APPROACH_ARGUMENT);
MathGuards.CheckExactSize(approach);
var limit = expression.Limit(variable, approach, request.Direction);
var complete = !Contains<Entity.Limitf>(limit);
json["variable"] = variable.Name;
json["approach"] = MathFormatting.ToText(approach, "The point approached");
json["direction"] = DIRECTIONS.First(pair => pair.Value == request.Direction).Key;
Result(json, issues, expression, limit);
json["complete"] = complete;
json["verification"] = Verification(NumericCheckOutcome.NOT_POSSIBLE);
if (!complete)
issues.Add("The tool could not determine this limit.");
else if (IsNaN(limit))
issues.Add("The result is NaN, which the tool gives where the limit does not exist, e.g., where the one-sided limits differ.");
}
private static void Solve(JsonObject json, List<string> issues, Entity expression, Entity.Variable variable)
{
var statement = expression;
if (expression is not Entity.Statement)
{
statement = new Entity.Equalsf(expression, 0);
issues.Add("The expression has no '=', so it was solved for expression = 0.");
}
var solutions = statement.Solve(variable);
json["variable"] = variable.Name;
json["result"] = MathFormatting.ToText(solutions, "The set of solutions");
json["result_latex"] = MathFormatting.ToLatex(solutions, "The LaTeX of the solutions");
ExplainParameters(issues, expression, solutions);
if (solutions is Entity.Set.ConditionalSet)
{
json["complete"] = false;
json["verification"] = Verification(NumericCheckOutcome.NOT_POSSIBLE);
issues.Add("The tool found no solution in closed form; the result restates the equation as a set.");
return;
}
json["complete"] = true;
if (solutions is not Entity.Set.FiniteSet finite)
{
json["verification"] = Verification(NumericCheckOutcome.NOT_POSSIBLE);
return;
}
var outcome = finite.Count == 0 || statement is not Entity.Equalsf ? NumericCheckOutcome.NOT_POSSIBLE : NumericCheckOutcome.PASSED;
var list = new JsonArray();
foreach (var solution in finite)
{
list.Add(Solution(solution));
if (statement is Entity.Equalsf equation)
outcome = Combined(outcome, NumericCheck.AreEqual(equation.Left.Substitute(variable, solution), equation.Right.Substitute(variable, solution)));
}
json["solutions"] = list;
json["verification"] = Verification(outcome);
if (finite.Count == 0)
issues.Add("There is no solution.");
}
private static JsonObject Solution(Entity solution)
{
var json = new JsonObject
{
["value"] = MathFormatting.ToText(solution, "A solution"),
["latex"] = MathFormatting.ToLatex(solution, "The LaTeX of a solution"),
};
if (MathVariables.HasAny(solution))
return json;
try
{
var value = NumericEvaluator.Evaluate(solution, SOLUTION_DIGITS, CancellationToken.None);
json["decimal"] = MathFormatting.FormatNumber(value.Value, Math.Max(value.Digits, 1));
json["real"] = value.Value.ImaginaryPart.EDecimal.IsZero;
}
catch (MathComputationException)
{
// A solution without a value, such as NaN, keeps only its exact form.
}
return json;
}
/// <summary>
/// Writes a result, with its condition apart when AngouriMath states one.
/// </summary>
private static void Result(JsonObject json, List<string> issues, Entity expression, Entity result)
{
if (result is Entity.Providedf provided)
{
json["result"] = MathFormatting.ToText(provided.Expression, "The result");
json["result_latex"] = MathFormatting.ToLatex(provided.Expression, "The LaTeX of the result");
json["condition"] = MathFormatting.ToText(provided.Predicate, "The condition of the result");
}
else
{
json["result"] = MathFormatting.ToText(result, "The result");
json["result_latex"] = MathFormatting.ToLatex(result, "The LaTeX of the result");
}
ExplainParameters(issues, expression, result);
}
/// <summary>
/// Says what the names mean which AngouriMath brought into the result, such as C for the constant of integration.
/// </summary>
private static void ExplainParameters(List<string> issues, Entity expression, Entity result)
{
var original = MathVariables.FreeIn(expression).Select(variable => variable.Name).ToHashSet(StringComparer.Ordinal);
foreach (var variable in MathVariables.FreeIn(result).Where(variable => !original.Contains(variable.Name)))
{
if (NumericCheck.IsIntegerParameter(variable))
issues.Add($"{variable.Name} stands for any integer.");
else if (variable.Name.StartsWith("r_", StringComparison.Ordinal))
issues.Add($"{variable.Name} stands for any real number.");
else if (variable.Name == "C" || variable.Name.StartsWith("C_", StringComparison.Ordinal))
issues.Add($"{variable.Name} stands for any constant.");
}
}
private static bool Contains<TNode>(Entity expression) where TNode : Entity => expression.Nodes.Any(node => node is TNode);
private static bool IsNaN(Entity expression) => expression is Entity.Number.Complex number && (number.RealPart.EDecimal.IsNaN() || number.ImaginaryPart.EDecimal.IsNaN());
private static NumericCheckOutcome Combined(NumericCheckOutcome first, NumericCheckOutcome second) => (first, second) switch
{
(NumericCheckOutcome.FAILED, _) or (_, NumericCheckOutcome.FAILED) => NumericCheckOutcome.FAILED,
(NumericCheckOutcome.NOT_POSSIBLE, _) or (_, NumericCheckOutcome.NOT_POSSIBLE) => NumericCheckOutcome.NOT_POSSIBLE,
_ => NumericCheckOutcome.PASSED,
};
private static string Verification(NumericCheckOutcome outcome) => outcome switch
{
NumericCheckOutcome.PASSED => "passed",
NumericCheckOutcome.FAILED => "failed",
_ => "not_possible",
};
}
@@ -0,0 +1,34 @@
using AIStudio.Provider;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
/// <summary>
/// The tools which compute locally: Calculate and Compute Symbolically.
/// </summary>
/// <remarks>
/// Both read the same syntax and share one computing slot, see MathComputationService, and a model
/// which works with formulas needs both: symbolic answers are checked with numbers, and numbers come
/// from formulas. They see nothing but what the model writes into their arguments, and nothing leaves
/// AI Studio, so the collection asks for no provider confidence. More tools of this kind, such as
/// approximations or fits, belong here; a tool which reads local data does not, since it needs a
/// confidence of its own.
/// </remarks>
public sealed class MathematicsToolCollection : IToolCollection
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(MathematicsToolCollection).Namespace, nameof(MathematicsToolCollection));
public ToolCollectionDefinition GetDefinition() => new()
{
Id = ToolSelectionRules.MATHEMATICS_COLLECTION_ID,
ToolIds = [ToolSelectionRules.CALCULATE_TOOL_ID, ToolSelectionRules.COMPUTE_SYMBOLICALLY_TOOL_ID],
MinimumProviderConfidence = ConfidenceLevel.NONE,
DescriptionForLLM = "Calculate exactly or to a stated number of digits, and work with formulas: simplify, factor, differentiate, integrate, take limits, and solve equations. Everything is computed locally, and each result is checked numerically where possible.",
};
public string Icon => Icons.Material.Filled.Calculate;
public string GetDisplayName() => TB("Mathematics");
public string GetDescription() => TB("Lets the AI calculate exactly and work with formulas, such as solving equations or checking an integral, right on your computer.");
}
@@ -0,0 +1,15 @@
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
/// <summary>
/// What compute_symbolically does with an expression.
/// </summary>
public enum SymbolicOperation
{
SIMPLIFY = 0,
EXPAND,
FACTOR,
DIFFERENTIATE,
INTEGRATE,
LIMIT,
SOLVE,
}
@@ -0,0 +1,16 @@
using AngouriMath.Core;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mathematics;
/// <summary>
/// The arguments of a call of compute_symbolically, checked against the operation, but not yet read as mathematics.
/// </summary>
/// <param name="Operation">What to do.</param>
/// <param name="Expression">The expression, as the model wrote it.</param>
/// <param name="Variable">The variable to differentiate, integrate, approach, or solve for, or null to take the only one.</param>
/// <param name="Order">How often to differentiate.</param>
/// <param name="LowerBound">The lower bound of a definite integral, or null for an indefinite one.</param>
/// <param name="UpperBound">The upper bound of a definite integral, or null for an indefinite one.</param>
/// <param name="Approach">The point a limit approaches, or null for any other operation.</param>
/// <param name="Direction">From where the limit approaches.</param>
public sealed record SymbolicRequest(SymbolicOperation Operation, string Expression, string? Variable, int Order, string? LowerBound, string? UpperBound, string? Approach, ApproachFrom Direction);