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

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