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"), {})
|
||||
113
tests/test_pipeline_formulas.py
Normal file
113
tests/test_pipeline_formulas.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""End-to-end test that pass 2 can estimate a non-transport domain entirely
|
||||
from domain-authored formulas, without touching the platform/actuator/
|
||||
energy_storage physics model in Pipeline._estimate_physics."""
|
||||
|
||||
import pytest
|
||||
|
||||
from physcom.engine.constraint_resolver import ConstraintResolver
|
||||
from physcom.engine.scorer import Scorer
|
||||
from physcom.engine.pipeline import Pipeline
|
||||
from physcom.models.domain import Domain, FreeVariable, MetricBound, MetricFormula
|
||||
from physcom.models.entity import Dependency, Entity
|
||||
|
||||
|
||||
def _build_archery_domain(repo):
|
||||
repo.add_entity(Entity(
|
||||
name="Recurve",
|
||||
dimension="bow",
|
||||
dependencies=[
|
||||
Dependency("physical", "draw_weight", "20", None, "range_min"),
|
||||
Dependency("physical", "draw_weight", "50", None, "range_max"),
|
||||
],
|
||||
))
|
||||
repo.add_entity(Entity(
|
||||
name="Carbon",
|
||||
dimension="arrow",
|
||||
dependencies=[
|
||||
Dependency("physical", "arrow_mass", "0.02", None, "provides"),
|
||||
],
|
||||
))
|
||||
return repo.add_domain(Domain(
|
||||
name="archery_test",
|
||||
metric_bounds=[
|
||||
MetricBound("drawback_force", weight=0.6, norm_min=0, norm_max=100),
|
||||
MetricBound("range", weight=0.4, norm_min=0, norm_max=300),
|
||||
],
|
||||
free_variables=[
|
||||
FreeVariable(
|
||||
name="draw_weight_chosen",
|
||||
floor_formula='dep("draw_weight", "range_min")',
|
||||
ceiling_formula='dep("draw_weight", "range_max")',
|
||||
sort_order=0,
|
||||
),
|
||||
],
|
||||
metric_formulas=[
|
||||
MetricFormula(metric_name="drawback_force", formula="draw_weight_chosen * 2"),
|
||||
MetricFormula(
|
||||
metric_name="range",
|
||||
formula='draw_weight_chosen * 5 / dep("arrow_mass")',
|
||||
),
|
||||
],
|
||||
))
|
||||
|
||||
|
||||
def test_formula_domain_scores_without_platform_actuator_shape(repo):
|
||||
domain = _build_archery_domain(repo)
|
||||
resolver = ConstraintResolver()
|
||||
scorer = Scorer(domain)
|
||||
pipeline = Pipeline(repo, resolver, scorer)
|
||||
|
||||
result = pipeline.run(
|
||||
domain, ["bow", "arrow"], score_threshold=0.01, passes=[1, 2, 3, 5],
|
||||
)
|
||||
|
||||
assert result.total_generated == 1
|
||||
assert result.pass1_failed == 0
|
||||
assert result.pass2_estimated == 1
|
||||
assert result.pass3_above_threshold == 1
|
||||
|
||||
combos = repo.list_combinations()
|
||||
assert len(combos) == 1
|
||||
combo = combos[0]
|
||||
scores = {
|
||||
s["metric_name"]: s["raw_value"]
|
||||
for s in repo.get_combination_scores(combo.id, domain.id)
|
||||
}
|
||||
# Both metrics increase monotonically with draw_weight_chosen and nothing
|
||||
# trades off against it, so the optimizer should push to the declared
|
||||
# ceiling (50) -- confirms _search_free_variables is actually searching,
|
||||
# not just evaluating at the floor.
|
||||
assert scores["drawback_force"] == pytest.approx(100.0, rel=0.02)
|
||||
|
||||
|
||||
def test_formula_domain_zero_free_variables_direct_evaluation(repo):
|
||||
"""A domain with metric_formulas but no free_variables should evaluate
|
||||
each formula once directly -- no search loop at all."""
|
||||
repo.add_entity(Entity(
|
||||
name="Recurve",
|
||||
dimension="bow",
|
||||
dependencies=[Dependency("physical", "draw_weight", "30", None, "provides")],
|
||||
))
|
||||
repo.add_entity(Entity(name="Carbon", dimension="arrow"))
|
||||
domain = repo.add_domain(Domain(
|
||||
name="archery_direct_test",
|
||||
metric_bounds=[MetricBound("drawback_force", weight=1.0, norm_min=0, norm_max=100)],
|
||||
metric_formulas=[
|
||||
MetricFormula(metric_name="drawback_force", formula='dep("draw_weight") * 2'),
|
||||
],
|
||||
))
|
||||
resolver = ConstraintResolver()
|
||||
scorer = Scorer(domain)
|
||||
pipeline = Pipeline(repo, resolver, scorer)
|
||||
|
||||
result = pipeline.run(
|
||||
domain, ["bow", "arrow"], score_threshold=0.01, passes=[1, 2, 3, 5],
|
||||
)
|
||||
|
||||
assert result.pass2_estimated == 1
|
||||
combos = repo.list_combinations()
|
||||
scores = {
|
||||
s["metric_name"]: s["raw_value"]
|
||||
for s in repo.get_combination_scores(combos[0].id, domain.id)
|
||||
}
|
||||
assert scores["drawback_force"] == 60.0
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the database repository."""
|
||||
|
||||
from physcom.models.entity import Entity, Dependency
|
||||
from physcom.models.domain import Domain, MetricBound
|
||||
from physcom.models.domain import Domain, FreeVariable, MetricBound, MetricFormula
|
||||
|
||||
|
||||
def test_ensure_dimension(repo):
|
||||
@@ -63,6 +63,62 @@ def test_add_and_get_domain(repo):
|
||||
assert loaded.metric_bounds[0].metric_name == "speed"
|
||||
|
||||
|
||||
def test_add_domain_with_free_variables_and_formulas(repo):
|
||||
domain = Domain(
|
||||
name="archery_test",
|
||||
metric_bounds=[MetricBound("drawback_force", weight=1.0, norm_min=0, norm_max=500)],
|
||||
free_variables=[
|
||||
FreeVariable(
|
||||
name="draw_weight",
|
||||
floor_formula='dep("draw_weight", "range_min")',
|
||||
ceiling_formula='dep("draw_weight", "range_max")',
|
||||
sort_order=0,
|
||||
),
|
||||
],
|
||||
metric_formulas=[
|
||||
MetricFormula(metric_name="drawback_force", formula="draw_weight * 1.5"),
|
||||
],
|
||||
)
|
||||
saved = repo.add_domain(domain)
|
||||
assert saved.id is not None
|
||||
|
||||
loaded = repo.get_domain("archery_test")
|
||||
assert loaded is not None
|
||||
assert len(loaded.free_variables) == 1
|
||||
assert loaded.free_variables[0].name == "draw_weight"
|
||||
assert loaded.free_variables[0].id is not None
|
||||
assert len(loaded.metric_formulas) == 1
|
||||
assert loaded.metric_formulas[0].formula == "draw_weight * 1.5"
|
||||
|
||||
|
||||
def test_free_variable_and_formula_crud(repo):
|
||||
domain = repo.add_domain(Domain(name="crud_test"))
|
||||
|
||||
fv = repo.add_free_variable(
|
||||
domain.id,
|
||||
FreeVariable(name="x", floor_formula="0", ceiling_formula="100", sort_order=0),
|
||||
)
|
||||
mf = repo.add_metric_formula(
|
||||
domain.id, MetricFormula(metric_name="m", formula="x * 2")
|
||||
)
|
||||
|
||||
repo.update_free_variable(
|
||||
fv.id, FreeVariable(name="x", floor_formula="1", ceiling_formula="200", sort_order=0)
|
||||
)
|
||||
repo.update_metric_formula(mf.id, MetricFormula(metric_name="m", formula="x * 3"))
|
||||
|
||||
loaded = repo.get_domain_by_id(domain.id)
|
||||
assert loaded.free_variables[0].floor_formula == "1"
|
||||
assert loaded.free_variables[0].ceiling_formula == "200"
|
||||
assert loaded.metric_formulas[0].formula == "x * 3"
|
||||
|
||||
repo.delete_free_variable(fv.id)
|
||||
repo.delete_metric_formula(mf.id)
|
||||
loaded = repo.get_domain_by_id(domain.id)
|
||||
assert loaded.free_variables == []
|
||||
assert loaded.metric_formulas == []
|
||||
|
||||
|
||||
def test_combination_save_and_dedup(repo):
|
||||
e1 = repo.add_entity(Entity(name="A", dimension="platform"))
|
||||
e2 = repo.add_entity(Entity(name="B", dimension="actuator"))
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from physcom.db.schema import init_db
|
||||
from physcom.db.repository import Repository
|
||||
from physcom.models.entity import Entity, Dependency
|
||||
from physcom.models.domain import Domain, DomainConstraint, MetricBound
|
||||
from physcom.models.domain import Domain, DomainConstraint, FreeVariable, MetricBound, MetricFormula
|
||||
from physcom.models.combination import Combination
|
||||
from physcom.snapshot import export_snapshot, import_snapshot
|
||||
|
||||
@@ -169,6 +169,46 @@ def test_import_with_combinations(seeded_repo, tmp_path):
|
||||
assert len(fresh_combos) == len(data["combinations"])
|
||||
|
||||
|
||||
def test_export_import_roundtrip_free_variables_and_formulas(repo, tmp_path):
|
||||
domain = Domain(
|
||||
name="archery_snapshot_test",
|
||||
metric_bounds=[MetricBound("drawback_force", weight=1.0, norm_min=0, norm_max=100)],
|
||||
free_variables=[
|
||||
FreeVariable(
|
||||
name="draw_weight_chosen",
|
||||
floor_formula='dep("draw_weight", "range_min")',
|
||||
ceiling_formula='dep("draw_weight", "range_max")',
|
||||
sort_order=0,
|
||||
),
|
||||
],
|
||||
metric_formulas=[
|
||||
MetricFormula(metric_name="drawback_force", formula="draw_weight_chosen * 2"),
|
||||
],
|
||||
)
|
||||
repo.add_domain(domain)
|
||||
|
||||
data = export_snapshot(repo)
|
||||
exported = next(d for d in data["domains"] if d["name"] == "archery_snapshot_test")
|
||||
assert exported["free_variables"] == [{
|
||||
"name": "draw_weight_chosen", "sort_order": 0,
|
||||
"floor_formula": 'dep("draw_weight", "range_min")',
|
||||
"ceiling_formula": 'dep("draw_weight", "range_max")',
|
||||
}]
|
||||
assert exported["metric_formulas"] == [
|
||||
{"metric_name": "drawback_force", "formula": "draw_weight_chosen * 2"},
|
||||
]
|
||||
|
||||
conn = init_db(tmp_path / "fresh.db")
|
||||
fresh = Repository(conn)
|
||||
import_snapshot(fresh, data, clear=True)
|
||||
|
||||
loaded = fresh.get_domain("archery_snapshot_test")
|
||||
assert len(loaded.free_variables) == 1
|
||||
assert loaded.free_variables[0].floor_formula == 'dep("draw_weight", "range_min")'
|
||||
assert len(loaded.metric_formulas) == 1
|
||||
assert loaded.metric_formulas[0].formula == "draw_weight_chosen * 2"
|
||||
|
||||
|
||||
def test_import_merge_skips_existing_domain(repo):
|
||||
"""Merge import skips domains that already exist."""
|
||||
domain = Domain(
|
||||
|
||||
Reference in New Issue
Block a user