From 10898b08a065ffc564c00473069e02b0e5e06c2a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 23:25:26 +0300 Subject: [PATCH] Add formula engine: data-driven expression evaluator (Core) Keystone for the gene system. A Formula compiles a string from a def (lexer -> recursive-descent parser -> tree of Func) once, then evaluates allocation-free against a variable context. Supports + - * / %, comparisons, && || !, ternary, the constants pi/tau/e, and functions abs sign floor ceil round sqrt exp log sin cos tan min max pow clamp lerp step. Deterministic and side-effect-free so gene-effect formulas stay pure. Covered by FormulaTests (parsing, precedence, functions, logic/ternary, variables, errors). Co-Authored-By: Claude Opus 4.8 --- src/MrGameEng.Core/Formulas/Formula.cs | 60 ++++ src/MrGameEng.Core/Formulas/FormulaContext.cs | 28 ++ .../Formulas/FormulaException.cs | 12 + .../Formulas/FormulaFunctions.cs | 90 ++++++ src/MrGameEng.Core/Formulas/FormulaLexer.cs | 217 ++++++++++++++ src/MrGameEng.Core/Formulas/FormulaParser.cs | 273 ++++++++++++++++++ tests/MrGameEng.Core.Tests/FormulaTests.cs | 112 +++++++ 7 files changed, 792 insertions(+) create mode 100644 src/MrGameEng.Core/Formulas/Formula.cs create mode 100644 src/MrGameEng.Core/Formulas/FormulaContext.cs create mode 100644 src/MrGameEng.Core/Formulas/FormulaException.cs create mode 100644 src/MrGameEng.Core/Formulas/FormulaFunctions.cs create mode 100644 src/MrGameEng.Core/Formulas/FormulaLexer.cs create mode 100644 src/MrGameEng.Core/Formulas/FormulaParser.cs create mode 100644 tests/MrGameEng.Core.Tests/FormulaTests.cs diff --git a/src/MrGameEng.Core/Formulas/Formula.cs b/src/MrGameEng.Core/Formulas/Formula.cs new file mode 100644 index 0000000..1436fa2 --- /dev/null +++ b/src/MrGameEng.Core/Formulas/Formula.cs @@ -0,0 +1,60 @@ +namespace MrGameEng.Formulas; + +/// +/// A compiled arithmetic expression — the keystone of the data-driven gene system. A formula is +/// parsed once from a string in a def (e.g. a gene effect on a trait) into a delegate tree, then +/// evaluated many times against an that supplies variable values +/// (gene values, environment readings, other traits). Evaluation is deterministic and +/// allocation-free; all the work happens in . +/// +/// Grammar: numbers, variables, the constants pi/tau/e, operators +/// + - * / %, comparisons < > <= >= == !=, logical && || !, +/// the ternary cond ? a : b, and functions abs sign floor ceil round sqrt exp log sin cos +/// tan min max pow clamp lerp step. Comparisons and logical operators yield 1/0. +/// +public sealed class Formula +{ + private static readonly IFormulaContext Empty = new EmptyContext(); + + private readonly Func _root; + + private Formula(string source, Func root) + { + Source = source; + _root = root; + } + + /// The original expression text this formula was compiled from. + public string Source { get; } + + /// + /// Parses and compiles . Throws on any + /// lexical or syntactic error, with the offending position. + /// + public static Formula Compile(string expression) + { + ArgumentNullException.ThrowIfNull(expression); + var tokens = FormulaLexer.Tokenize(expression); + var root = new FormulaParser(tokens).ParseProgram(); + return new Formula(expression, root); + } + + /// Evaluates the formula, resolving variables through . + public float Evaluate(IFormulaContext context) + { + ArgumentNullException.ThrowIfNull(context); + return _root(context); + } + + /// + /// Evaluates a formula that references no variables. Throws if it + /// turns out to reference one. + /// + public float Evaluate() => _root(Empty); + + private sealed class EmptyContext : IFormulaContext + { + public float Resolve(string name) => + throw new FormulaException($"No context to resolve variable '{name}'."); + } +} diff --git a/src/MrGameEng.Core/Formulas/FormulaContext.cs b/src/MrGameEng.Core/Formulas/FormulaContext.cs new file mode 100644 index 0000000..25b7d6f --- /dev/null +++ b/src/MrGameEng.Core/Formulas/FormulaContext.cs @@ -0,0 +1,28 @@ +namespace MrGameEng.Formulas; + +/// +/// Supplies variable values to a compiled . A context maps a variable name +/// (a gene value, an environment reading, another trait…) to a number; the formula engine itself +/// is data-agnostic, so any consumer can back this with whatever lookup it owns. +/// +public interface IFormulaContext +{ + /// + /// Returns the value bound to . Throw (e.g. ) + /// if the name is unknown — the engine does not invent a default. + /// + float Resolve(string name); +} + +/// An backed by a lookup delegate — handy for tests and ad-hoc use. +public sealed class DelegateFormulaContext : IFormulaContext +{ + private readonly Func _resolve; + + /// Wraps ; it is called once per variable reference per evaluation. + public DelegateFormulaContext(Func resolve) => + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + + /// + public float Resolve(string name) => _resolve(name); +} diff --git a/src/MrGameEng.Core/Formulas/FormulaException.cs b/src/MrGameEng.Core/Formulas/FormulaException.cs new file mode 100644 index 0000000..4512596 --- /dev/null +++ b/src/MrGameEng.Core/Formulas/FormulaException.cs @@ -0,0 +1,12 @@ +namespace MrGameEng.Formulas; + +/// +/// Thrown when a cannot be lexed or parsed, or when a compiled formula +/// references a variable the context cannot resolve at evaluation time. +/// +public sealed class FormulaException : Exception +{ + /// Creates the exception with a human-readable . + public FormulaException(string message) + : base(message) { } +} diff --git a/src/MrGameEng.Core/Formulas/FormulaFunctions.cs b/src/MrGameEng.Core/Formulas/FormulaFunctions.cs new file mode 100644 index 0000000..455e240 --- /dev/null +++ b/src/MrGameEng.Core/Formulas/FormulaFunctions.cs @@ -0,0 +1,90 @@ +using Node = System.Func; + +namespace MrGameEng.Formulas; + +/// +/// The built-in function set available inside formulas. Each entry validates its argument count at +/// compile time and returns a that evaluates its operands then the math. Kept +/// deterministic and side-effect-free so formulas stay pure. +/// +internal static class FormulaFunctions +{ + public static Node Build(string name, List args) + { + switch (name) + { + case "abs": + return Unary(name, args, MathF.Abs); + case "sign": + return Unary(name, args, x => MathF.Sign(x)); + case "floor": + return Unary(name, args, MathF.Floor); + case "ceil": + return Unary(name, args, MathF.Ceiling); + case "round": + return Unary(name, args, MathF.Round); + case "sqrt": + return Unary(name, args, MathF.Sqrt); + case "exp": + return Unary(name, args, MathF.Exp); + case "log": + return args.Count == 2 + ? Binary(name, args, MathF.Log) + : Unary(name, args, MathF.Log); + case "sin": + return Unary(name, args, MathF.Sin); + case "cos": + return Unary(name, args, MathF.Cos); + case "tan": + return Unary(name, args, MathF.Tan); + case "min": + return Binary(name, args, MathF.Min); + case "max": + return Binary(name, args, MathF.Max); + case "pow": + return Binary(name, args, MathF.Pow); + case "clamp": + return Ternary(name, args, (x, lo, hi) => Math.Clamp(x, lo, hi)); + case "lerp": + return Ternary(name, args, (a, b, t) => a + (b - a) * t); + case "step": + return Binary(name, args, (edge, x) => x < edge ? 0f : 1f); + default: + throw new FormulaException($"Unknown function '{name}'."); + } + } + + private static Node Unary(string name, List args, Func op) + { + Require(name, args, 1); + var a = args[0]; + return ctx => op(a(ctx)); + } + + private static Node Binary(string name, List args, Func op) + { + Require(name, args, 2); + var a = args[0]; + var b = args[1]; + return ctx => op(a(ctx), b(ctx)); + } + + private static Node Ternary(string name, List args, Func op) + { + Require(name, args, 3); + var a = args[0]; + var b = args[1]; + var c = args[2]; + return ctx => op(a(ctx), b(ctx), c(ctx)); + } + + private static void Require(string name, List args, int count) + { + if (args.Count != count) + { + throw new FormulaException( + $"Function '{name}' expects {count} argument(s) but got {args.Count}." + ); + } + } +} diff --git a/src/MrGameEng.Core/Formulas/FormulaLexer.cs b/src/MrGameEng.Core/Formulas/FormulaLexer.cs new file mode 100644 index 0000000..423fbce --- /dev/null +++ b/src/MrGameEng.Core/Formulas/FormulaLexer.cs @@ -0,0 +1,217 @@ +using System.Globalization; + +namespace MrGameEng.Formulas; + +internal enum TokenType +{ + Number, + Identifier, + Plus, + Minus, + Star, + Slash, + Percent, + LParen, + RParen, + Comma, + Less, + Greater, + LessEqual, + GreaterEqual, + EqualEqual, + NotEqual, + And, + Or, + Not, + Question, + Colon, + End, +} + +internal readonly struct Token(TokenType type, int position, float number = 0f, string text = "") +{ + public TokenType Type { get; } = type; + public int Position { get; } = position; + public float Number { get; } = number; + public string Text { get; } = text; +} + +/// +/// Turns a formula string into a flat token list. Pure and allocation-light; recognizes numbers, +/// identifiers, the arithmetic/comparison/logical operators and the punctuation the parser needs. +/// +internal static class FormulaLexer +{ + public static List Tokenize(string source) + { + var tokens = new List(); + var i = 0; + while (i < source.Length) + { + var c = source[i]; + if (char.IsWhiteSpace(c)) + { + i++; + continue; + } + + if ( + char.IsDigit(c) + || (c == '.' && i + 1 < source.Length && char.IsDigit(source[i + 1])) + ) + { + var start = i; + while (i < source.Length && (char.IsDigit(source[i]) || source[i] == '.')) + { + i++; + } + + var span = source.AsSpan(start, i - start); + if ( + !float.TryParse( + span, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var value + ) + ) + { + throw new FormulaException( + $"Invalid number '{span.ToString()}' at position {start}." + ); + } + + tokens.Add(new Token(TokenType.Number, start, value)); + continue; + } + + if (char.IsLetter(c) || c == '_') + { + var start = i; + while (i < source.Length && (char.IsLetterOrDigit(source[i]) || source[i] == '_')) + { + i++; + } + + tokens.Add( + new Token(TokenType.Identifier, start, text: source.Substring(start, i - start)) + ); + continue; + } + + var pos = i; + switch (c) + { + case '+': + tokens.Add(new Token(TokenType.Plus, pos)); + i++; + break; + case '-': + tokens.Add(new Token(TokenType.Minus, pos)); + i++; + break; + case '*': + tokens.Add(new Token(TokenType.Star, pos)); + i++; + break; + case '/': + tokens.Add(new Token(TokenType.Slash, pos)); + i++; + break; + case '%': + tokens.Add(new Token(TokenType.Percent, pos)); + i++; + break; + case '(': + tokens.Add(new Token(TokenType.LParen, pos)); + i++; + break; + case ')': + tokens.Add(new Token(TokenType.RParen, pos)); + i++; + break; + case ',': + tokens.Add(new Token(TokenType.Comma, pos)); + i++; + break; + case '?': + tokens.Add(new Token(TokenType.Question, pos)); + i++; + break; + case ':': + tokens.Add(new Token(TokenType.Colon, pos)); + i++; + break; + case '<': + i = AddMaybeEqual(tokens, source, i, TokenType.LessEqual, TokenType.Less); + break; + case '>': + i = AddMaybeEqual(tokens, source, i, TokenType.GreaterEqual, TokenType.Greater); + break; + case '=': + if (Next(source, i) == '=') + { + tokens.Add(new Token(TokenType.EqualEqual, pos)); + i += 2; + break; + } + + throw new FormulaException($"Expected '==' at position {pos}."); + case '!': + if (Next(source, i) == '=') + { + tokens.Add(new Token(TokenType.NotEqual, pos)); + i += 2; + break; + } + + tokens.Add(new Token(TokenType.Not, pos)); + i++; + break; + case '&': + if (Next(source, i) == '&') + { + tokens.Add(new Token(TokenType.And, pos)); + i += 2; + break; + } + + throw new FormulaException($"Expected '&&' at position {pos}."); + case '|': + if (Next(source, i) == '|') + { + tokens.Add(new Token(TokenType.Or, pos)); + i += 2; + break; + } + + throw new FormulaException($"Expected '||' at position {pos}."); + default: + throw new FormulaException($"Unexpected character '{c}' at position {pos}."); + } + } + + tokens.Add(new Token(TokenType.End, source.Length)); + return tokens; + } + + private static int AddMaybeEqual( + List tokens, + string source, + int i, + TokenType withEqual, + TokenType plain + ) + { + if (Next(source, i) == '=') + { + tokens.Add(new Token(withEqual, i)); + return i + 2; + } + + tokens.Add(new Token(plain, i)); + return i + 1; + } + + private static char Next(string source, int i) => i + 1 < source.Length ? source[i + 1] : '\0'; +} diff --git a/src/MrGameEng.Core/Formulas/FormulaParser.cs b/src/MrGameEng.Core/Formulas/FormulaParser.cs new file mode 100644 index 0000000..b897191 --- /dev/null +++ b/src/MrGameEng.Core/Formulas/FormulaParser.cs @@ -0,0 +1,273 @@ +using Node = System.Func; + +namespace MrGameEng.Formulas; + +/// +/// Recursive-descent parser that compiles a token list straight into a tree of +/// delegates. Parsing (and therefore all closure allocation) happens once; evaluating the returned +/// node is allocation-free. Precedence, low to high: ternary, ||, &&, equality, +/// comparison, additive, multiplicative, unary, primary. +/// +internal sealed class FormulaParser(List tokens) +{ + private const float True = 1f; + private const float False = 0f; + + private int _pos; + + public Node ParseProgram() + { + var node = ParseTernary(); + Expect(TokenType.End); + return node; + } + + private Node ParseTernary() + { + var condition = ParseOr(); + if (!Match(TokenType.Question)) + { + return condition; + } + + var whenTrue = ParseTernary(); + Expect(TokenType.Colon); + var whenFalse = ParseTernary(); + return ctx => condition(ctx) != False ? whenTrue(ctx) : whenFalse(ctx); + } + + private Node ParseOr() + { + var left = ParseAnd(); + while (Match(TokenType.Or)) + { + var right = ParseAnd(); + var l = left; + left = ctx => l(ctx) != False || right(ctx) != False ? True : False; + } + + return left; + } + + private Node ParseAnd() + { + var left = ParseEquality(); + while (Match(TokenType.And)) + { + var right = ParseEquality(); + var l = left; + left = ctx => l(ctx) != False && right(ctx) != False ? True : False; + } + + return left; + } + + private Node ParseEquality() + { + var left = ParseComparison(); + while (true) + { + if (Match(TokenType.EqualEqual)) + { + var right = ParseComparison(); + var l = left; + left = ctx => l(ctx) == right(ctx) ? True : False; + } + else if (Match(TokenType.NotEqual)) + { + var right = ParseComparison(); + var l = left; + left = ctx => l(ctx) != right(ctx) ? True : False; + } + else + { + return left; + } + } + } + + private Node ParseComparison() + { + var left = ParseAdditive(); + while (true) + { + if (Match(TokenType.Less)) + { + left = Compare(left, ParseAdditive(), (a, b) => a < b); + } + else if (Match(TokenType.LessEqual)) + { + left = Compare(left, ParseAdditive(), (a, b) => a <= b); + } + else if (Match(TokenType.Greater)) + { + left = Compare(left, ParseAdditive(), (a, b) => a > b); + } + else if (Match(TokenType.GreaterEqual)) + { + left = Compare(left, ParseAdditive(), (a, b) => a >= b); + } + else + { + return left; + } + } + } + + private Node ParseAdditive() + { + var left = ParseMultiplicative(); + while (true) + { + if (Match(TokenType.Plus)) + { + var right = ParseMultiplicative(); + var l = left; + left = ctx => l(ctx) + right(ctx); + } + else if (Match(TokenType.Minus)) + { + var right = ParseMultiplicative(); + var l = left; + left = ctx => l(ctx) - right(ctx); + } + else + { + return left; + } + } + } + + private Node ParseMultiplicative() + { + var left = ParseUnary(); + while (true) + { + if (Match(TokenType.Star)) + { + var right = ParseUnary(); + var l = left; + left = ctx => l(ctx) * right(ctx); + } + else if (Match(TokenType.Slash)) + { + var right = ParseUnary(); + var l = left; + left = ctx => l(ctx) / right(ctx); + } + else if (Match(TokenType.Percent)) + { + var right = ParseUnary(); + var l = left; + left = ctx => l(ctx) % right(ctx); + } + else + { + return left; + } + } + } + + private Node ParseUnary() + { + if (Match(TokenType.Minus)) + { + var operand = ParseUnary(); + return ctx => -operand(ctx); + } + + if (Match(TokenType.Plus)) + { + return ParseUnary(); + } + + if (Match(TokenType.Not)) + { + var operand = ParseUnary(); + return ctx => operand(ctx) != False ? False : True; + } + + return ParsePrimary(); + } + + private Node ParsePrimary() + { + var token = Current; + if (Match(TokenType.Number)) + { + var value = token.Number; + return _ => value; + } + + if (Match(TokenType.LParen)) + { + var inner = ParseTernary(); + Expect(TokenType.RParen); + return inner; + } + + if (Match(TokenType.Identifier)) + { + return Peek(TokenType.LParen) ? ParseCall(token.Text) : ParseName(token.Text); + } + + throw new FormulaException($"Unexpected token at position {token.Position}."); + } + + private Node ParseName(string name) + { + switch (name) + { + case "pi": + return _ => MathF.PI; + case "tau": + return _ => MathF.Tau; + case "e": + return _ => MathF.E; + default: + return ctx => ctx.Resolve(name); + } + } + + private Node ParseCall(string name) + { + Expect(TokenType.LParen); + var args = new List(); + if (!Peek(TokenType.RParen)) + { + do + { + args.Add(ParseTernary()); + } while (Match(TokenType.Comma)); + } + + Expect(TokenType.RParen); + return FormulaFunctions.Build(name, args); + } + + private static Node Compare(Node left, Node right, Func op) => + ctx => op(left(ctx), right(ctx)) ? True : False; + + private Token Current => tokens[_pos]; + + private bool Peek(TokenType type) => Current.Type == type; + + private bool Match(TokenType type) + { + if (Current.Type != type) + { + return false; + } + + _pos++; + return true; + } + + private void Expect(TokenType type) + { + if (!Match(type)) + { + throw new FormulaException($"Expected {type} at position {Current.Position}."); + } + } +} diff --git a/tests/MrGameEng.Core.Tests/FormulaTests.cs b/tests/MrGameEng.Core.Tests/FormulaTests.cs new file mode 100644 index 0000000..e126106 --- /dev/null +++ b/tests/MrGameEng.Core.Tests/FormulaTests.cs @@ -0,0 +1,112 @@ +using MrGameEng.Formulas; +using Xunit; + +namespace MrGameEng.Core.Tests; + +public class FormulaTests +{ + private static IFormulaContext Vars(Dictionary values) => + new DelegateFormulaContext(name => + values.TryGetValue(name, out var v) + ? v + : throw new FormulaException($"unknown '{name}'") + ); + + [Theory] + [InlineData("1 + 2 * 3", 7f)] // precedence + [InlineData("(1 + 2) * 3", 9f)] // parentheses + [InlineData("10 - 2 - 3", 5f)] // left associativity + [InlineData("-2 + 5", 3f)] // unary minus + [InlineData("2 * -3", -6f)] // unary minus after operator + [InlineData("7 % 3", 1f)] // modulo + [InlineData("2.5 * 4", 10f)] // decimals + public void Evaluate_Arithmetic_RespectsPrecedence(string expr, float expected) + { + Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4); + } + + [Theory] + [InlineData("min(3, 5)", 3f)] + [InlineData("max(3, 5)", 5f)] + [InlineData("clamp(12, 0, 10)", 10f)] + [InlineData("clamp(-4, 0, 10)", 0f)] + [InlineData("lerp(0, 10, 0.25)", 2.5f)] + [InlineData("pow(2, 10)", 1024f)] + [InlineData("abs(-7)", 7f)] + [InlineData("floor(3.9)", 3f)] + [InlineData("ceil(3.1)", 4f)] + [InlineData("step(5, 7)", 1f)] + [InlineData("step(5, 2)", 0f)] + public void Evaluate_Functions_ComputeExpected(string expr, float expected) + { + Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4); + } + + [Fact] + public void Evaluate_Constants_AreBuiltIn() + { + Assert.Equal(MathF.PI, Formula.Compile("pi").Evaluate(), 5); + Assert.Equal(MathF.Tau, Formula.Compile("tau").Evaluate(), 5); + Assert.Equal(MathF.E, Formula.Compile("e").Evaluate(), 5); + } + + [Theory] + [InlineData("2 < 3", 1f)] + [InlineData("3 <= 3", 1f)] + [InlineData("3 > 5", 0f)] + [InlineData("4 == 4", 1f)] + [InlineData("4 != 4", 0f)] + [InlineData("1 && 0", 0f)] + [InlineData("0 || 2", 1f)] + [InlineData("!0", 1f)] + [InlineData("!5", 0f)] + public void Evaluate_LogicAndComparison_YieldBooleansAsOneOrZero(string expr, float expected) + { + Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4); + } + + [Theory] + [InlineData("3 > 2 ? 10 : 20", 10f)] + [InlineData("3 < 2 ? 10 : 20", 20f)] + [InlineData("1 ? 2 ? 3 : 4 : 5", 3f)] // nested ternary + public void Evaluate_Ternary_SelectsBranch(string expr, float expected) + { + Assert.Equal(expected, Formula.Compile(expr).Evaluate(), 4); + } + + [Fact] + public void Evaluate_Variables_ResolvedThroughContext() + { + var ctx = Vars(new() { ["light"] = 0.8f, ["optimal"] = 0.5f }); + var formula = Formula.Compile("clamp(1 - abs(light - optimal), 0, 1)"); + Assert.Equal(0.7f, formula.Evaluate(ctx), 4); + } + + [Fact] + public void Evaluate_SameFormulaTwice_IsDeterministic() + { + var formula = Formula.Compile("sin(t) * 2 + 1"); + var ctx = Vars(new() { ["t"] = 1.234f }); + Assert.Equal(formula.Evaluate(ctx), formula.Evaluate(ctx), 6); + } + + [Theory] + [InlineData("1 +")] // dangling operator + [InlineData("(1 + 2")] // unbalanced paren + [InlineData("1 2")] // trailing token + [InlineData("min(1)")] // wrong arity + [InlineData("nope(1)")] // unknown function + [InlineData("@")] // bad character + [InlineData("1 = 2")] // single equals + public void Compile_InvalidExpression_Throws(string expr) + { + Assert.Throws(() => Formula.Compile(expr)); + } + + [Fact] + public void Evaluate_UnknownVariableWithoutContext_Throws() + { + var formula = Formula.Compile("x + 1"); + Assert.Throws(() => formula.Evaluate()); + } +}