aps-agent/tests/golden/test_engine_constraint_sema...

190 lines
7.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# matrix-113(Q 方向):RULE/CP/HYBRID/GA 四引擎硬约束语义一致
# 黄金测试(可重生 ✅)
# 同一世界 + 同一 engine flags:硬约束开启 → 对应冲突出现且发布被拦;
# 关闭 → 冲突消失;软权重无法关闭硬约束(profile 门禁拒绝)。
# 审计结论(2026-08-02):四引擎均经 RuleEngine.materialize_schedule 统一物化,
# equipment(C4)/capacity(C7)/materialKit(C6) 开关行为一致,无需引擎修复;
# personnel/tooling/freeze 开关只作用于 FLEX 引擎(柔性世界 flex*),
# 不在本 RULE/CP/HYBRID/GA 四引擎集合的可观察面内(差异见模块注释)。
# ============================================================
from __future__ import annotations
import copy
import pytest
from server.aps_domain.constraints import (
ConstraintProfileDenied,
apply_profile_save,
engine_constraint_flags,
hard_blocking_conflicts,
)
from server.engines import get_engine
from server.engines.base import EngineParams
from server.state.seed import seed_world
from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt, today0
ENGINES = ["RULE", "CP", "HYBRID", "GA"]
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, engine, flags):
"""在传入世界跑一次指定引擎(solve 会就地写版本/冲突)。"""
start = fmt_date(add_minutes(today0(), 24 * 60))
params = EngineParams(
orderIds=[world["salesOrders"][0]["id"]], engineType=engine,
strategyTemplate="COMPREHENSIVE", planningHorizonDays=14,
startDate=start, constraints=flags, timeLimitSeconds=2.0,
)
return get_engine(engine).solve(world, params, _next_id_factory())
def _single_order_world():
"""只保留一个订单,放大数量并压缩班次 → 容量冲突对所有引擎都必然出现。"""
world = seed_world()
so = world["salesOrders"][0]
so["items"] = [so["items"][0]]
so["items"][0]["quantity"] = 3000
keep_pid = so["items"][0]["productId"]
world["lineProducts"] = [lp for lp in world["lineProducts"] if lp["productId"] == keep_pid]
for s in world["shifts"]:
start_hour = int(str(s["startTime"]).split(":", 1)[0])
s["endTime"] = f"{start_hour + 2:02d}:00"
s["breakPeriods"] = []
return world
def _full_horizon_maintenance_world():
"""全时域维保覆盖全部设备 → 任何工单必然与维保重叠(EQUIPMENT 必现)。"""
world = _single_order_world()
world["maintenance"] = []
start = parse_dt(fmt_date(add_minutes(today0(), 24 * 60)) + " 08:00")
end = add_minutes(start, 15 * 24 * 60)
for i, eq in enumerate(world["equipment"]):
world["maintenance"].append({
"id": 100 + i, "equipmentId": eq["id"], "status": "PLANNED",
"plannedStart": fmt_dt(start), "plannedEnd": fmt_dt(end),
})
return world
def _zero_stock_world():
"""清空全部非成品库存 → 物料齐套冲突(C6)必现。"""
world = seed_world()
so = world["salesOrders"][0]
so["items"] = [so["items"][0]]
for m in world["materials"]:
if m.get("type") != "FINISHED_PRODUCT":
m["stock"] = 0
m["inTransit"] = 0
return world
def _conflicts_of(world, version_id, ctype):
return [c for c in world["conflicts"]
if c.get("conflictType") == ctype and c.get("versionId") == version_id]
def _blockers_of(world, version_id, ctype):
return [c for c in hard_blocking_conflicts(world, version_id=version_id)
if c["conflictType"] == ctype]
def _semantics(engine, world_factory, ctype, flags_off_key, patch=None):
"""返回 (on_conflict, on_blocker, off_conflict, off_blocker) 四元组。"""
base = world_factory()
flags = engine_constraint_flags(base)
if patch:
apply_profile_save(base, {"constraints": patch})
flags = engine_constraint_flags(base)
w_on = copy.deepcopy(base)
res = _run(w_on, engine, flags)
on = (bool(_conflicts_of(w_on, res.versionId, ctype)),
bool(_blockers_of(w_on, res.versionId, ctype)))
w_off = copy.deepcopy(base)
res = _run(w_off, engine, {**flags, flags_off_key: False})
off = (bool(_conflicts_of(w_off, res.versionId, ctype)),
bool(_blockers_of(w_off, res.versionId, ctype)))
return (*on, *off)
@pytest.mark.parametrize("engine", ENGINES)
def test_equipment_hard_constraint_on_off_consistent_per_engine(engine):
on_c, on_b, off_c, off_b = _semantics(engine, _full_horizon_maintenance_world,
"EQUIPMENT", "equipment")
assert on_c and on_b, f"{engine}: 开启 EQUIPMENT 硬约束应出现冲突并拦发布"
assert not off_c and not off_b, f"{engine}: 关闭 EQUIPMENT 后冲突应消失"
@pytest.mark.parametrize("engine", ENGINES)
def test_capacity_hard_constraint_on_off_consistent_per_engine(engine):
on_c, on_b, off_c, off_b = _semantics(engine, _single_order_world,
"CAPACITY", "capacity")
assert on_c and on_b, f"{engine}: 开启 CAPACITY 硬约束应出现冲突并拦发布"
assert not off_c and not off_b, f"{engine}: 关闭 CAPACITY 后冲突应消失"
@pytest.mark.parametrize("engine", ENGINES)
def test_material_kit_hard_soft_off_consistent_per_engine(engine):
# 硬:MAJOR 冲突且拦发布
on_c, on_b, off_c, off_b = _semantics(
engine, _zero_stock_world, "MATERIAL_SHORTAGE", "materialKit",
patch={"C6_material_kit": {"kind": "hard", "enabled": True}})
assert on_c and on_b, f"{engine}: C6 硬约束开启应出现 MAJOR 冲突并拦发布"
assert not off_c and not off_b, f"{engine}: C6 关闭后缺料冲突应消失"
@pytest.mark.parametrize("engine", ENGINES)
def test_material_kit_soft_reports_risk_without_blocking(engine):
base = _zero_stock_world()
flags = engine_constraint_flags(base) # C6 默认软
w = copy.deepcopy(base)
res = _run(w, engine, flags)
conflicts = _conflicts_of(w, res.versionId, "MATERIAL_SHORTAGE")
blockers = _blockers_of(w, res.versionId, "MATERIAL_SHORTAGE")
assert conflicts and all(c["severity"] == "MINOR" for c in conflicts)
assert not blockers
def test_all_engines_agree_on_hard_constraint_semantics():
"""跨引擎一致性:同一世界 + 同一 flags,四引擎开/关语义完全一致。"""
for ctype, factory, flag_key, patch in [
("EQUIPMENT", _full_horizon_maintenance_world, "equipment", None),
("CAPACITY", _single_order_world, "capacity", None),
("MATERIAL_SHORTAGE", _zero_stock_world, "materialKit",
{"C6_material_kit": {"kind": "hard", "enabled": True}}),
]:
outcomes = {}
for engine in ENGINES:
outcomes[engine] = _semantics(engine, factory, ctype, flag_key, patch)
first = outcomes["RULE"]
assert first == (True, True, False, False), f"{ctype} RULE 基线异常: {first}"
for engine in ENGINES[1:]:
assert outcomes[engine] == first, (
f"{ctype}: {engine} 与 RULE 语义不一致 {outcomes[engine]} != {first}")
def test_soft_weight_cannot_close_hard_constraint_end_to_end():
"""软权重归零被门禁拒绝 → flags 仍开启 → 四引擎仍拦发布。"""
world = seed_world()
with pytest.raises(ConstraintProfileDenied):
apply_profile_save(world, {"constraints": {"C7_capacity": {"weight": 0}}})
assert engine_constraint_flags(world)["capacity"] is True
base = _single_order_world()
results = {}
for engine in ENGINES:
w = copy.deepcopy(base)
res = _run(w, engine, engine_constraint_flags(world))
results[engine] = bool(_blockers_of(w, res.versionId, "CAPACITY"))
assert results == {e: True for e in ENGINES}