feat: 完成 issue #79 [Ti-2] 配方优化求解器集成(网格枚举+坐标下降轻量求解器+solve统一入口,求解器无关契约)

This commit is contained in:
2026-08-05 04:00:57 +08:00
parent 2aa70d078b
commit c42846e18a
4 changed files with 524 additions and 3 deletions
@@ -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()