aps-agent/tests/golden/test_param_opt.py

278 lines
13 KiB
Python
Raw Normal View History

# ============================================================
# 参数优化闭环黄金测试(矩阵 87 行 · 方向 E)
# 覆盖:集隔离、回放(沙盒重排)、灰度受限生效、退化自动回滚、
# 验证通过全量生效、敏感性分析衔接(候选方向)
# ============================================================
from __future__ import annotations
import json
import pytest
from server.agent_core.param_opt import ParameterOptimizer, optimize
from server.aps_domain.sensitivity import run_sensitivity
from server.state.seed import seed_world
from server.timeutil import add_minutes, fmt_date, today0
def _tight_world():
"""6 单紧张交期世界:基线(冻结 24h)总延期 > 0,参数方向稳定可复现。
构造口径与 test_params.py 的 _two_orders_world 一致(在演示主数据上
覆盖订单集),qty=300 使单产线满负荷,2/3/4/5 天交期产生延期压力。
"""
w = seed_world()
base = today0()
order_date = fmt_date(base)
product = next(m for m in w["materials"] if m["type"] == "FINISHED_PRODUCT")
sos = []
for i in range(6):
oid = i + 1
level = "VIP" if i % 2 == 0 else "C"
due = fmt_date(add_minutes(base, (2, 2, 3, 3, 4, 5)[i] * 24 * 60))
sos.append({
"id": oid, "orderNo": f"SO-PT-{oid}", "customerId": f"C{oid}",
"customerName": f"客户{level}", "customerLevel": level,
"orderDate": order_date, "deliveryDate": due,
"priority": 5, "manualPriority": None, "status": "CONFIRMED",
"source": "MANUAL", "specialRequirements": "", "totalAmount": 1000,
"isRush": False, "rushStrategy": None, "changes": [],
"createdBy": "test", "createdAt": order_date + " 09:00",
"updatedAt": order_date + " 09:00",
"items": [{
"id": oid * 10, "orderId": oid, "lineNo": 1,
"productId": product["id"], "productName": product["name"],
"productCode": product["code"], "quantity": 300,
"unit": "件", "bomVersion": "V1.0", "routingVersion": "V1.0",
"status": "PENDING", "note": "",
}],
})
w["salesOrders"] = sos
w["scheduleVersions"] = []
w["productionOrders"] = []
w["workOrders"] = []
w["conflicts"] = []
return w
def test_split_orders_isolation():
"""训练/验证集隔离:互斥、并集全量、同种子可复现、不同种子不同切分。"""
w = _tight_world()
opt = ParameterOptimizer(seed=20260802)
s1 = opt.split_orders(w)
s2 = opt.split_orders(w) # 同种子 → 相同切分
assert s1 == s2
assert not (set(s1["train"]) & set(s1["validation"]))
all_ids = {so["id"] for so in w["salesOrders"]}
assert set(s1["train"]) | set(s1["validation"]) == all_ids
assert len(s1["train"]) >= 1 and len(s1["validation"]) >= 1
s3 = opt.split_orders(w, seed=42) # 不同种子 → 不同切分
assert s1["train"] != s3["train"]
def test_replay_sandbox_no_mutation_and_directions():
"""回放:沙盒重排零副作用;改善/退化方向与引擎信号一致;越界参数拒绝。"""
w = _tight_world()
before = json.dumps(w, ensure_ascii=False, sort_keys=True)
opt = ParameterOptimizer()
improved = opt.replay(w, {"freezeWindowHours": 0.0})
assert improved["degraded"] is False
assert improved["deltas"]["tardiness"] < -1 # 冻结 0h 相对 24h 明显改善延期
degraded = opt.replay(w, {"freezeWindowHours": 48.0})
assert degraded["degraded"] is True
assert degraded["deltas"]["tardiness"] > 1 # 冻结 48h 明显恶化延期
after = json.dumps(w, ensure_ascii=False, sort_keys=True)
assert before == after # 回放不写主干
with pytest.raises(ValueError):
opt.replay(w, {"freezeWindowHours": 999.0}) # 越界参数拒绝
with pytest.raises(ValueError):
opt.replay(w, {"unknownKey": 1.0}) # 未知键拒绝
def test_gray_restricted_effect():
"""灰度受限生效:命中作用域的查询才拿到候选值,其余保持基线。"""
w = _tight_world()
opt = ParameterOptimizer()
g = opt.promote_gray(w, {"freezeWindowHours": 0.0}, scope={"lineIds": [1]})
assert g["accepted"] is True
assert g["experiment"]["status"] == "GRAY"
assert opt.active_params(w, {"lineIds": [1]})["freezeWindowHours"] == 0.0
assert opt.active_params(w, {"lineIds": [2]})["freezeWindowHours"] == 24.0
assert opt.active_params(w)["freezeWindowHours"] == 24.0 # 无作用域不扩散
# 灰度阶段不动 scheduleParams(全量写入在 finalize)
assert w["scheduleParams"]["freezeWindowHours"] == 24.0
def test_replay_gate_rejects_degrading_candidate():
"""回放门禁:训练集回放退化的候选在灰度前被拦截,世界零写入。"""
w = _tight_world()
opt = ParameterOptimizer()
g = opt.promote_gray(w, {"freezeWindowHours": 48.0})
assert g["accepted"] is False
assert g["reason"] == "replay-degraded"
assert g["replay"]["degraded"] is True
assert len(w.get("paramExperiments") or []) == 0
def test_rollback_on_degradation():
"""效果退化自动回滚:金丝雀灰度后验证集回放退化 → 自动回滚到上一版本。"""
w = _tight_world()
opt = ParameterOptimizer()
g = opt.promote_gray(w, {"freezeWindowHours": 48.0}, scope={"lineIds": [1]},
require_replay_pass=False)
assert g["accepted"] is True
assert g["experiment"]["trainReplay"]["degraded"] is True
rec = opt.finalize(w, g["experiment"]["id"])
assert rec["status"] == "ROLLED_BACK"
assert rec["rollbackReason"] == "validation-degraded"
assert "validationReplay" in rec and rec["validationReplay"]["degraded"] is True
# 回滚后:受限参数不再生效,基线参数保持
assert opt.active_params(w, {"lineIds": [1]})["freezeWindowHours"] == 24.0
assert w["scheduleParams"]["freezeWindowHours"] == 24.0
assert len(opt.history(w)) == 1
def test_finalize_full_apply_and_manual_rollback():
"""验证通过 → 全量生效(留 before/after 证据);显式回滚恢复上一版本。"""
w = _tight_world()
opt = ParameterOptimizer()
g = opt.promote_gray(w, {"freezeWindowHours": 0.0}, scope={"lineIds": [1]})
rec = opt.finalize(w, g["experiment"]["id"])
assert rec["status"] == "FULL"
assert w["scheduleParams"]["freezeWindowHours"] == 0.0
assert rec["fullApply"]["before"]["freezeWindowHours"] == 24.0
assert rec["fullApply"]["after"]["freezeWindowHours"] == 0.0
rb = opt.rollback(w, g["experiment"]["id"])
assert rb["status"] == "ROLLED_BACK"
assert w["scheduleParams"]["freezeWindowHours"] == 24.0 # 恢复上一参数版本
def test_candidates_from_sensitivity():
"""与敏感性分析衔接:Tornado 产出 → 候选方向,且候选回放不退化。"""
w = _tight_world()
opt = ParameterOptimizer()
report = run_sensitivity(w, strategy="COMPREHENSIVE")
cands = opt.candidates_from_sensitivity(report)
assert cands, "敏感性报告应产出候选"
# 摆幅最大因子优先:冻结窗口(低档 0h 改善延期)
assert cands[0]["factorId"] == "freezeWindowHours"
assert cands[0]["candidate"]["freezeWindowHours"] == 0.0
# 效率因子高档(×1.2)方向
eff = next(c for c in cands if c["factorId"] == "lineEfficiency")
assert eff["candidate"]["efficiencyScale"] == 1.2
# 每个候选按改善方向回放都不退化
for c in cands[:2]:
r = opt.replay(w, c["candidate"])
assert r["degraded"] is False
def test_pipeline_end_to_end():
"""一键闭环:敏感性取方向 → 逐候选灰度→收口,产出实验记录。"""
w = _tight_world()
out = optimize(w, scope={"lineIds": [1]})
assert out["summary"]["total"] >= 3
assert out["summary"]["rolledBack"] == 0 # 敏感性方向候选不应退化
assert out["summary"]["active"] >= 1
assert any(r["stage"] == "full" for r in out["results"])
assert len(w.get("paramExperiments") or []) == out["summary"]["total"]
# ============================================================
# 矩阵 87 补充:线上观测回调单元(观测落盘 / 基线 / 连续劣化自动回滚 / 审计)
# ============================================================
def _activate_full(w, candidate=None):
"""helper: 灰度→finalize 升级为 FULL,返回实验 id。"""
opt = ParameterOptimizer()
cand = candidate or {"freezeWindowHours": 0.0}
g = opt.promote_gray(w, cand)
assert g["accepted"] is True
rec = opt.finalize(w, g["experiment"]["id"])
assert rec["status"] == "FULL"
return g["experiment"]["id"]
def test_observation_persisted_and_self_calibrating_baseline():
"""观测回调:观测落盘 paramObservations;首个观测自校准为生效前基线。"""
w = _tight_world()
opt = ParameterOptimizer()
exp_id = _activate_full(w)
rec0 = w["paramExperiments"][0]
assert rec0["productionBaseline"] is None # 激活时无观测
out = opt.record_observation(w, {"tardiness": 5.0})
assert out["autoRolledBack"] == []
assert len(w["paramObservations"]) == 1
obs = w["paramObservations"][0]
assert obs["kpi"] == {"tardiness": 5.0}
assert obs["evaluations"][0]["experimentId"] == exp_id
assert obs["evaluations"][0]["degraded"] is False # 首个观测即基线,不算劣化
assert rec0["productionBaseline"] == {"tardiness": 5.0}
def test_observation_uses_pre_activation_baseline():
"""激活前已有生产观测:finalize 捕获最近一次观测为生效前基线。"""
w = _tight_world()
opt = ParameterOptimizer()
opt.record_observation(w, {"tardiness": 10.0}) # 生产基线观测(激活前)
g = opt.promote_gray(w, {"freezeWindowHours": 0.0})
rec = opt.finalize(w, g["experiment"]["id"])
assert rec["productionBaseline"] == {"tardiness": 10.0}
assert rec["degradedStreak"] == 0
def test_consecutive_degradation_auto_rollback_with_audit():
"""连续劣化自动回滚:streak 达阈值 → ROLLED_BACK + 恢复基线参数 + 审计。"""
w = _tight_world()
opt = ParameterOptimizer(max_consecutive_degraded=2)
exp_id = _activate_full(w)
opt.record_observation(w, {"tardiness": 10.0}) # 基线自校准
opt.record_observation(w, {"tardiness": 50.0}) # 劣化 1
rec = w["paramExperiments"][0]
assert rec["degradedStreak"] == 1
assert rec["status"] == "FULL"
out = opt.record_observation(w, {"tardiness": 80.0}) # 劣化 2 → 回滚
assert out["autoRolledBack"] == [exp_id]
rec = w["paramExperiments"][0]
assert rec["status"] == "ROLLED_BACK"
assert rec["rollbackReason"] == "production-degraded"
assert rec["autoRollback"]["degradedStreak"] == 2
assert w["scheduleParams"]["freezeWindowHours"] == 24.0 # 恢复上一版本
events = [e for e in w["auditEvents"]
if e["action"] == "param.experiment.auto_rolled_back"]
assert len(events) == 1
assert events[0]["target"] == {"type": "PARAM_EXPERIMENT", "id": exp_id}
assert events[0]["power"] == "P1"
assert events[0]["rationale"]["degradedStreak"] == 2
assert events[0]["rationale"]["reason"] == "production-degraded"
assert len(w["paramObservations"]) == 3
def test_observation_recovery_resets_streak():
"""观测恢复:非劣化观测清零连败计数,不触发回滚。"""
w = _tight_world()
opt = ParameterOptimizer(max_consecutive_degraded=2)
_activate_full(w)
opt.record_observation(w, {"tardiness": 10.0})
opt.record_observation(w, {"tardiness": 60.0}) # 劣化 1
opt.record_observation(w, {"tardiness": 8.0}) # 恢复 → 清零
rec = w["paramExperiments"][0]
assert rec["degradedStreak"] == 0
assert rec["status"] == "FULL"
assert w["scheduleParams"]["freezeWindowHours"] == 0.0
assert [e["action"] for e in w["auditEvents"]] == [] # 无自动回滚审计
assert len(w["paramObservations"]) == 3
def test_observation_monitors_only_active_full():
"""只监控 ACTIVE/FULL:GRAY 与已回滚实验不参与观测评估。"""
w = _tight_world()
opt = ParameterOptimizer()
g = opt.promote_gray(w, {"freezeWindowHours": 0.0}) # GRAY
out = opt.record_observation(w, {"tardiness": 10.0})
assert out["observation"]["evaluations"] == []
assert w["paramExperiments"][0]["status"] == "GRAY"
opt.finalize(w, g["experiment"]["id"]) # → FULL
opt.rollback(w, g["experiment"]["id"]) # → ROLLED_BACK
out2 = opt.record_observation(w, {"tardiness": 100.0})
assert out2["observation"]["evaluations"] == []
assert out2["autoRolledBack"] == []