Files
AI-Studio/app/MindWork AI Studio/Tools/Mathematics/MathExpressionReader.cs
T

165 lines
8.1 KiB
C#

using System.Globalization;
using System.Text.RegularExpressions;
using AIStudio.Chat;
using AngouriMath;
using AngouriMath.Core.Exceptions;
using PeterO.Numbers;
namespace AIStudio.Tools.Mathematics;
/// <summary>
/// Reads an expression of the model into AngouriMath, strictly.
/// </summary>
/// <remarks>
/// AngouriMath reads some spellings as something else than they say, silently, see
/// AngouriMathBehaviorTests: without explicit parsing, x2 is x^2 and foo(3) is foo times 3, and
/// even with it, a != b is the factorial of a equated to b. This reader refuses such spellings
/// instead of letting a model compute what it did not mean, and states how AngouriMath writes the
/// thing instead. Its parse errors list over a thousand characters of expected tokens; the reader
/// keeps the column and the cause.<br/><br/>
/// Call it inside a computation of MathComputationService: the parser needs the stack of its thread.
/// </remarks>
public static partial class MathExpressionReader
{
private const int MAX_REASON_LENGTH = 200;
/// <summary>
/// Reads an expression, or refuses it.
/// </summary>
/// <param name="text">The expression as the model wrote it.</param>
/// <param name="argumentName">The argument the expression came in, named in every message.</param>
/// <returns>The expression as AngouriMath reads it.</returns>
/// <exception cref="MathComputationException">The expression is empty, too long, uses a spelling AngouriMath reads otherwise, or cannot be read.</exception>
public static Entity Read(string text, string argumentName)
{
if (string.IsNullOrWhiteSpace(text))
throw new MathComputationException($"Argument '{argumentName}' is empty.");
if (text.Length > MathLimits.MAX_EXPRESSION_LENGTH)
throw new MathComputationException(string.Create(CultureInfo.InvariantCulture, $"Argument '{argumentName}' has {text.Length:N0} characters, but at most {MathLimits.MAX_EXPRESSION_LENGTH:N0} are read."));
if (text.Contains("!=", StringComparison.Ordinal))
throw new MathComputationException($"Argument '{argumentName}' contains '!=', which the tool reads as a factorial followed by '='. Not-equal is written '<>'.");
if (text.Contains('|'))
throw new MathComputationException($"Argument '{argumentName}' contains '|', which the tool reads as divisibility. The absolute value is written abs(x), and 'or' joins two statements.");
var equalsSigns = CountEqualsSigns(text);
if (equalsSigns > 1)
throw new MathComputationException($"Argument '{argumentName}' contains {equalsSigns} equals signs, but an expression holds one equation at most. The tool would read a chain of them as several equations joined by 'and'.");
var expression = Parse(text, argumentName);
if (expression.Vars.Any(variable => variable.Name == "oo"))
throw new MathComputationException($"Argument '{argumentName}' uses 'oo', which the tool reads as a variable. Infinity is written +oo or -oo.");
return expression;
}
private static Entity Parse(string text, string argumentName)
{
try
{
using var explicitParsing = MathS.Settings.ExplicitParsingOnly.Set(true);
Entity parsed;
// Without the cache, an expression parsed elsewhere with other settings cannot come back here:
using (MathS.Settings.DowncastingEnabled.Set(false))
parsed = MathS.FromString(text, useCache: false);
return WithExactLiterals(parsed);
}
catch (MissingOperatorParseException exception)
{
throw new MathComputationException(DescribeMissingOperator(exception.Message, argumentName));
}
catch (FunctionArgumentCountException exception)
{
throw new MathComputationException($"Argument '{argumentName}' cannot be read: {exception.Message}.");
}
catch (ParseException exception)
{
throw new MathComputationException(DescribeParseError(exception.Message, argumentName));
}
}
/// <summary>
/// Turns every decimal literal into the fraction it writes, e.g., 1.5e-3 into 3/2000.
/// </summary>
/// <remarks>
/// AngouriMath downcasts while it parses: it reads 0.1 as 1/10, but 6.62607015e-34 as an inexact
/// decimal and 1e-60 as 0, see AngouriMathBehaviorTests. Simplifying then drops an inexact decimal
/// from a sum, so 1 + 1e-40 would be exactly 1. The reader therefore parses without downcasting,
/// which leaves every number a decimal, and turns each into its fraction or integer. A literal
/// with an exponent beyond MathLimits.MAX_EXACT_DIGITS stays a decimal.
/// </remarks>
private static Entity WithExactLiterals(Entity expression) => expression.Replace(node => node is Entity.Number.Real literal and not Entity.Number.Rational ? ExactValueOf(literal) : node);
private static Entity ExactValueOf(Entity.Number.Real literal)
{
var value = literal.EDecimal;
if (value.IsNaN() || value.IsInfinity() || value.Exponent.Abs().CompareTo(EInteger.FromInt32(MathLimits.MAX_EXACT_DIGITS)) > 0)
return literal;
var fraction = ERational.FromEDecimal(value);
return fraction.Denominator.Equals(EInteger.One) ? Entity.Number.Integer.Create(fraction.Numerator) : Entity.Number.Rational.Create(fraction.Numerator, fraction.Denominator);
}
private static string DescribeMissingOperator(string message, string argumentName)
{
var match = MissingOperatorTokens().Match(message);
if (!match.Success)
return $"Argument '{argumentName}' lacks an operator between two operands. Multiplication is written '*', as in 2*x.";
var left = match.Groups["left"].Value;
var right = match.Groups["right"].Value;
var column = int.Parse(match.Groups["column"].Value, CultureInfo.InvariantCulture) + 1;
if (right == "(" && IsName(left))
return $"Argument '{argumentName}' has no operator between '{left}' and '(' at column {column}. The tool knows no function named '{left}', so it would read the name as a variable times the parentheses. Multiplication is written '*'.";
return $"Argument '{argumentName}' has no operator between '{left}' and '{right}' at column {column}. Multiplication is written '*', as in 2*x, and a function takes its argument in parentheses, as in sin(x).";
}
private static string DescribeParseError(string message, string argumentName)
{
var match = ParseErrorPosition().Match(message);
if (!match.Success)
return $"Argument '{argumentName}' cannot be read: {message.Shorten(MAX_REASON_LENGTH)}";
var column = int.Parse(match.Groups["column"].Value, CultureInfo.InvariantCulture) + 1;
var reason = match.Groups["reason"].Value.Shorten(MAX_REASON_LENGTH);
return $"Argument '{argumentName}' cannot be read at column {column}: {reason}.";
}
private static bool IsName(string token) => token.Length > 0 && token.All(character => char.IsLetterOrDigit(character) || character == '_');
/// <summary>
/// Counts the equals signs which stand for an equation, leaving out those of &lt;= and &gt;=.
/// </summary>
private static int CountEqualsSigns(string text)
{
var count = 0;
for (var index = 0; index < text.Length; index++)
{
if (text[index] != '=')
continue;
if (index > 0 && text[index - 1] is '<' or '>')
continue;
count++;
}
return count;
}
// There should be an operator between [@0,0:0='2',<185>,1:0] and [@1,1:1='x',<189>,1:1], while parsing `2x`
[GeneratedRegex(@"='(?<left>.*?)',<\d+>,\d+:\d+\] and \[@\d+,\d+:\d+='(?<right>.*?)',<\d+>,\d+:(?<column>\d+)\]")]
private static partial Regex MissingOperatorTokens();
// line 1:4 extraneous input '*' expecting {'-', '+', ...}, while parsing `2 + * 3`
[GeneratedRegex(@"^line \d+:(?<column>\d+) (?<reason>.*?)(?: expecting .*)?(?:, while parsing `.*`)?$", RegexOptions.Singleline)]
private static partial Regex ParseErrorPosition();
}