aps-agent/tests/golden/test_attribution.py

297 lines
13 KiB
Python
Raw Permalink Normal View History

# ============================================================
# M5-89 紧约束/影子价、IIS 与冲突归因 黄金测试
# 验收口径:对不可行问题产出最小冲突集(删除任一约束后可恢复可行);
# 可行输入返回空 + 明确说明;主控参数结论可由求解日志复核;
# 归因文案为现场计划员可读的中文。
# ============================================================
from __future__ import annotations
import pytest
from server.aps_domain.attribution import (
build_attribution_report,
detect_iis,
feasibility_after_relax,
hard_violations,
relief_constraints,
)
from server.aps_domain.constraints import (
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, 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"):
# 黄金用例固定以当前验收基线 2026-08-02 的次日开排,禁止跨午夜漂移。
start = "2026-08-03"
params = EngineParams(
orderIds=[], engineType="RULE", strategyTemplate=strategy,
planningHorizonDays=14, startDate=start,
constraints=engine_constraint_flags(world),
)
return get_engine("RULE").solve(world, params, _next_id_factory())
def _resolve_all(world):
for c in world["conflicts"]:
c["isResolved"] = True
def _inject_conflict(world, vid, **fields):
"""注入一条冲突记录(与现有测试同款手法),id 自动续号。"""
cid = max((c.get("id") or 0 for c in world["conflicts"]), default=0) + 1
rec = {"id": cid, "versionId": vid, "severity": "MAJOR",
"isResolved": False, "resolutionAction": ""}
rec.update(fields)
world["conflicts"].append(rec)
return rec
def _first_capacity_on(world):
"""取本次求解产生的首个产能冲突,不绑定机器当前日期。"""
return next(
c for c in world["conflicts"]
if c["conflictType"] == "CAPACITY"
)
def _production_order_on_capacity_line(world, capacity_conflict):
line = next(
row for row in world["lines"]
if row["name"] == capacity_conflict["resourceName"]
)
return next(
row["orderNo"] for row in world["productionOrders"]
if row["lineId"] == line["id"]
)
def test_coupled_capacity_due_date_produces_minimal_conflict_set():
"""资源超额 + 交期冲突(联合成因)→ 最小冲突集 {C7, C8},
删除其中任一约束后恢复可行(不可约性质)。"""
world = seed_world()
result = _run(world)
vid = result.versionId
# 交期冲突升级为硬约束(否则 DELAY 只是风险,不构成不可行)
apply_profile_save(world, {"constraints": {"C8_due_date": {"kind": "hard", "enabled": True}}})
cap = _first_capacity_on(world)
order_no = _production_order_on_capacity_line(world, cap)
cap["reliefConstraintIds"] = ["C7_capacity", "C8_due_date"]
for c in world["conflicts"]:
if c["id"] != cap["id"]:
c["isResolved"] = True
_inject_conflict(
world, vid,
conflictType="DELAY", resourceType="TIME", orderNo=order_no,
description=f"{order_no} 完成时间晚于交期 6.0 小时",
suggestedSolution="分流至替代产线或启用加班",
reliefConstraintIds=["C8_due_date", "C7_capacity"],
)
report = build_attribution_report(world, version_id=vid)
assert report["feasible"] is False
assert report["iis"], "不可行输入必须产出非空最小冲突集"
group = next(
g for g in report["iis"]
if set(g["constraintIds"]) == {"C7_capacity", "C8_due_date"}
)
# 不可约:删除其中任一约束后该冲突组恢复可行
assert len(group["verification"]) == 2
for v in group["verification"]:
assert v["feasibleAfterDelete"] is True
assert feasibility_after_relax(world, ["C7_capacity"], version_id=vid) is True
assert feasibility_after_relax(world, ["C8_due_date"], version_id=vid) is True
# 违反度可从求解日志描述复核
cap_att = next(a for a in report["attributions"] if a["conflictId"] == cap["id"])
assert cap_att["degree"]["kind"] == "overload_minutes"
assert cap_att["degree"]["overloadMinutes"] > 0
assert cap_att["reliefConstraintIds"] == ["C7_capacity", "C8_due_date"]
delay_att = next(
a for a in report["attributions"] if a["conflictType"] == "DELAY")
assert delay_att["degree"]["kind"] == "hours_late"
assert delay_att["degree"]["hoursLate"] == 6.0
def test_feasible_input_returns_empty_iis():
"""可行输入 → 空结果 + 明确中文说明。"""
world = seed_world()
result = _run(world)
_resolve_all(world)
rep = detect_iis(world, version_id=result.versionId)
assert rep["feasible"] is True
assert rep["iis"] == []
assert "可行" in rep["message"]
report = build_attribution_report(world, version_id=result.versionId)
assert report["feasible"] is True
assert report["iis"] == []
assert report["attributions"] == []
assert report["masterControl"] == []
assert "可行" in report["summary"]
def test_auto_enrichment_delay_root_cause_is_capacity():
"""求解日志联合成因:订单延误 + 所在产线当日产能冲突 →
自动把 C7 产能加入 DELAY 的解除约束集合,IIS 指向产能约束。"""
world = seed_world()
result = _run(world)
vid = result.versionId
apply_profile_save(world, {"constraints": {"C8_due_date": {"kind": "hard", "enabled": True}}})
cap = _first_capacity_on(world)
order_no = _production_order_on_capacity_line(world, cap)
for c in world["conflicts"]:
if c["id"] != cap["id"]:
c["isResolved"] = True
delay = _inject_conflict(
world, vid,
conflictType="DELAY", resourceType="TIME", orderNo=order_no,
description=f"{order_no} 完成时间晚于交期 6.0 小时",
suggestedSolution="分流至替代产线或启用加班",
)
relief = relief_constraints(world, delay, version_id=vid)
assert "C8_due_date" in relief
assert "C7_capacity" in relief, "延误且所在产线产能冲突 → 产能应为联合成因"
d = detect_iis(world, version_id=vid)
assert d["feasible"] is False
assert any(set(g["constraintIds"]) == {"C7_capacity"} for g in d["iis"])
# 删除 C7(该 IIS 的唯一成员)→ 恢复可行(联合成因的 DELAY 一并解除)
assert feasibility_after_relax(world, ["C7_capacity"], version_id=vid) is True
# 归因文案点明产能是延误根源
report = build_attribution_report(world, version_id=vid)
delay_att = next(a for a in report["attributions"] if a["conflictType"] == "DELAY")
assert "C7_capacity" in delay_att["reliefConstraintIds"]
assert "产能" in report["masterControl"][0]["conclusion"] or any(
"产能" in m["conclusion"] for m in report["masterControl"])
def test_copy_readability_chinese_fields():
"""文案可读性:中文关键字段(约束名/订单号/资源名/短日期/建议)。"""
world = seed_world()
result = _run(world)
vid = result.versionId
apply_profile_save(world, {"constraints": {"C8_due_date": {"kind": "hard", "enabled": True}}})
cap = _first_capacity_on(world)
order_no = _production_order_on_capacity_line(world, cap)
cap["reliefConstraintIds"] = ["C7_capacity", "C8_due_date"]
for c in world["conflicts"]:
if c["id"] != cap["id"]:
c["isResolved"] = True
_inject_conflict(
world, vid,
conflictType="DELAY", resourceType="TIME", orderNo=order_no,
description=f"{order_no} 完成时间晚于交期 6.0 小时",
suggestedSolution="分流至替代产线或启用加班",
reliefConstraintIds=["C8_due_date", "C7_capacity"],
)
report = build_attribution_report(world, version_id=vid)
group = next(
g for g in report["iis"]
if set(g["constraintIds"]) == {"C7_capacity", "C8_due_date"}
)
iis_text = group["copy"]
assert "约束" in iis_text and "卡死" in iis_text
assert "订单" in iis_text and "资源" in iis_text and "建议" in iis_text
assert "装配产线A" in iis_text # 资源名(中文)
assert order_no in iis_text # 订单号
assert "8/3" in iis_text # 短日期
# 单条归因文案
att_text = report["attributions"][0]["copy"]
assert "约束" in att_text and "被违反" in att_text
assert "涉及订单" in att_text or "资源/对象" in att_text
assert "解开方式" in att_text
# 主控参数结论文案
assert report["masterControl"]
assert any("紧约束" in m["conclusion"] for m in report["masterControl"])
# 文案面向现场计划员:不出现纯数学术语
assert "IIS" not in iis_text and "shadow" not in iis_text.lower()
assert "IIS" not in att_text
def test_attribution_evidence_verifiable_against_solve_log():
"""主控参数结论可由求解日志复核:evidence 携带冲突 ID/版本/描述。"""
world = seed_world()
result = _run(world)
vid = result.versionId
report = build_attribution_report(world, version_id=vid)
assert report["feasible"] is False
conflict_ids = {c["id"] for c in world["conflicts"] if c["versionId"] == vid}
for att in report["attributions"]:
ev = att["evidence"]
assert ev["conflictId"] in conflict_ids
assert ev["versionId"] == vid
assert ev["description"]
assert report["masterControl"]
for m in report["masterControl"]:
assert m["evidenceRefs"], "主控参数结论必须携带可复核的日志证据"
for ref in m["evidenceRefs"]:
cid = int(ref.split("#")[1])
assert cid in conflict_ids
for key, entry in report["evidence"].items():
assert key == f"conflict#{entry['conflictId']}"
assert entry["description"]
# 与 world["conflicts"] 逐条可对照
for att in report["attributions"]:
raw = next(c for c in world["conflicts"] if c["id"] == att["conflictId"])
assert att["description"] == raw["description"]
def test_delete_constraint_restores_publish_gate_for_singleton_iis():
"""默认演示运行产生两组单约束 IIS(C4 维保 / C7 产能):
每组内任删一条即恢复该组可行;硬约束关闭请求被门禁拒绝(矩阵 113),
发布门禁仍同时拦截产能与维保;只有松弛(simulated relax)才能恢复可行。"""
from server.aps_domain.constraints import ConstraintProfileDenied
world = seed_world()
result = _run(world)
vid = result.versionId
d = detect_iis(world, version_id=vid)
assert d["feasible"] is False
groups = {g["constraintIds"][0]: g for g in d["iis"]}
assert "C7_capacity" in groups and "C4_maintenance" in groups
for g in groups.values():
assert len(g["constraintIds"]) == 1
for v in g["verification"]:
assert v["feasibleAfterDelete"] is True
# 物理关闭 C7 被拒(硬约束不可关闭)→ 发布门禁仍拦截 CAPACITY 与 EQUIPMENT
with pytest.raises(ConstraintProfileDenied):
apply_profile_save(world, {"constraints": {"C7_capacity": {"enabled": False}}})
blockers = hard_blocking_conflicts(world, version_id=vid)
assert any(b["constraintId"] == "C7_capacity" for b in blockers)
assert any(b["constraintId"] == "C4_maintenance" for b in blockers)
# 从每个 IIS 各松一条 → 整体可行
assert feasibility_after_relax(
world, ["C7_capacity", "C4_maintenance"], version_id=vid) is True
def test_hard_violations_expose_solve_log_fields():
"""硬违反记录可直接展示并对照求解日志:约束/实体/违反度/证据。"""
world = seed_world()
result = _run(world)
vid = result.versionId
rows = hard_violations(world, version_id=vid)
assert rows
for r in rows:
assert r["constraintId"] in {"C7_capacity", "C4_maintenance"}
assert r["constraintName"]
assert r["evidence"]["conflictId"] == r["conflictId"]
assert r["evidence"]["versionId"] == vid
assert r["degree"]["value"] > 0
capacity = next(r for r in rows if r["constraintId"] == "C7_capacity")
assert capacity["degree"]["kind"] == "overload_minutes"
assert capacity["degree"]["overloadMinutes"] > 0
equipment = next(r for r in rows if r["constraintId"] == "C4_maintenance")
assert equipment["degree"]["kind"] == "overlap_minutes"
assert equipment["orderNos"], "维保冲突应能解析出涉及的订单号"