let domains author their own pass-2 estimator as formulas, not Python
Pass 2 previously only knew one estimator: a hardcoded physics model that matches dimensions literally named platform/actuator/energy_storage. Any domain outside that shape (e.g. archery) got all-zero estimates and failed every combo. Domains can now declare free variables and per-metric formulas as data instead; a safe AST-based evaluator (engine/formula.py, no eval()) resolves declared entity properties via dep(key, constraint_type) and generalizes the existing hand-nested mass-budget search into an N-variable recursive optimizer. Fully additive -- the legacy platform/actuator/ energy_storage path is untouched and still runs unchanged for every domain that declares no formulas. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
108
tests/test_formula.py
Normal file
108
tests/test_formula.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tests for the safe formula evaluator."""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from physcom.engine.formula import FormulaError, compile_formula, evaluate_formula
|
||||
|
||||
|
||||
class TestArithmetic:
|
||||
def test_constant(self):
|
||||
assert evaluate_formula(compile_formula("42"), {}) == 42.0
|
||||
|
||||
def test_basic_ops(self):
|
||||
assert evaluate_formula(compile_formula("2 + 3 * 4"), {}) == 14.0
|
||||
assert evaluate_formula(compile_formula("(2 + 3) * 4"), {}) == 20.0
|
||||
assert evaluate_formula(compile_formula("10 / 4"), {}) == 2.5
|
||||
assert evaluate_formula(compile_formula("2 ** 3"), {}) == 8.0
|
||||
|
||||
def test_unary_minus(self):
|
||||
assert evaluate_formula(compile_formula("-5 + 2"), {}) == -3.0
|
||||
|
||||
def test_variable_lookup(self):
|
||||
result = evaluate_formula(compile_formula("mass * 2"), {"mass": 3.0})
|
||||
assert result == 6.0
|
||||
|
||||
def test_unknown_variable_raises(self):
|
||||
with pytest.raises(FormulaError):
|
||||
evaluate_formula(compile_formula("unknown_var"), {})
|
||||
|
||||
def test_division_by_zero_raises_formula_error(self):
|
||||
with pytest.raises(FormulaError):
|
||||
evaluate_formula(compile_formula("1 / 0"), {})
|
||||
|
||||
|
||||
class TestFunctions:
|
||||
def test_default_math_functions(self):
|
||||
assert evaluate_formula(compile_formula("sqrt(16)"), {}) == 4.0
|
||||
assert evaluate_formula(compile_formula("max(1, 2, 3)"), {}) == 3.0
|
||||
assert evaluate_formula(compile_formula("min(1, 2, 3)"), {}) == 1.0
|
||||
assert evaluate_formula(compile_formula("abs(-5)"), {}) == 5.0
|
||||
assert evaluate_formula(compile_formula("exp(0)"), {}) == 1.0
|
||||
assert math.isclose(evaluate_formula(compile_formula("log(exp(1))"), {}), 1.0)
|
||||
|
||||
def test_custom_injected_function(self):
|
||||
formula = compile_formula('dep("power_density", "provides")')
|
||||
result = evaluate_formula(
|
||||
formula, {}, functions={"dep": lambda key, constraint_type: 99.0}
|
||||
)
|
||||
assert result == 99.0
|
||||
|
||||
def test_unknown_function_raises(self):
|
||||
with pytest.raises(FormulaError):
|
||||
evaluate_formula(compile_formula("unknown_fn(1)"), {})
|
||||
|
||||
def test_string_constant_passthrough_to_function(self):
|
||||
formula = compile_formula('dep("mass")')
|
||||
result = evaluate_formula(formula, {}, functions={"dep": lambda key: len(key)})
|
||||
assert result == 4.0
|
||||
|
||||
|
||||
class TestSecurity:
|
||||
@pytest.mark.parametrize("source", [
|
||||
"__import__('os').system('echo hi')",
|
||||
"().__class__",
|
||||
"[1, 2, 3]",
|
||||
"{1: 2}",
|
||||
"{1, 2}",
|
||||
"(x for x in [1])",
|
||||
"lambda: 1",
|
||||
"1 if True else 0",
|
||||
"1 == 1",
|
||||
"x.__class__",
|
||||
"x[0]",
|
||||
"(lambda: 1)()",
|
||||
"1; 2",
|
||||
])
|
||||
def test_disallowed_constructs_rejected(self, source):
|
||||
with pytest.raises(FormulaError):
|
||||
compile_formula(source)
|
||||
|
||||
def test_unregistered_function_name_never_executes(self):
|
||||
"""exec/eval/__import__ etc. parse as ordinary Call nodes -- the
|
||||
actual guarantee is that no function name is callable unless it's
|
||||
explicitly in DEFAULT_FUNCTIONS or caller-supplied, checked at
|
||||
evaluate time, not that the bare name is rejected at compile time."""
|
||||
with pytest.raises(FormulaError):
|
||||
evaluate_formula(compile_formula("exec('1')"), {})
|
||||
|
||||
def test_dunder_name_rejected(self):
|
||||
with pytest.raises(FormulaError):
|
||||
compile_formula("__builtins__")
|
||||
|
||||
def test_indirect_call_rejected(self):
|
||||
with pytest.raises(FormulaError):
|
||||
compile_formula("(a + b)(1)")
|
||||
|
||||
def test_invalid_syntax_raises_formula_error(self):
|
||||
with pytest.raises(FormulaError):
|
||||
compile_formula("2 +")
|
||||
|
||||
def test_boolean_constant_rejected(self):
|
||||
with pytest.raises(FormulaError):
|
||||
compile_formula("True")
|
||||
|
||||
def test_large_exponent_overflows_cleanly_not_hangs(self):
|
||||
with pytest.raises(FormulaError):
|
||||
evaluate_formula(compile_formula("9 ** 9 ** 9 ** 9"), {})
|
||||
Reference in New Issue
Block a user