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());
+ }
+}