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

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