Study Interview Mode is now the DEFAULT for all study creation requests. This intelligent Q&A system guides users through optimization setup with: - 7-phase interview flow: introspection → objectives → constraints → design_variables → validation → review → complete - Material-aware validation with 12 materials and fuzzy name matching - Anti-pattern detection for 12 common mistakes (mass-no-constraint, stress-over-yield, etc.) - Auto extractor mapping E1-E24 based on goal keywords - State persistence with JSON serialization and backup rotation - StudyBlueprint generation with full validation Triggers: "create a study", "new study", "optimize this", any study creation intent Skip with: "skip interview", "quick setup", "manual config" Components: - StudyInterviewEngine: Main orchestrator - QuestionEngine: Conditional logic evaluation - EngineeringValidator: MaterialsDatabase + AntiPatternDetector - InterviewPresenter: Markdown formatting for Claude - StudyBlueprint: Validated configuration output - InterviewState: Persistent state management All 129 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
482 lines
15 KiB
Python
482 lines
15 KiB
Python
"""Tests for StudyBlueprint and BlueprintBuilder."""
|
|
|
|
import pytest
|
|
import json
|
|
|
|
from optimization_engine.interview.study_blueprint import (
|
|
StudyBlueprint,
|
|
DesignVariable,
|
|
Objective,
|
|
Constraint,
|
|
BlueprintBuilder,
|
|
)
|
|
from optimization_engine.interview.interview_state import InterviewState
|
|
|
|
|
|
class TestDesignVariable:
|
|
"""Tests for DesignVariable dataclass."""
|
|
|
|
def test_creation(self):
|
|
"""Test creating design variable."""
|
|
dv = DesignVariable(
|
|
parameter="thickness",
|
|
current_value=5.0,
|
|
min_value=1.0,
|
|
max_value=10.0,
|
|
units="mm"
|
|
)
|
|
|
|
assert dv.parameter == "thickness"
|
|
assert dv.min_value == 1.0
|
|
assert dv.max_value == 10.0
|
|
assert dv.current_value == 5.0
|
|
assert dv.units == "mm"
|
|
|
|
def test_to_dict(self):
|
|
"""Test conversion to dict."""
|
|
dv = DesignVariable(
|
|
parameter="thickness",
|
|
current_value=5.0,
|
|
min_value=1.0,
|
|
max_value=10.0
|
|
)
|
|
|
|
d = dv.to_dict()
|
|
assert d["parameter"] == "thickness"
|
|
assert d["min_value"] == 1.0
|
|
assert d["max_value"] == 10.0
|
|
|
|
def test_to_config_format(self):
|
|
"""Test conversion to config format."""
|
|
dv = DesignVariable(
|
|
parameter="thickness",
|
|
current_value=5.0,
|
|
min_value=1.0,
|
|
max_value=10.0,
|
|
units="mm"
|
|
)
|
|
|
|
config = dv.to_config_format()
|
|
assert config["expression_name"] == "thickness"
|
|
assert config["bounds"] == [1.0, 10.0]
|
|
|
|
|
|
class TestObjective:
|
|
"""Tests for Objective dataclass."""
|
|
|
|
def test_creation(self):
|
|
"""Test creating objective."""
|
|
obj = Objective(
|
|
name="mass",
|
|
goal="minimize",
|
|
extractor="E4",
|
|
weight=1.0
|
|
)
|
|
|
|
assert obj.name == "mass"
|
|
assert obj.goal == "minimize"
|
|
assert obj.extractor == "E4"
|
|
assert obj.weight == 1.0
|
|
|
|
def test_to_dict(self):
|
|
"""Test conversion to dict."""
|
|
obj = Objective(
|
|
name="displacement",
|
|
goal="minimize",
|
|
extractor="E1",
|
|
extractor_params={"node_id": 123}
|
|
)
|
|
|
|
d = obj.to_dict()
|
|
assert d["name"] == "displacement"
|
|
assert d["extractor"] == "E1"
|
|
assert d["extractor_params"]["node_id"] == 123
|
|
|
|
def test_to_config_format(self):
|
|
"""Test conversion to config format."""
|
|
obj = Objective(
|
|
name="mass",
|
|
goal="minimize",
|
|
extractor="E4",
|
|
weight=0.5
|
|
)
|
|
|
|
config = obj.to_config_format()
|
|
assert config["name"] == "mass"
|
|
assert config["type"] == "minimize"
|
|
assert config["weight"] == 0.5
|
|
|
|
|
|
class TestConstraint:
|
|
"""Tests for Constraint dataclass."""
|
|
|
|
def test_creation(self):
|
|
"""Test creating constraint."""
|
|
con = Constraint(
|
|
name="max_stress",
|
|
constraint_type="max",
|
|
threshold=200.0,
|
|
extractor="E3"
|
|
)
|
|
|
|
assert con.name == "max_stress"
|
|
assert con.constraint_type == "max"
|
|
assert con.threshold == 200.0
|
|
|
|
def test_to_dict(self):
|
|
"""Test conversion to dict."""
|
|
con = Constraint(
|
|
name="max_displacement",
|
|
constraint_type="max",
|
|
threshold=0.5,
|
|
extractor="E1"
|
|
)
|
|
|
|
d = con.to_dict()
|
|
assert d["name"] == "max_displacement"
|
|
assert d["threshold"] == 0.5
|
|
|
|
def test_to_config_format(self):
|
|
"""Test conversion to config format."""
|
|
con = Constraint(
|
|
name="max_stress",
|
|
constraint_type="max",
|
|
threshold=200.0,
|
|
extractor="E3",
|
|
is_hard=True
|
|
)
|
|
|
|
config = con.to_config_format()
|
|
assert config["type"] == "max"
|
|
assert config["threshold"] == 200.0
|
|
assert config["hard"] is True
|
|
|
|
|
|
class TestStudyBlueprint:
|
|
"""Tests for StudyBlueprint dataclass."""
|
|
|
|
def test_creation(self):
|
|
"""Test creating blueprint."""
|
|
bp = StudyBlueprint(
|
|
study_name="test_study",
|
|
study_description="A test study",
|
|
model_path="/path/model.prt",
|
|
sim_path="/path/sim.sim",
|
|
design_variables=[
|
|
DesignVariable(parameter="t", current_value=5, min_value=1, max_value=10)
|
|
],
|
|
objectives=[
|
|
Objective(name="mass", goal="minimize", extractor="E4")
|
|
],
|
|
constraints=[
|
|
Constraint(name="stress", constraint_type="max", threshold=200, extractor="E3")
|
|
],
|
|
protocol="protocol_10_single",
|
|
n_trials=100,
|
|
sampler="TPE"
|
|
)
|
|
|
|
assert bp.study_name == "test_study"
|
|
assert len(bp.design_variables) == 1
|
|
assert len(bp.objectives) == 1
|
|
assert len(bp.constraints) == 1
|
|
|
|
def test_to_config_json(self):
|
|
"""Test conversion to optimization_config.json format."""
|
|
bp = StudyBlueprint(
|
|
study_name="test",
|
|
study_description="Test",
|
|
model_path="/model.prt",
|
|
sim_path="/sim.sim",
|
|
design_variables=[
|
|
DesignVariable(parameter="thickness", current_value=5, min_value=1, max_value=10)
|
|
],
|
|
objectives=[
|
|
Objective(name="mass", goal="minimize", extractor="E4")
|
|
],
|
|
constraints=[],
|
|
protocol="protocol_10_single",
|
|
n_trials=50,
|
|
sampler="TPE"
|
|
)
|
|
|
|
config = bp.to_config_json()
|
|
|
|
assert isinstance(config, dict)
|
|
assert config["study_name"] == "test"
|
|
assert "design_variables" in config
|
|
assert "objectives" in config
|
|
|
|
# Should be valid JSON
|
|
json_str = json.dumps(config)
|
|
assert len(json_str) > 0
|
|
|
|
def test_to_markdown(self):
|
|
"""Test conversion to markdown summary."""
|
|
bp = StudyBlueprint(
|
|
study_name="bracket_v1",
|
|
study_description="Bracket optimization",
|
|
model_path="/model.prt",
|
|
sim_path="/sim.sim",
|
|
design_variables=[
|
|
DesignVariable(parameter="thickness", current_value=5, min_value=1, max_value=10, units="mm")
|
|
],
|
|
objectives=[
|
|
Objective(name="mass", goal="minimize", extractor="E4")
|
|
],
|
|
constraints=[
|
|
Constraint(name="stress", constraint_type="max", threshold=200, extractor="E3")
|
|
],
|
|
protocol="protocol_10_single",
|
|
n_trials=100,
|
|
sampler="TPE"
|
|
)
|
|
|
|
md = bp.to_markdown()
|
|
|
|
assert "bracket_v1" in md
|
|
assert "thickness" in md
|
|
assert "mass" in md.lower()
|
|
assert "stress" in md
|
|
assert "200" in md
|
|
assert "100" in md # n_trials
|
|
assert "TPE" in md
|
|
|
|
def test_validate_valid_blueprint(self):
|
|
"""Test validation passes for valid blueprint."""
|
|
bp = StudyBlueprint(
|
|
study_name="test",
|
|
study_description="Test",
|
|
model_path="/model.prt",
|
|
sim_path="/sim.sim",
|
|
design_variables=[
|
|
DesignVariable(parameter="t", current_value=5, min_value=1, max_value=10)
|
|
],
|
|
objectives=[
|
|
Objective(name="mass", goal="minimize", extractor="E4")
|
|
],
|
|
constraints=[
|
|
Constraint(name="stress", constraint_type="max", threshold=200, extractor="E3")
|
|
],
|
|
protocol="protocol_10_single",
|
|
n_trials=100,
|
|
sampler="TPE"
|
|
)
|
|
|
|
errors = bp.validate()
|
|
assert len(errors) == 0
|
|
|
|
def test_validate_missing_objectives(self):
|
|
"""Test validation catches missing objectives."""
|
|
bp = StudyBlueprint(
|
|
study_name="test",
|
|
study_description="Test",
|
|
model_path="/model.prt",
|
|
sim_path="/sim.sim",
|
|
design_variables=[
|
|
DesignVariable(parameter="t", current_value=5, min_value=1, max_value=10)
|
|
],
|
|
objectives=[], # No objectives
|
|
constraints=[],
|
|
protocol="protocol_10_single",
|
|
n_trials=100,
|
|
sampler="TPE"
|
|
)
|
|
|
|
errors = bp.validate()
|
|
assert any("objective" in e.lower() for e in errors)
|
|
|
|
def test_validate_missing_design_variables(self):
|
|
"""Test validation catches missing design variables."""
|
|
bp = StudyBlueprint(
|
|
study_name="test",
|
|
study_description="Test",
|
|
model_path="/model.prt",
|
|
sim_path="/sim.sim",
|
|
design_variables=[], # No design variables
|
|
objectives=[
|
|
Objective(name="mass", goal="minimize", extractor="E4")
|
|
],
|
|
constraints=[],
|
|
protocol="protocol_10_single",
|
|
n_trials=100,
|
|
sampler="TPE"
|
|
)
|
|
|
|
errors = bp.validate()
|
|
assert any("design variable" in e.lower() for e in errors)
|
|
|
|
def test_validate_invalid_bounds(self):
|
|
"""Test validation catches invalid bounds."""
|
|
bp = StudyBlueprint(
|
|
study_name="test",
|
|
study_description="Test",
|
|
model_path="/model.prt",
|
|
sim_path="/sim.sim",
|
|
design_variables=[
|
|
DesignVariable(parameter="t", current_value=5, min_value=10, max_value=1) # min > max
|
|
],
|
|
objectives=[
|
|
Objective(name="mass", goal="minimize", extractor="E4")
|
|
],
|
|
constraints=[],
|
|
protocol="protocol_10_single",
|
|
n_trials=100,
|
|
sampler="TPE"
|
|
)
|
|
|
|
errors = bp.validate()
|
|
assert any("bound" in e.lower() or "min" in e.lower() for e in errors)
|
|
|
|
def test_to_dict_from_dict_roundtrip(self):
|
|
"""Test dict serialization roundtrip."""
|
|
bp = StudyBlueprint(
|
|
study_name="test",
|
|
study_description="Test",
|
|
model_path="/model.prt",
|
|
sim_path="/sim.sim",
|
|
design_variables=[
|
|
DesignVariable(parameter="t", current_value=5, min_value=1, max_value=10)
|
|
],
|
|
objectives=[
|
|
Objective(name="mass", goal="minimize", extractor="E4")
|
|
],
|
|
constraints=[
|
|
Constraint(name="stress", constraint_type="max", threshold=200, extractor="E3")
|
|
],
|
|
protocol="protocol_10_single",
|
|
n_trials=100,
|
|
sampler="TPE"
|
|
)
|
|
|
|
d = bp.to_dict()
|
|
bp2 = StudyBlueprint.from_dict(d)
|
|
|
|
assert bp2.study_name == bp.study_name
|
|
assert len(bp2.design_variables) == len(bp.design_variables)
|
|
assert bp2.n_trials == bp.n_trials
|
|
|
|
|
|
class TestBlueprintBuilder:
|
|
"""Tests for BlueprintBuilder."""
|
|
|
|
def test_from_interview_state_simple(self):
|
|
"""Test building blueprint from simple interview state."""
|
|
builder = BlueprintBuilder()
|
|
|
|
state = InterviewState(
|
|
study_name="bracket_v1",
|
|
study_path="/path/to/study"
|
|
)
|
|
state.answers = {
|
|
"study_description": "Bracket mass optimization",
|
|
"objectives": [{"goal": "minimize_mass"}],
|
|
"constraints": [{"type": "stress", "threshold": 200}],
|
|
"design_variables": [
|
|
{"name": "thickness", "min": 1, "max": 10, "current": 5}
|
|
],
|
|
"n_trials": 100,
|
|
}
|
|
|
|
introspection = {
|
|
"model_path": "/path/model.prt",
|
|
"sim_path": "/path/sim.sim"
|
|
}
|
|
|
|
bp = builder.from_interview_state(state, introspection)
|
|
|
|
assert bp.study_name == "bracket_v1"
|
|
assert len(bp.design_variables) >= 1
|
|
assert len(bp.objectives) >= 1
|
|
|
|
def test_from_interview_state_multi_objective(self):
|
|
"""Test building blueprint for multi-objective optimization."""
|
|
builder = BlueprintBuilder()
|
|
|
|
state = InterviewState(study_name="multi_obj")
|
|
state.answers = {
|
|
"study_description": "Multi-objective",
|
|
"objectives": [
|
|
{"goal": "minimize_mass"},
|
|
{"goal": "minimize_displacement"}
|
|
],
|
|
"constraints": [],
|
|
"design_variables": [
|
|
{"name": "t", "min": 1, "max": 10}
|
|
],
|
|
"n_trials": 200
|
|
}
|
|
|
|
introspection = {}
|
|
|
|
bp = builder.from_interview_state(state, introspection)
|
|
|
|
# Blueprint creation succeeds
|
|
assert bp is not None
|
|
assert bp.study_name == "multi_obj"
|
|
|
|
def test_auto_assign_extractors(self):
|
|
"""Test automatic extractor assignment."""
|
|
builder = BlueprintBuilder()
|
|
|
|
state = InterviewState(study_name="test")
|
|
state.answers = {
|
|
"study_description": "Test",
|
|
"objectives": [{"goal": "minimize_mass"}], # No extractor specified
|
|
"constraints": [],
|
|
"design_variables": [{"name": "t", "min": 1, "max": 10}],
|
|
"n_trials": 50
|
|
}
|
|
|
|
bp = builder.from_interview_state(state, {})
|
|
|
|
# Should auto-assign E4 for mass
|
|
assert bp.objectives[0].extractor == "E4"
|
|
|
|
def test_calculate_n_trials(self):
|
|
"""Test automatic trial count calculation."""
|
|
builder = BlueprintBuilder()
|
|
|
|
# Few design variables = fewer trials
|
|
state = InterviewState(study_name="test")
|
|
state.answers = {
|
|
"study_description": "Test",
|
|
"objectives": [{"goal": "minimize_mass"}],
|
|
"constraints": [],
|
|
"design_variables": [
|
|
{"name": "t1", "min": 1, "max": 10},
|
|
{"name": "t2", "min": 1, "max": 10},
|
|
],
|
|
}
|
|
state.complexity = "simple"
|
|
|
|
bp = builder.from_interview_state(state, {})
|
|
assert bp.n_trials >= 50 # Minimum trials
|
|
|
|
def test_select_sampler(self):
|
|
"""Test automatic sampler selection."""
|
|
builder = BlueprintBuilder()
|
|
|
|
# Single objective = TPE
|
|
state = InterviewState(study_name="test")
|
|
state.answers = {
|
|
"study_description": "Test",
|
|
"objectives": [{"goal": "minimize_mass"}],
|
|
"constraints": [],
|
|
"design_variables": [{"name": "t", "min": 1, "max": 10}],
|
|
}
|
|
|
|
bp = builder.from_interview_state(state, {})
|
|
assert bp.sampler == "TPE"
|
|
|
|
# Multi-objective case - sampler selection depends on implementation
|
|
state.answers["objectives"] = [
|
|
{"goal": "minimize_mass"},
|
|
{"goal": "minimize_displacement"}
|
|
]
|
|
|
|
bp = builder.from_interview_state(state, {})
|
|
# Just verify blueprint is created successfully
|
|
assert bp.sampler is not None
|
|
|