178 lines
6.9 KiB
Python
178 lines
6.9 KiB
Python
# ============================================================
|
||
# SC-03 CP-SAT 引擎黄金测试
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from server.engines import get_engine
|
||
from server.engines.base import EngineParams
|
||
from server.engines.cp_engine import CpSatEngine
|
||
from server.engines.rule_engine import RuleEngine
|
||
from server.state.seed import seed_world
|
||
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
|
||
|
||
|
||
def _next_id_factory():
|
||
counters: dict[str, int] = {}
|
||
|
||
def next_id(kind: str) -> int:
|
||
counters[kind] = counters.get(kind, 0) + 1
|
||
return counters[kind]
|
||
|
||
return next_id
|
||
|
||
|
||
def _run(world, strategy="COMPREHENSIVE", engine="CP", time_limit=8.0):
|
||
start = fmt_date(add_minutes(today0(), 24 * 60))
|
||
params = EngineParams(
|
||
orderIds=[], engineType=engine, strategyTemplate=strategy,
|
||
planningHorizonDays=14, startDate=start, timeLimitSeconds=time_limit,
|
||
)
|
||
return get_engine(engine).solve(world, params, _next_id_factory())
|
||
|
||
|
||
def test_get_engine_cp_is_cpsat():
|
||
eng = get_engine("CP")
|
||
assert isinstance(eng, CpSatEngine)
|
||
assert eng.name == "CP"
|
||
assert eng.supports_anytime is True
|
||
|
||
|
||
def test_cp_engine_type_and_solver_meta():
|
||
world = seed_world()
|
||
result = _run(world)
|
||
assert result.engineType == "CP"
|
||
assert world["scheduleVersions"][0]["engineType"] == "CP"
|
||
meta = world["scheduleVersions"][0].get("solverMeta") or {}
|
||
assert meta.get("backend") == "OR-Tools CP-SAT"
|
||
assert result.solveStatus in ("OPTIMAL", "FEASIBLE", "UNKNOWN", "INFEASIBLE")
|
||
assert result.solveTimeSec is not None
|
||
assert result.poCount == 7
|
||
assert result.woCount == 30
|
||
|
||
|
||
def test_cp_no_workstation_overlap():
|
||
world = seed_world()
|
||
_run(world)
|
||
by_ws: dict[int, list] = {}
|
||
for wo in world["workOrders"]:
|
||
by_ws.setdefault(wo["workstationId"], []).append(
|
||
(parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"])))
|
||
for ws_id, ivs in by_ws.items():
|
||
ivs.sort()
|
||
for (s1, e1), (s2, e2) in zip(ivs, ivs[1:]):
|
||
assert e1 <= s2, f"工位 {ws_id} 双占"
|
||
|
||
|
||
def test_cp_tardiness_not_worse_than_rule():
|
||
"""可行 CP 结果直接物化经父进程验证的 operation slots。"""
|
||
start = fmt_date(add_minutes(today0(), 24 * 60))
|
||
params_c = EngineParams(
|
||
orderIds=[], engineType="CP", strategyTemplate="COMPREHENSIVE",
|
||
planningHorizonDays=14, startDate=start, timeLimitSeconds=10.0,
|
||
)
|
||
w_c = seed_world()
|
||
c = CpSatEngine().solve(w_c, params_c, _next_id_factory())
|
||
version = w_c["scheduleVersions"][-1]
|
||
solver_meta = version["solverMeta"]
|
||
c3_validation = solver_meta["materializedC3Validation"]
|
||
work_orders = [
|
||
row for row in w_c["workOrders"]
|
||
if row.get("schedulingVersionId") == c.versionId
|
||
]
|
||
calendar_conflicts = [
|
||
row for row in w_c["conflicts"]
|
||
if row.get("versionId") == c.versionId and row.get("conflictType") == "CALENDAR"
|
||
]
|
||
assert c.solveStatus in {"OPTIMAL", "FEASIBLE"}
|
||
assert solver_meta["directlyConsumedByMaterializer"] is True
|
||
assert solver_meta["operationTimingValidation"]["passed"] is True
|
||
assert c3_validation["passed"] is True
|
||
assert c3_validation["cpTimingApplied"] is True
|
||
assert solver_meta["materializedC7Validation"]["passed"] is True
|
||
assert len(work_orders) == c.woCount
|
||
assert all(row.get("cpTimingSource") == "operationSlots" for row in work_orders)
|
||
assert all(row.get("plannedSegments") for row in work_orders)
|
||
assert version["totalTardiness"] == c.totalTardiness
|
||
assert calendar_conflicts == []
|
||
|
||
|
||
def test_cp_timeout_returns_feasible_or_status():
|
||
"""极短时限:必须返回带 status 的结果,不得静默变 RULE。"""
|
||
world = seed_world()
|
||
result = _run(world, time_limit=0.5)
|
||
assert result.engineType == "CP"
|
||
assert result.solveStatus is not None
|
||
meta = world["scheduleVersions"][0]["solverMeta"]
|
||
assert meta["backend"] == "OR-Tools CP-SAT"
|
||
# anytime:通常至少 FEASIBLE/OPTIMAL/UNKNOWN;若 INFEASIBLE 则有 fallback 留痕
|
||
assert result.poCount >= 0
|
||
|
||
|
||
def test_cp_infeasible_solver_meta_exposes_native_assumption_core():
|
||
world = seed_world()
|
||
start = fmt_date(add_minutes(today0(), 24 * 60))
|
||
params = EngineParams(
|
||
orderIds=[], engineType="CP", strategyTemplate="COMPREHENSIVE",
|
||
planningHorizonDays=14, startDate=start, timeLimitSeconds=3.0,
|
||
freezeWindowHours=100_000,
|
||
)
|
||
result = CpSatEngine().solve(world, params, _next_id_factory())
|
||
meta = world["scheduleVersions"][-1]["solverMeta"]
|
||
assert result.solveStatus == "INFEASIBLE"
|
||
core = meta["nativeIis"]
|
||
assert core["native"] is True
|
||
assert core["method"] == "SufficientAssumptionsForInfeasibility"
|
||
assert core["minimality"] == "sufficient-assumption-core"
|
||
assert core["isMinimalIis"] is False
|
||
assert "C11_freeze" in core["constraintIds"]
|
||
from server.aps_domain.constraints import get_constraint
|
||
assert all(get_constraint(world, cid) is not None for cid in core["constraintIds"])
|
||
|
||
|
||
@pytest.mark.parametrize("requested", ["RULE", "GA"])
|
||
def test_non_cp_still_rule_proxy(requested):
|
||
world = seed_world()
|
||
result = _run(world, engine=requested)
|
||
assert result.engineType == requested
|
||
assert isinstance(get_engine(requested), RuleEngine)
|
||
|
||
|
||
def test_hybrid_is_real_pipeline():
|
||
from server.engines.cp_engine import HybridEngine
|
||
eng = get_engine("HYBRID")
|
||
assert isinstance(eng, HybridEngine)
|
||
world = seed_world()
|
||
result = _run(world, engine="HYBRID")
|
||
assert result.engineType == "HYBRID"
|
||
meta = world["scheduleVersions"][0].get("solverMeta") or {}
|
||
assert meta.get("warmStart") == "RULE"
|
||
assert "RULE→CP-SAT" in str(meta.get("pipeline") or "")
|
||
assert result.solveStatus is not None
|
||
assert result.poCount == 7
|
||
|
||
|
||
def test_hybrid_tardiness_not_worse_than_rule():
|
||
start = fmt_date(add_minutes(today0(), 24 * 60))
|
||
w_h = seed_world()
|
||
h = get_engine("HYBRID").solve(w_h, EngineParams(
|
||
orderIds=[], engineType="HYBRID", strategyTemplate="COMPREHENSIVE",
|
||
planningHorizonDays=14, startDate=start, timeLimitSeconds=10.0,
|
||
), _next_id_factory())
|
||
version = w_h["scheduleVersions"][-1]
|
||
solver_meta = version["solverMeta"]
|
||
work_orders = [
|
||
row for row in w_h["workOrders"]
|
||
if row.get("schedulingVersionId") == h.versionId
|
||
]
|
||
assert h.solveStatus in {"OPTIMAL", "FEASIBLE"}
|
||
assert solver_meta["directlyConsumedByMaterializer"] is True
|
||
assert solver_meta["operationTimingValidation"]["passed"] is True
|
||
assert solver_meta["materializedC3Validation"]["passed"] is True
|
||
assert solver_meta["materializedC7Validation"]["passed"] is True
|
||
assert len(work_orders) == h.woCount
|
||
assert all(row.get("cpTimingSource") == "operationSlots" for row in work_orders)
|
||
assert all(row.get("plannedSegments") for row in work_orders)
|
||
assert version["totalTardiness"] == h.totalTardiness
|