aps-agent/tests/golden/test_attribution.py

297 lines
13 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.

# ============================================================
# 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"], "维保冲突应能解析出涉及的订单号"