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:
2026-08-16 21:28:15 -05:00
parent 3795a7e826
commit 3429bce8d0
15 changed files with 1064 additions and 9 deletions

108
tests/test_formula.py Normal file
View 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"), {})

View 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

View File

@@ -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"))

View File

@@ -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(