aps-agent/tests/golden/test_param_opt.py

278 lines
13 KiB
Python
Raw 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.

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