aps-agent/tests/golden/test_param_opt_gateway.py

340 lines
15 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 行 · 方向 W)
# 覆盖:端点契约 / 优化触发(split→回放→候选) /
# P2 确认卡 promote(批准升级、拒绝留 GRAY) /
# 手动回滚 P1 / 线上观测劣化自动回滚+审计
# ============================================================
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from server.agent_core import harness
from server.agent_core.param_opt import ParameterOptimizer
from server.auth.context import bind_identity, reset_identity
from server.state.seed import seed_world
from server.timeutil import add_minutes, fmt_date, today0
def _tight_world():
"""6 单紧张交期世界(与 test_param_opt 同构)。"""
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
class _WorldStore:
"""测试用世界存储:与确认卡记录的租户/世界键匹配。"""
def __init__(self, world: dict, *, tenant_uuid: str = "tenant-param-opt",
world_key: str = "personal-1001"):
self.tenant_uuid = tenant_uuid
self.world_key = world_key
self.data = world
def next_id(self, _kind: str) -> int:
return len(self.data.get("auditEvents") or []) + 1
def save(self) -> None:
pass
class _CheckpointStore:
def __init__(self) -> None:
self.pairs: list[dict] = []
def create(self, *_args, **_kwargs) -> dict:
pair = {"pairId": f"pair-{len(self.pairs) + 1}"}
self.pairs.append(pair)
return pair
class _ProjectStore:
def active_world_key(self) -> str:
return "default"
def require_active_write(self) -> None:
pass
@pytest.fixture(autouse=True)
def _clear_pending():
harness._approval_store.clear()
yield
harness._approval_store.clear()
@pytest.fixture
def client_factory(monkeypatch):
"""返回 (client, store, provider);用测试认证代替 JMS。"""
import server.gateway.app as gateway_module
import server.state.projects as projects_module
from tests.auth_provider import install_test_auth
provider = install_test_auth(monkeypatch, "tenant-param-opt")
def factory(world: dict | None = None):
store = _WorldStore(world if world is not None else _tight_world())
monkeypatch.setattr(gateway_module, "get_store", lambda: store)
monkeypatch.setattr(gateway_module, "get_checkpoints", lambda: _CheckpointStore())
monkeypatch.setattr(projects_module, "get_project_store", lambda: _ProjectStore())
client = TestClient(gateway_module.create_app())
login = client.post("/api/auth/login", json={"username": "planner", "password": "test"})
assert login.status_code == 200, login.text
return client, store, provider
return factory
def _as(identity, callback):
token = bind_identity(identity)
try:
return callback()
finally:
reset_identity(token)
def _audit_actions(world: dict) -> list[str]:
return [e.get("action") for e in world.get("auditEvents") or []]
# ---------------- 端点契约 / 优化触发 ----------------
def test_params_experiments_contract_empty(client_factory):
"""GET /api/params/experiments 契约:无实验时返回空列表。"""
client, _, _ = client_factory()
resp = client.get("/api/params/experiments")
assert resp.status_code == 200, resp.text
assert resp.json() == {"experiments": []}
def test_params_optimize_creates_gray_experiments_and_audits(client_factory):
"""POST /api/params/optimize:split→回放→候选,产出 GRAY 实验且审计 param.optimize。"""
client, store, _ = client_factory()
resp = client.post("/api/params/optimize", json={
"sessionId": "opt-session",
"strategy": "COMPREHENSIVE",
"scope": {"lineIds": [1]},
"candidates": [{"freezeWindowHours": 0.0}, {"freezeWindowHours": 48.0}],
})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["total"] == 2
assert data["summary"]["gray"] == 1 # freeze 48h 训练回放退化被拒绝
assert data["summary"]["rejected"] == 1
gray = data["results"][0]
assert gray["stage"] == "gray" and gray["experimentId"]
exp = next(e for e in store.data["paramExperiments"]
if e["id"] == gray["experimentId"])
assert exp["status"] == "GRAY"
assert exp["scope"] == {"lineIds": [1]}
assert "split" in exp and "trainReplay" in exp # 集隔离与回放证据均在
assert "param.optimize" in _audit_actions(store.data)
# 实验列表可查
resp2 = client.get("/api/params/experiments")
assert len(resp2.json()["experiments"]) == 1
assert resp2.json()["experiments"][0]["id"] == exp["id"]
# 主干参数未动(灰度不全量写入)
assert store.data["scheduleParams"]["freezeWindowHours"] == 24.0
def test_params_optimize_default_candidates_from_sensitivity(client_factory):
"""默认候选:敏感性分析方向→候选→回放→GRAY(闭环驱动)。"""
client, store, _ = client_factory()
resp = client.post("/api/params/optimize", json={"sessionId": "opt2"})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["summary"]["total"] >= 3
assert data["summary"]["gray"] >= 1
assert data["summary"]["rejected"] == 0
assert len(store.data.get("paramExperiments") or []) == data["summary"]["total"]
# ---------------- P2 确认卡 promote ----------------
def test_promote_stages_confirm_card_and_approve_activates(client_factory):
"""promote 经 P2 门禁:出卡后仍为 GRAY;批准后验证回放通过 → FULL+全量生效。"""
world = _tight_world()
opt = ParameterOptimizer()
g = opt.promote_gray(world, {"freezeWindowHours": 0.0})
exp_id = g["experiment"]["id"]
client, _, provider = client_factory(world)
resp = client.post(f"/api/params/experiments/{exp_id}/promote",
json={"sessionId": "promote-session"})
assert resp.status_code == 200, resp.text
data = resp.json()
block = data["block"]
assert block["type"] == "confirm-card"
confirm_id = block["props"]["confirmId"]
assert data["message"].find("P2") >= 0
# 卡面文案含实验摘要/候选参数/回放 KPI
card_lines = "\n".join(block["props"].get("summary") or [])
assert "freezeWindowHours=0.0" in card_lines
assert "GRAY" in card_lines and "KPI" in card_lines
assert "GRAY→ACTIVE" in data["message"] or "升级" in data["message"]
assert world["paramExperiments"][0]["status"] == "GRAY" # 确认前不动
assert "param.promote.stage" in _audit_actions(world)
# 批准执行(单次确认,P2 不需二次确认)
resp2 = client.post("/api/actions/confirm",
json={"sessionId": "promote-session", "confirmId": confirm_id,
"approve": True, "note": "同意升级"})
assert resp2.status_code == 200, resp2.text
assert resp2.json()["secondConfirmRequired"] is False
rec = world["paramExperiments"][0]
assert rec["status"] == "FULL"
assert world["scheduleParams"]["freezeWindowHours"] == 0.0
assert rec["validationReplay"]["degraded"] is False
assert rec["productionBaseline"] is None # 激活时无观测,等首个观测自校准
audits = _audit_actions(world)
assert "param.promote" in audits
assert "param.promote.stage" in audits
promote_evt = next(e for e in world["auditEvents"] if e["action"] == "param.promote")
assert promote_evt["power"] == "P2"
assert promote_evt["rationale"]["confirmId"] == confirm_id
# 批准历史记录 note
identity = provider._identity("planner")
history = _as(identity, harness.list_approval_history)
assert history[-1]["note"] == "同意升级"
assert history[-1]["status"] == "APPROVED"
def test_promote_reject_keeps_gray(client_factory):
"""拒绝则留在 GRAY:无任何世界写入,审计 param.experiment.promote.reject。"""
world = _tight_world()
opt = ParameterOptimizer()
g = opt.promote_gray(world, {"freezeWindowHours": 0.0})
exp_id = g["experiment"]["id"]
client, _, _ = client_factory(world)
resp = client.post(f"/api/params/experiments/{exp_id}/promote", json={"sessionId": "s"})
confirm_id = resp.json()["block"]["props"]["confirmId"]
resp2 = client.post("/api/actions/confirm",
json={"sessionId": "s", "confirmId": confirm_id, "approve": False})
assert resp2.status_code == 200, resp2.text
assert "拒绝" in resp2.json()["message"] or "取消" in resp2.json()["message"] or "DENIED" in resp2.json()["message"].upper() or "驳回" in resp2.json()["message"]
rec = world["paramExperiments"][0]
assert rec["status"] == "GRAY" # 留在 GRAY
assert world["scheduleParams"]["freezeWindowHours"] == 24.0
assert "param.experiment.promote.reject" in _audit_actions(world)
def test_promote_unknown_or_non_gray_rejected(client_factory):
"""契约保护:不存在实验 / 非 GRAY 实验不出卡。"""
client, store, _ = client_factory()
resp = client.post("/api/params/experiments/px-none/promote", json={"sessionId": "s"})
assert resp.status_code == 200 and "error" in resp.json()
# 已升级实验不可再提交
world = store.data
opt = ParameterOptimizer()
g = opt.promote_gray(world, {"freezeWindowHours": 0.0})
rec = opt.finalize(world, g["experiment"]["id"])
assert rec["status"] == "FULL"
resp2 = client.post(f"/api/params/experiments/{g['experiment']['id']}/promote",
json={"sessionId": "s"})
assert "error" in resp2.json()
# ---------------- 手动回滚(P1) ----------------
def test_manual_rollback_p1_direct(client_factory):
"""手动回滚 P1:直接执行,恢复上一参数版本,审计 param.rollback。"""
world = _tight_world()
opt = ParameterOptimizer()
g = opt.promote_gray(world, {"freezeWindowHours": 0.0})
exp_id = g["experiment"]["id"]
opt.finalize(world, exp_id) # → FULL,freeze=0.0
assert world["scheduleParams"]["freezeWindowHours"] == 0.0
client, _, _ = client_factory(world)
resp = client.post(f"/api/params/experiments/{exp_id}/rollback",
json={"sessionId": "s", "reason": "人工干预"})
assert resp.status_code == 200, resp.text
rec = world["paramExperiments"][0]
assert rec["status"] == "ROLLED_BACK"
assert rec["rollbackReason"] == "人工干预"
assert world["scheduleParams"]["freezeWindowHours"] == 24.0 # 恢复上一版本
evt = next(e for e in world["auditEvents"] if e["action"] == "param.rollback")
assert evt["power"] == "P1"
assert evt["rationale"]["reason"] == "人工干预"
# ---------------- 线上观测驱动回滚监控 ----------------
def test_observation_degradation_auto_rollback_via_endpoint(client_factory):
"""观测回调:连续劣化达阈值自动回滚+审计,观测数据落盘 paramObservations。"""
world = _tight_world()
opt = ParameterOptimizer(max_consecutive_degraded=2)
g = opt.promote_gray(world, {"freezeWindowHours": 0.0})
exp_id = g["experiment"]["id"]
opt.finalize(world, exp_id) # → FULL
client, _, _ = client_factory(world)
# 基线自校准(首个观测)
r1 = client.post("/api/params/observations", json={"sessionId": "obs", "kpi": {"tardiness": 10.0}})
assert r1.status_code == 200, r1.text
assert r1.json()["autoRolledBack"] == []
# 劣化 1
r2 = client.post("/api/params/observations", json={"sessionId": "obs", "kpi": {"tardiness": 60.0}})
assert r2.status_code == 200, r2.text
assert r2.json()["autoRolledBack"] == []
assert world["paramExperiments"][0]["degradedStreak"] == 1
# 劣化 2 → 自动回滚
r3 = client.post("/api/params/observations", json={"sessionId": "obs", "kpi": {"tardiness": 90.0}})
assert r3.status_code == 200, r3.text
assert r3.json()["autoRolledBack"] == [exp_id]
rec = world["paramExperiments"][0]
assert rec["status"] == "ROLLED_BACK"
assert rec["rollbackReason"] == "production-degraded"
assert world["scheduleParams"]["freezeWindowHours"] == 24.0
# 观测数据落盘
assert len(world.get("paramObservations") or []) == 3
assert world["paramObservations"][-1]["kpi"] == {"tardiness": 90.0}
# 审计
evts = [e for e in world["auditEvents"]
if e["action"] == "param.experiment.auto_rolled_back"]
assert len(evts) == 1
assert evts[0]["target"] == {"type": "PARAM_EXPERIMENT", "id": exp_id}
assert evts[0]["power"] == "P1"
assert evts[0]["rationale"]["degradedStreak"] == 2
assert evts[0]["rationale"]["observationId"] == world["paramObservations"][-1]["id"]
def test_observation_endpoint_configurable_threshold(client_factory):
"""可配置连续劣化阈值:maxConsecutiveDegraded=1 单次劣化即回滚。"""
world = _tight_world()
opt = ParameterOptimizer()
g = opt.promote_gray(world, {"freezeWindowHours": 0.0})
exp_id = g["experiment"]["id"]
opt.finalize(world, exp_id)
client, _, _ = client_factory(world)
r1 = client.post("/api/params/observations", json={"sessionId": "obs", "kpi": {"tardiness": 10.0}})
assert r1.status_code == 200
r2 = client.post("/api/params/observations",
json={"sessionId": "obs", "kpi": {"tardiness": 70.0},
"maxConsecutiveDegraded": 1})
assert r2.status_code == 200, r2.text
assert r2.json()["autoRolledBack"] == [exp_id]
assert world["paramExperiments"][0]["status"] == "ROLLED_BACK"