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>
114 lines
4.0 KiB
Python
114 lines
4.0 KiB
Python
"""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
|