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:
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
|
||||
Reference in New Issue
Block a user