feat: 完成 issue #79 [Ti-2] 配方优化求解器集成(网格枚举+坐标下降轻量求解器+solve统一入口,求解器无关契约)
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Ti-2 配方优化求解器集成 单元测试(Issue #79)。
|
||||
|
||||
覆盖:
|
||||
- 求解器配置(grid_steps/max_combinations 合法性);
|
||||
- 候选取值生成(bounds 等分/integer 去重/choices 枚举);
|
||||
- 网格求解(全局最优、可行性、target 达成、无可行解降级);
|
||||
- 坐标下降(规模超限降级、收敛);
|
||||
- solve 统一入口(自动选策略、静态校验失败、空问题);
|
||||
- Solution 序列化;
|
||||
- 加载 #78 模板后端到端求解。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _bootstrap # noqa: E402 挂载 recipe_optim 包
|
||||
|
||||
from recipe_optim.problem import ( # noqa: E402
|
||||
ConstraintKind,
|
||||
ConstraintSpec,
|
||||
DecisionVariable,
|
||||
DomainKind,
|
||||
ObjectiveSpec,
|
||||
ObjectiveTerm,
|
||||
OptimizationProblem,
|
||||
Sense,
|
||||
load_problem,
|
||||
)
|
||||
from recipe_optim.solver import ( # noqa: E402
|
||||
SolverConfig,
|
||||
SolverError,
|
||||
Solution,
|
||||
candidate_values,
|
||||
solve,
|
||||
)
|
||||
|
||||
CONFIG_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"config", "recipe_optim.template.yaml",
|
||||
)
|
||||
|
||||
|
||||
def _toy_problem() -> OptimizationProblem:
|
||||
"""minimize -(x+y),x∈[0,4] 5 点,y∈[0,4] 5 点 → 网格 25 组合。"""
|
||||
return OptimizationProblem(
|
||||
variables=[
|
||||
DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 4.0)),
|
||||
DecisionVariable("y", DomainKind.BOUNDS, bounds=(0.0, 4.0)),
|
||||
],
|
||||
objective=ObjectiveSpec(Sense.MAXIMIZE,
|
||||
terms=[ObjectiveTerm("x", 1.0), ObjectiveTerm("y", 1.0)]),
|
||||
)
|
||||
|
||||
|
||||
class TestSolverConfig(unittest.TestCase):
|
||||
def test_defaults(self):
|
||||
c = SolverConfig()
|
||||
self.assertGreaterEqual(c.grid_steps, 2)
|
||||
self.assertGreaterEqual(c.max_combinations, 1)
|
||||
|
||||
def test_invalid_grid_steps(self):
|
||||
with self.assertRaises(SolverError):
|
||||
SolverConfig(grid_steps=1)
|
||||
|
||||
def test_invalid_max_combinations(self):
|
||||
with self.assertRaises(SolverError):
|
||||
SolverConfig(max_combinations=0)
|
||||
|
||||
|
||||
class TestCandidateValues(unittest.TestCase):
|
||||
def test_bounds_grid(self):
|
||||
v = DecisionVariable("t", DomainKind.BOUNDS, bounds=(0.0, 4.0))
|
||||
vals = candidate_values(v, SolverConfig(grid_steps=5))
|
||||
self.assertEqual(vals[0], 0.0)
|
||||
self.assertEqual(vals[-1], 4.0)
|
||||
self.assertEqual(len(vals), 5)
|
||||
|
||||
def test_integer_dedupe(self):
|
||||
v = DecisionVariable("n", DomainKind.BOUNDS, bounds=(0.0, 4.0), integer=True)
|
||||
vals = candidate_values(v, SolverConfig(grid_steps=5))
|
||||
self.assertEqual(vals, [0, 1, 2, 3, 4])
|
||||
|
||||
def test_choices_enum(self):
|
||||
v = DecisionVariable("c", DomainKind.CHOICES, choices=["A", "B", "C"])
|
||||
self.assertEqual(candidate_values(v, SolverConfig()), ["A", "B", "C"])
|
||||
self.assertEqual(candidate_values(v, SolverConfig(enumerate_choices=False)), ["A"])
|
||||
|
||||
|
||||
class TestSolveGrid(unittest.TestCase):
|
||||
def test_global_optimum_maximize(self):
|
||||
p = _toy_problem()
|
||||
sol = solve(p, SolverConfig(grid_steps=5))
|
||||
self.assertTrue(sol.feasible)
|
||||
# 最优 x=y=4 → obj=8
|
||||
self.assertAlmostEqual(sol.objective_value, 8.0)
|
||||
self.assertEqual(sol.assignment["x"], 4.0)
|
||||
self.assertEqual(sol.assignment["y"], 4.0)
|
||||
self.assertEqual(sol.strategy, "grid")
|
||||
|
||||
def test_minimize(self):
|
||||
p = OptimizationProblem(
|
||||
variables=[DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 4.0))],
|
||||
objective=ObjectiveSpec(Sense.MINIMIZE, terms=[ObjectiveTerm("x", 1.0)]),
|
||||
)
|
||||
sol = solve(p, SolverConfig(grid_steps=5))
|
||||
self.assertTrue(sol.feasible)
|
||||
self.assertEqual(sol.assignment["x"], 0.0)
|
||||
self.assertAlmostEqual(sol.objective_value, 0.0)
|
||||
|
||||
def test_target_met(self):
|
||||
p = OptimizationProblem(
|
||||
variables=[DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 10.0))],
|
||||
objective=ObjectiveSpec(Sense.MAXIMIZE, target_value=8.0,
|
||||
terms=[ObjectiveTerm("x", 1.0)]),
|
||||
)
|
||||
sol = solve(p, SolverConfig(grid_steps=11))
|
||||
self.assertTrue(sol.feasible)
|
||||
self.assertTrue(sol.target_met) # x=10 >= 8
|
||||
|
||||
def test_target_not_met(self):
|
||||
p = OptimizationProblem(
|
||||
variables=[DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 5.0))],
|
||||
objective=ObjectiveSpec(Sense.MAXIMIZE, target_value=99.0,
|
||||
terms=[ObjectiveTerm("x", 1.0)]),
|
||||
)
|
||||
sol = solve(p, SolverConfig(grid_steps=6))
|
||||
self.assertTrue(sol.feasible)
|
||||
self.assertFalse(sol.target_met)
|
||||
|
||||
def test_no_feasible_solution(self):
|
||||
# box 收紧到与域不交 → 无可行
|
||||
p = OptimizationProblem(
|
||||
variables=[DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 1.0))],
|
||||
constraints=[ConstraintSpec(ConstraintKind.BOX, variable="x", bounds=(5.0, 6.0))],
|
||||
objective=ObjectiveSpec(Sense.MAXIMIZE, terms=[ObjectiveTerm("x", 1.0)]),
|
||||
)
|
||||
sol = solve(p, SolverConfig(grid_steps=3))
|
||||
self.assertFalse(sol.feasible)
|
||||
self.assertIn("无可行解", sol.message)
|
||||
|
||||
|
||||
class TestCoordinateDescent(unittest.TestCase):
|
||||
def test_falls_back_when_grid_too_large(self):
|
||||
# 三个变量 × grid_steps=5 = 125;设 max_combinations=10 → 降级
|
||||
p = OptimizationProblem(
|
||||
variables=[
|
||||
DecisionVariable(f"v{i}", DomainKind.BOUNDS, bounds=(0.0, 4.0))
|
||||
for i in range(3)
|
||||
],
|
||||
objective=ObjectiveSpec(Sense.MAXIMIZE,
|
||||
terms=[ObjectiveTerm(f"v{i}", 1.0) for i in range(3)]),
|
||||
)
|
||||
sol = solve(p, SolverConfig(grid_steps=5, max_combinations=10))
|
||||
self.assertEqual(sol.strategy, "coordinate_descent")
|
||||
# 坐标下降应能爬到各维上界附近(贪心可收敛到此线性目标的全局最优)
|
||||
self.assertTrue(sol.feasible)
|
||||
self.assertAlmostEqual(sol.objective_value, 12.0, places=6)
|
||||
|
||||
|
||||
class TestSolveEntry(unittest.TestCase):
|
||||
def test_validate_failure_returns_infeasible(self):
|
||||
p = OptimizationProblem(
|
||||
variables=[DecisionVariable("x", DomainKind.BOUNDS, bounds=(0.0, 1.0))],
|
||||
objective=ObjectiveSpec(Sense.MAXIMIZE,
|
||||
terms=[ObjectiveTerm("ghost", 1.0)]),
|
||||
)
|
||||
sol = solve(p, SolverConfig())
|
||||
self.assertFalse(sol.feasible)
|
||||
self.assertEqual(sol.strategy, "validate")
|
||||
self.assertIn("校验失败", sol.message)
|
||||
|
||||
def test_empty_problem_trivially_feasible(self):
|
||||
p = OptimizationProblem()
|
||||
sol = solve(p, SolverConfig())
|
||||
self.assertTrue(sol.feasible)
|
||||
self.assertEqual(sol.strategy, "empty")
|
||||
|
||||
def test_solution_to_dict(self):
|
||||
p = _toy_problem()
|
||||
sol = solve(p, SolverConfig(grid_steps=3))
|
||||
d = sol.to_dict()
|
||||
self.assertIn("assignment", d)
|
||||
self.assertIn("objective_value", d)
|
||||
self.assertIn("evaluated", d)
|
||||
self.assertTrue(d["feasible"])
|
||||
|
||||
|
||||
class TestEndToEndFromTemplate(unittest.TestCase):
|
||||
def test_solve_loaded_problem(self):
|
||||
p = load_problem(CONFIG_PATH)
|
||||
sol = solve(p, SolverConfig(grid_steps=7, max_combinations=200000))
|
||||
# 模板含 4 变量;7^3 * 3 = 1029 组合 < 上限 → 网格
|
||||
self.assertEqual(sol.strategy, "grid")
|
||||
self.assertTrue(sol.feasible)
|
||||
# 取值应满足 box 收紧(clf_temp∈[820,900])与 forbidden(非 C@910)
|
||||
self.assertGreaterEqual(sol.assignment["clf_temp"], 820 - 1e-6)
|
||||
self.assertLessEqual(sol.assignment["clf_temp"], 900 + 1e-6)
|
||||
self.assertFalse(sol.assignment["catalyst"] == "C"
|
||||
and sol.assignment.get("clf_temp") == 910)
|
||||
# evaluated>0
|
||||
self.assertGreater(sol.evaluated, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user