278 lines
13 KiB
Python
278 lines
13 KiB
Python
# ============================================================
|
||
# 参数优化闭环黄金测试(矩阵 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"] == []
|