628 lines
31 KiB
Python
628 lines
31 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 参数优化闭环(moduleId: core-param-opt, 可重生 ✅)
|
|||
|
|
# plan.md §9.8:候选参数 → 黄金算例集回放(沙盒重排)→ 灰度(受限生效)
|
|||
|
|
# → 验证 → 效果退化自动回滚;与 domain-sensitivity(SC-06)衔接,用其
|
|||
|
|
# Tornado 产出推导候选方向。
|
|||
|
|
# 边界说明:本模块是库层领域操作(矩阵 87 行)。真实生产写入须经门禁
|
|||
|
|
# harness P2 审批(本模块不接网关),但所有写操作均留 before/after 证据,
|
|||
|
|
# 供调用方(harness/gateway)串进证据链。全部评测跑沙盒深拷贝,不污染主干。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import random
|
|||
|
|
import time
|
|||
|
|
import uuid
|
|||
|
|
from copy import deepcopy
|
|||
|
|
from typing import Any, ClassVar
|
|||
|
|
|
|||
|
|
from server.aps_domain.params import get_schedule_params
|
|||
|
|
from server.aps_domain.sensitivity import _run_sandbox, run_sensitivity
|
|||
|
|
from server.timeutil import add_minutes, fmt_date, today0
|
|||
|
|
|
|||
|
|
World = dict[str, Any]
|
|||
|
|
|
|||
|
|
# 默认随机种子(训练/验证集切分与复现)
|
|||
|
|
DEFAULT_SEED = 20260802
|
|||
|
|
# 默认退化容忍度:候选 KPI 相对基线恶化超过该比例即判定退化
|
|||
|
|
DEFAULT_TOLERANCE = 0.02
|
|||
|
|
# 默认连续劣化阈值:线上观测连续 N 次劣化即自动回滚实验(矩阵 87)
|
|||
|
|
DEFAULT_MAX_CONSECUTIVE_DEGRADED = 2
|
|||
|
|
# RULE 引擎策略模板(候选参数可调)
|
|||
|
|
STRATEGIES = (
|
|||
|
|
"COMPREHENSIVE", "DELIVERY_FIRST", "FIFO", "CAPACITY_BALANCE",
|
|||
|
|
"CHANGEOVER_MIN", "COST_FIRST", "CAMPAIGN",
|
|||
|
|
)
|
|||
|
|
# 候选参数取值范围(越界即拒绝,防止把排产参数调出物理合理域)
|
|||
|
|
_CANDIDATE_BOUNDS: dict[str, tuple[float, float]] = {
|
|||
|
|
"planningHorizonDays": (1.0, 90.0),
|
|||
|
|
"deliveryBufferRatio": (0.5, 2.0),
|
|||
|
|
"freezeWindowHours": (0.0, 168.0),
|
|||
|
|
"efficiencyScale": (0.5, 1.5),
|
|||
|
|
}
|
|||
|
|
_LEVELS = ("VIP", "A", "B", "C")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _default_start() -> str:
|
|||
|
|
"""评测起点(与敏感性分析一致:明天 0 点)。"""
|
|||
|
|
return fmt_date(add_minutes(today0(), 24 * 60))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_degraded(baseline_kpi: dict[str, float], candidate_kpi: dict[str, float],
|
|||
|
|
tolerance: float) -> bool:
|
|||
|
|
"""退化判定:主 KPI 为总延期;基线零延期时任何明显延期即退化。"""
|
|||
|
|
base = float((baseline_kpi or {}).get("tardiness") or 0.0)
|
|||
|
|
cand = float((candidate_kpi or {}).get("tardiness") or 0.0)
|
|||
|
|
if base <= 1e-9:
|
|||
|
|
return cand > tolerance
|
|||
|
|
return cand > base * (1.0 + tolerance)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _scope_matches(exp_scope: dict[str, Any], query_scope: dict[str, Any] | None) -> bool:
|
|||
|
|
"""受限生效判定:实验作用域与查询作用域按维度求交集。
|
|||
|
|
|
|||
|
|
- 实验作用域为空 → 全局生效(任何查询都命中)
|
|||
|
|
- 查询作用域为空 → 只命中全局实验(不把受限实验扩散到全量)
|
|||
|
|
- 否则逐维度(lineIds/orderIds/customerLevels)要求有交集
|
|||
|
|
"""
|
|||
|
|
if not exp_scope:
|
|||
|
|
return True
|
|||
|
|
if not query_scope:
|
|||
|
|
return False
|
|||
|
|
for key, values in exp_scope.items():
|
|||
|
|
qv = query_scope.get(key)
|
|||
|
|
if not qv:
|
|||
|
|
return False
|
|||
|
|
if not set(values) & set(qv):
|
|||
|
|
return False
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ParameterOptimizer:
|
|||
|
|
"""参数优化闭环:训练/验证集隔离 → 回放 → 灰度 → 验证 → 退化回滚。"""
|
|||
|
|
|
|||
|
|
def __init__(self, *, seed: int = DEFAULT_SEED,
|
|||
|
|
tolerance: float = DEFAULT_TOLERANCE,
|
|||
|
|
strategy: str = "COMPREHENSIVE",
|
|||
|
|
max_consecutive_degraded: int = DEFAULT_MAX_CONSECUTIVE_DEGRADED) -> None:
|
|||
|
|
if strategy not in STRATEGIES:
|
|||
|
|
raise ValueError(f"strategy 须为 {STRATEGIES}")
|
|||
|
|
self.seed = int(seed)
|
|||
|
|
self.tolerance = float(tolerance)
|
|||
|
|
self.strategy = strategy
|
|||
|
|
self.max_consecutive_degraded = max(1, int(max_consecutive_degraded))
|
|||
|
|
|
|||
|
|
# ---------------- 训练/验证集隔离 ----------------
|
|||
|
|
def split_orders(self, world: World, *, train_ratio: float = 0.7,
|
|||
|
|
seed: int | None = None) -> dict[str, Any]:
|
|||
|
|
"""把可排订单切成互斥的训练/验证子集(确定性种子,可复现)。
|
|||
|
|
|
|||
|
|
隔离意义:回放选参只看训练子集,验证子集是"没见过的订单",
|
|||
|
|
防止候选参数过拟合到特定订单集(§9.8 黄金算例集回放评测口径)。
|
|||
|
|
"""
|
|||
|
|
ids = [so["id"] for so in world.get("salesOrders") or []
|
|||
|
|
if so.get("status") not in ("CANCELLED", "COMPLETED")]
|
|||
|
|
if not ids:
|
|||
|
|
raise ValueError("世界中没有可排订单,无法切分训练/验证集")
|
|||
|
|
rng = random.Random(int(seed) if seed is not None else self.seed)
|
|||
|
|
shuffled = ids[:]
|
|||
|
|
rng.shuffle(shuffled)
|
|||
|
|
n = round(len(shuffled) * train_ratio)
|
|||
|
|
n = max(1, min(n, len(shuffled) - 1)) # 保证验证集至少 1 单(多单时)
|
|||
|
|
return {
|
|||
|
|
"train": sorted(shuffled[:n]),
|
|||
|
|
"validation": sorted(shuffled[n:]),
|
|||
|
|
"trainRatio": train_ratio,
|
|||
|
|
"seed": int(seed) if seed is not None else self.seed,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ---------------- 候选参数校验与映射 ----------------
|
|||
|
|
def _validate_candidate(self, candidate: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""校验候选参数(键白名单 + 值域),返回规范化副本。"""
|
|||
|
|
if not isinstance(candidate, dict) or not candidate:
|
|||
|
|
raise ValueError("候选参数必须是非空 dict")
|
|||
|
|
out: dict[str, Any] = {}
|
|||
|
|
for key, value in candidate.items():
|
|||
|
|
if key == "customerLevelWeights":
|
|||
|
|
if not isinstance(value, dict) or not value:
|
|||
|
|
raise ValueError("customerLevelWeights 必须是非空 dict")
|
|||
|
|
levels: dict[str, float] = {}
|
|||
|
|
for k, v in value.items():
|
|||
|
|
lv = str(k).upper()
|
|||
|
|
if lv not in _LEVELS:
|
|||
|
|
raise ValueError(f"不支持的客户等级:{k}")
|
|||
|
|
try:
|
|||
|
|
num = float(v)
|
|||
|
|
except (TypeError, ValueError) as exc:
|
|||
|
|
raise ValueError(f"{lv} 权重必须是数字") from exc
|
|||
|
|
if num < 0 or num > 100:
|
|||
|
|
raise ValueError(f"{lv} 权重须在 0~100")
|
|||
|
|
levels[lv] = num
|
|||
|
|
out["customerLevelWeights"] = levels
|
|||
|
|
elif key == "strategy":
|
|||
|
|
if str(value).upper() not in STRATEGIES:
|
|||
|
|
raise ValueError(f"strategy 须为 {STRATEGIES}")
|
|||
|
|
out["strategy"] = str(value).upper()
|
|||
|
|
elif key in _CANDIDATE_BOUNDS:
|
|||
|
|
try:
|
|||
|
|
num = float(value)
|
|||
|
|
except (TypeError, ValueError) as exc:
|
|||
|
|
raise ValueError(f"{key} 必须是数字") from exc
|
|||
|
|
lo, hi = _CANDIDATE_BOUNDS[key]
|
|||
|
|
if num < lo or num > hi:
|
|||
|
|
raise ValueError(f"{key} 须在 {lo}~{hi}")
|
|||
|
|
out[key] = int(num) if key == "planningHorizonDays" else num
|
|||
|
|
else:
|
|||
|
|
raise ValueError(f"不支持的候选参数键:{key}")
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
def _candidate_to_kwargs(self, candidate: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""候选参数 → 沙盒重排 kwargs(对齐 domain-sensitivity 的 _run_sandbox)。"""
|
|||
|
|
kw: dict[str, Any] = {}
|
|||
|
|
if "planningHorizonDays" in candidate:
|
|||
|
|
kw["horizon"] = int(candidate["planningHorizonDays"])
|
|||
|
|
if "deliveryBufferRatio" in candidate:
|
|||
|
|
kw["delivery_buffer"] = float(candidate["deliveryBufferRatio"])
|
|||
|
|
if "freezeWindowHours" in candidate:
|
|||
|
|
kw["freeze_hours"] = float(candidate["freezeWindowHours"])
|
|||
|
|
if "efficiencyScale" in candidate:
|
|||
|
|
kw["efficiency_scale"] = float(candidate["efficiencyScale"])
|
|||
|
|
if "customerLevelWeights" in candidate:
|
|||
|
|
kw["vip_weight"] = float(candidate["customerLevelWeights"].get("VIP", 3.0))
|
|||
|
|
if "strategy" in candidate:
|
|||
|
|
kw["strategy"] = candidate["strategy"]
|
|||
|
|
return kw
|
|||
|
|
|
|||
|
|
# ---------------- 回放(沙盒重排) ----------------
|
|||
|
|
def _sandbox_kpi(self, world: World, *, order_ids: list[int] | None = None,
|
|||
|
|
strategy: str | None = None,
|
|||
|
|
candidate: dict[str, Any] | None = None) -> dict[str, float]:
|
|||
|
|
"""深拷贝沙盒内按候选参数重排,返回 KPI(不写主干;PO/WO 清空重排)。"""
|
|||
|
|
sandbox = deepcopy(world)
|
|||
|
|
if order_ids:
|
|||
|
|
wanted = set(order_ids)
|
|||
|
|
sandbox["salesOrders"] = [so for so in sandbox["salesOrders"] if so["id"] in wanted]
|
|||
|
|
sp = get_schedule_params(world)
|
|||
|
|
kwargs: dict[str, Any] = {
|
|||
|
|
"strategy": strategy or self.strategy,
|
|||
|
|
"horizon": int(sp.get("planningHorizonDays") or 14),
|
|||
|
|
"vip_weight": float((sp.get("customerLevelWeights") or {}).get("VIP") or 3.0),
|
|||
|
|
"delivery_buffer": float(sp.get("deliveryBufferRatio") or 0.95),
|
|||
|
|
"freeze_hours": float(sp.get("freezeWindowHours") or 0.0),
|
|||
|
|
"efficiency_scale": float(sp.get("efficiencyScale") or 1.0),
|
|||
|
|
}
|
|||
|
|
if candidate:
|
|||
|
|
kwargs.update(self._candidate_to_kwargs(candidate))
|
|||
|
|
return _run_sandbox(sandbox, start=_default_start(), **kwargs)
|
|||
|
|
|
|||
|
|
def replay(self, world: World, candidate: dict[str, Any],
|
|||
|
|
*, order_ids: list[int] | None = None,
|
|||
|
|
strategy: str | None = None) -> dict[str, Any]:
|
|||
|
|
"""候选参数回放(沙盒重排):基线 vs 候选 KPI 对照,含退化标记。
|
|||
|
|
|
|||
|
|
幂等、无副作用:任何情况下都不修改传入的 world。
|
|||
|
|
"""
|
|||
|
|
candidate = self._validate_candidate(candidate)
|
|||
|
|
baseline = self._sandbox_kpi(world, order_ids=order_ids, strategy=strategy)
|
|||
|
|
cand_kpi = self._sandbox_kpi(world, order_ids=order_ids, strategy=strategy,
|
|||
|
|
candidate=candidate)
|
|||
|
|
degraded = _is_degraded(baseline, cand_kpi, self.tolerance)
|
|||
|
|
return {
|
|||
|
|
"candidate": candidate,
|
|||
|
|
"orderIds": sorted(order_ids) if order_ids else "all",
|
|||
|
|
"baselineKpi": baseline,
|
|||
|
|
"candidateKpi": cand_kpi,
|
|||
|
|
"deltas": {k: round(float(cand_kpi.get(k, 0)) - float(baseline.get(k, 0)), 4)
|
|||
|
|
for k in ("tardiness", "conflicts", "utilization", "changeoverMin")},
|
|||
|
|
"degraded": degraded,
|
|||
|
|
"improved": not degraded,
|
|||
|
|
"sandbox": True,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ---------------- 与敏感性分析衔接 ----------------
|
|||
|
|
# 敏感性 Tornado 行只回传 low/high 的 KPI(不携带 kwargs),候选取值由本表
|
|||
|
|
# 按因子对齐 SC-06 的扰动档位;planningHorizonDays 依 baselineValue 推导。
|
|||
|
|
_FACTOR_LEVELS: ClassVar[dict[str, dict[str, Any]]] = {
|
|||
|
|
"planningHorizonDays": {"low": lambda b: max(3, int(b) // 2),
|
|||
|
|
"high": lambda b: min(60, int(b) * 2)},
|
|||
|
|
"vipWeight": {"low": 1.0, "high": 10.0},
|
|||
|
|
"deliveryBufferRatio": {"low": 0.85, "high": 1.0},
|
|||
|
|
"freezeWindowHours": {"low": 0.0, "high": 48.0},
|
|||
|
|
"lineEfficiency": {"low": 0.8, "high": 1.2},
|
|||
|
|
}
|
|||
|
|
_FACTOR_CANDIDATE_KEYS: ClassVar[dict[str, str]] = {
|
|||
|
|
"planningHorizonDays": "planningHorizonDays",
|
|||
|
|
"vipWeight": "customerLevelWeights",
|
|||
|
|
"deliveryBufferRatio": "deliveryBufferRatio",
|
|||
|
|
"freezeWindowHours": "freezeWindowHours",
|
|||
|
|
"lineEfficiency": "efficiencyScale",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def candidates_from_sensitivity(self, report: dict[str, Any], *,
|
|||
|
|
limit: int = 5) -> list[dict[str, Any]]:
|
|||
|
|
"""把敏感性 Tornado 产出转成候选参数(按摆幅降序,取改善方向)。
|
|||
|
|
|
|||
|
|
对每个因子选 tardiness 更低的那一档作为候选方向;摆幅为 0 的
|
|||
|
|
因子(无影响)不产生候选。返回 [{candidate, factorId, swing, direction}]。
|
|||
|
|
"""
|
|||
|
|
out: list[dict[str, Any]] = []
|
|||
|
|
for row in report.get("rows") or []:
|
|||
|
|
factor_id = row.get("factorId")
|
|||
|
|
swing = float(row.get("swing") or 0.0)
|
|||
|
|
if not factor_id or swing <= 1e-9:
|
|||
|
|
continue
|
|||
|
|
low_tard = float((row.get("low") or {}).get("tardiness") or 0.0)
|
|||
|
|
high_tard = float((row.get("high") or {}).get("tardiness") or 0.0)
|
|||
|
|
direction = "high" if high_tard < low_tard else "low"
|
|||
|
|
level = self._FACTOR_LEVELS.get(factor_id)
|
|||
|
|
if level is None:
|
|||
|
|
continue
|
|||
|
|
spec = level[direction]
|
|||
|
|
value = spec(float(row.get("baselineValue") or 0)) if callable(spec) else spec
|
|||
|
|
key = self._FACTOR_CANDIDATE_KEYS[factor_id]
|
|||
|
|
candidate = {key: {"VIP": float(value)} if key == "customerLevelWeights" else value}
|
|||
|
|
out.append({
|
|||
|
|
"candidate": candidate,
|
|||
|
|
"factorId": factor_id,
|
|||
|
|
"swing": round(swing, 2),
|
|||
|
|
"direction": direction,
|
|||
|
|
"baselineValue": row.get("baselineValue"),
|
|||
|
|
})
|
|||
|
|
out.sort(key=lambda x: x["swing"], reverse=True)
|
|||
|
|
return out[:max(1, int(limit))]
|
|||
|
|
|
|||
|
|
# ---------------- 灰度(受限生效) ----------------
|
|||
|
|
def promote_gray(self, world: World, candidate: dict[str, Any],
|
|||
|
|
scope: dict[str, Any] | None = None, *,
|
|||
|
|
order_ids: list[int] | None = None,
|
|||
|
|
strategy: str | None = None,
|
|||
|
|
require_replay_pass: bool = True) -> dict[str, Any]:
|
|||
|
|
"""候选参数灰度放行:先回放(默认要求训练集不退化),再受限生效。
|
|||
|
|
|
|||
|
|
- require_replay_pass=True(默认):训练集回放退化即拒绝,不写世界。
|
|||
|
|
- require_replay_pass=False:金丝雀场景,允许受限范围试错;
|
|||
|
|
退化风险由 finalize 的验证回放兜底(效果退化自动回滚)。
|
|||
|
|
灰度只写 paramExperiments 记录(作用域受限),不动 scheduleParams;
|
|||
|
|
全量生效在 finalize(promote_full=True) 阶段完成。
|
|||
|
|
"""
|
|||
|
|
candidate = self._validate_candidate(candidate)
|
|||
|
|
if order_ids is None:
|
|||
|
|
split = self.split_orders(world)
|
|||
|
|
else:
|
|||
|
|
split = {"train": sorted(set(order_ids)), "validation": [], "seed": None}
|
|||
|
|
train_replay = self.replay(world, candidate, order_ids=split["train"],
|
|||
|
|
strategy=strategy)
|
|||
|
|
if require_replay_pass and train_replay["degraded"]:
|
|||
|
|
return {"accepted": False, "reason": "replay-degraded",
|
|||
|
|
"replay": train_replay}
|
|||
|
|
experiment: dict[str, Any] = {
|
|||
|
|
"id": "px-" + uuid.uuid4().hex[:8],
|
|||
|
|
"candidate": candidate,
|
|||
|
|
"scope": dict(scope or {}),
|
|||
|
|
"status": "GRAY",
|
|||
|
|
"strategy": strategy or self.strategy,
|
|||
|
|
"tolerance": self.tolerance,
|
|||
|
|
"createdAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|||
|
|
"split": split,
|
|||
|
|
"trainReplay": train_replay,
|
|||
|
|
"prevParams": deepcopy(world.get("scheduleParams")),
|
|||
|
|
"baselineParams": get_schedule_params(world),
|
|||
|
|
"evidence": {
|
|||
|
|
"sandbox": True,
|
|||
|
|
"note": "回放证据见 trainReplay/validationReplay;"
|
|||
|
|
"生效写入应经门禁 harness P2 审批后调用(矩阵 87 行)。",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
world.setdefault("paramExperiments", []).append(experiment)
|
|||
|
|
return {"accepted": True, "experiment": experiment}
|
|||
|
|
|
|||
|
|
def get_experiment(self, world: World, experiment_id: str) -> dict[str, Any] | None:
|
|||
|
|
for rec in world.get("paramExperiments") or []:
|
|||
|
|
if rec.get("id") == experiment_id:
|
|||
|
|
return rec
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def active_params(self, world: World, scope: dict[str, Any] | None = None) -> dict[str, Any]:
|
|||
|
|
"""受限生效的参数投影:scheduleParams + 命中作用域的灰度/生效实验。
|
|||
|
|
|
|||
|
|
查询无作用域时只返回基线参数(受限实验不扩散到全量查询)。
|
|||
|
|
"""
|
|||
|
|
out = dict(get_schedule_params(world))
|
|||
|
|
for rec in world.get("paramExperiments") or []:
|
|||
|
|
if rec.get("status") not in ("GRAY", "ACTIVE", "FULL"):
|
|||
|
|
continue
|
|||
|
|
if not _scope_matches(rec.get("scope") or {}, scope):
|
|||
|
|
continue
|
|||
|
|
cand = rec.get("candidate") or {}
|
|||
|
|
if "customerLevelWeights" in cand:
|
|||
|
|
out["customerLevelWeights"] = {
|
|||
|
|
**(out.get("customerLevelWeights") or {}),
|
|||
|
|
**cand["customerLevelWeights"],
|
|||
|
|
}
|
|||
|
|
out.update({k: v for k, v in cand.items() if k != "customerLevelWeights"})
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
# ---------------- 验证 / 全量生效 / 退化回滚 ----------------
|
|||
|
|
def finalize(self, world: World, experiment_id: str, *,
|
|||
|
|
promote_full: bool = True) -> dict[str, Any]:
|
|||
|
|
"""灰度收口:在**验证集**(held-out)回放,退化自动回滚,否则生效。
|
|||
|
|
|
|||
|
|
- 验证回放退化 → status=ROLLED_BACK(效果退化自动回滚,基线参数不动)。
|
|||
|
|
- 验证通过 → status=ACTIVE;promote_full=True 时写入 scheduleParams
|
|||
|
|
(status=FULL),并留存 before/after 证据。
|
|||
|
|
"""
|
|||
|
|
rec = self.get_experiment(world, experiment_id)
|
|||
|
|
if rec is None:
|
|||
|
|
raise ValueError(f"未找到参数实验:{experiment_id}")
|
|||
|
|
if rec.get("status") != "GRAY":
|
|||
|
|
raise ValueError(f"实验状态为 {rec.get('status')},只能从 GRAY 收口")
|
|||
|
|
split = rec.get("split") or {}
|
|||
|
|
val_ids = split.get("validation") or split.get("train") or []
|
|||
|
|
validation_replay = self.replay(world, rec["candidate"],
|
|||
|
|
order_ids=val_ids or None,
|
|||
|
|
strategy=rec.get("strategy"))
|
|||
|
|
rec["validationReplay"] = validation_replay
|
|||
|
|
if _is_degraded(validation_replay["baselineKpi"],
|
|||
|
|
validation_replay["candidateKpi"], self.tolerance):
|
|||
|
|
rec["status"] = "ROLLED_BACK"
|
|||
|
|
rec["rolledBackAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|||
|
|
rec["rollbackReason"] = "validation-degraded"
|
|||
|
|
return dict(rec)
|
|||
|
|
rec["status"] = "ACTIVE"
|
|||
|
|
rec["activatedAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|||
|
|
# 线上观测基线:激活时取最近一次生产观测(无观测则首个观测自校准,矩阵 87)
|
|||
|
|
prior_obs = world.get("paramObservations") or []
|
|||
|
|
rec["productionBaseline"] = deepcopy(prior_obs[-1]["kpi"]) if prior_obs else None
|
|||
|
|
rec["degradedStreak"] = 0
|
|||
|
|
if promote_full:
|
|||
|
|
full = self._apply_full(world, rec["candidate"])
|
|||
|
|
rec["status"] = "FULL"
|
|||
|
|
rec["appliedAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|||
|
|
rec["fullApply"] = full
|
|||
|
|
return dict(rec)
|
|||
|
|
|
|||
|
|
def rollback(self, world: World, experiment_id: str, *,
|
|||
|
|
reason: str = "manual") -> dict[str, Any]:
|
|||
|
|
"""显式回滚:若已全量写入 scheduleParams,恢复上一参数版本。"""
|
|||
|
|
rec = self.get_experiment(world, experiment_id)
|
|||
|
|
if rec is None:
|
|||
|
|
raise ValueError(f"未找到参数实验:{experiment_id}")
|
|||
|
|
if rec.get("status") == "FULL":
|
|||
|
|
self._restore_params(world, rec.get("prevParams"))
|
|||
|
|
rec["status"] = "ROLLED_BACK"
|
|||
|
|
rec["rolledBackAt"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|||
|
|
rec["rollbackReason"] = reason
|
|||
|
|
return dict(rec)
|
|||
|
|
|
|||
|
|
def history(self, world: World) -> list[dict[str, Any]]:
|
|||
|
|
return [dict(rec) for rec in world.get("paramExperiments") or []]
|
|||
|
|
|
|||
|
|
# ---------------- 候选阶段(split→回放→候选,网关 /api/params/optimize) ----------------
|
|||
|
|
def propose(self, world: World, candidates: list[dict[str, Any]] | None = None, *,
|
|||
|
|
scope: dict[str, Any] | None = None, strategy: str | None = None,
|
|||
|
|
limit: int | None = None) -> dict[str, Any]:
|
|||
|
|
"""候选提案:训练/验证集隔离 → 候选回放 → GRAY 实验(不写主干参数)。
|
|||
|
|
|
|||
|
|
与 run_pipeline 的分工:本方法只做灰度提案,升级(GRAY→ACTIVE/FULL)
|
|||
|
|
由网关 P2 门禁确认卡触发(execute_confirmed 内 finalize)。默认候选
|
|||
|
|
来自敏感性分析 Tornado 的改善方向;也支持显式传入候选参数。
|
|||
|
|
"""
|
|||
|
|
if candidates is None:
|
|||
|
|
report = run_sensitivity(world, strategy=strategy or self.strategy)
|
|||
|
|
rows = (self.candidates_from_sensitivity(report, limit=limit)
|
|||
|
|
if limit is not None else self.candidates_from_sensitivity(report))
|
|||
|
|
candidates = [c["candidate"] for c in rows]
|
|||
|
|
results: list[dict[str, Any]] = []
|
|||
|
|
for cand in candidates:
|
|||
|
|
step = self.promote_gray(world, cand, scope=scope, strategy=strategy)
|
|||
|
|
if not step["accepted"]:
|
|||
|
|
results.append({"candidate": cand, "stage": "rejected",
|
|||
|
|
"reason": step["reason"], "replay": step.get("replay")})
|
|||
|
|
continue
|
|||
|
|
exp = step["experiment"]
|
|||
|
|
results.append({"candidate": cand, "stage": "gray",
|
|||
|
|
"experimentId": exp["id"], "replay": exp.get("trainReplay")})
|
|||
|
|
return {
|
|||
|
|
"results": results,
|
|||
|
|
"summary": {
|
|||
|
|
"total": len(results),
|
|||
|
|
"gray": sum(1 for r in results if r["stage"] == "gray"),
|
|||
|
|
"rejected": sum(1 for r in results if r["stage"] == "rejected"),
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ---------------- 线上观测驱动回滚监控(矩阵 87) ----------------
|
|||
|
|
def record_observation(self, world: World, kpi: dict[str, Any], *,
|
|||
|
|
source: str = "production", actor: str = "param-observer",
|
|||
|
|
max_consecutive_degraded: int | None = None,
|
|||
|
|
next_id=None) -> dict[str, Any]:
|
|||
|
|
"""线上生产 KPI 观测回调:观测数据落盘 + 连续劣化自动回滚。
|
|||
|
|
|
|||
|
|
- 观测数据追加到 world["paramObservations"](append-only)。
|
|||
|
|
- 对每个 ACTIVE/FULL 实验:对比生效前基线(productionBaseline,
|
|||
|
|
finalize 时取最近一次观测;无观测则首个观测自校准),
|
|||
|
|
用 _is_degraded(tolerance 可配置)判定单次劣化。
|
|||
|
|
- 连续劣化次数达到 max_consecutive_degraded(默认 2)自动回滚
|
|||
|
|
该实验(恢复基线参数版本),并写审计
|
|||
|
|
param.experiment.auto_rolled_back。
|
|||
|
|
- 非劣化观测清零连败计数。
|
|||
|
|
next_id:审计发号器(通常传 store.next_id;缺省按 auditEvents 长度自增)。
|
|||
|
|
返回 {observation, autoRolledBack, degraded};调用方随后负责 save()。
|
|||
|
|
"""
|
|||
|
|
if not kpi:
|
|||
|
|
raise ValueError("观测 KPI 不能为空")
|
|||
|
|
kpi = {k: float(v) for k, v in kpi.items()}
|
|||
|
|
threshold = max(
|
|||
|
|
1, int(max_consecutive_degraded
|
|||
|
|
if max_consecutive_degraded is not None else self.max_consecutive_degraded))
|
|||
|
|
at = time.strftime("%Y-%m-%d %H:%M:%S")
|
|||
|
|
obs_id = "po-" + uuid.uuid4().hex[:8]
|
|||
|
|
evaluations: list[dict[str, Any]] = []
|
|||
|
|
auto_rolled_back: list[str] = []
|
|||
|
|
degraded_now: list[str] = []
|
|||
|
|
for rec in world.get("paramExperiments") or []:
|
|||
|
|
if rec.get("status") not in ("ACTIVE", "FULL"):
|
|||
|
|
continue
|
|||
|
|
exp_id = str(rec.get("id") or "")
|
|||
|
|
baseline = rec.get("productionBaseline")
|
|||
|
|
if baseline is None:
|
|||
|
|
baseline = dict(kpi) # 首个观测自校准为生效前基线
|
|||
|
|
rec["productionBaseline"] = baseline
|
|||
|
|
degraded = _is_degraded(baseline, kpi, self.tolerance)
|
|||
|
|
streak = (int(rec.get("degradedStreak") or 0) + 1) if degraded else 0
|
|||
|
|
rec["degradedStreak"] = streak
|
|||
|
|
rolled_back = False
|
|||
|
|
if degraded and streak >= threshold:
|
|||
|
|
rolled_back = True
|
|||
|
|
if rec.get("status") == "FULL":
|
|||
|
|
self._restore_params(world, rec.get("prevParams"))
|
|||
|
|
rec["status"] = "ROLLED_BACK"
|
|||
|
|
rec["rolledBackAt"] = at
|
|||
|
|
rec["rollbackReason"] = "production-degraded"
|
|||
|
|
rec["autoRollback"] = {
|
|||
|
|
"observationId": obs_id, "degradedStreak": streak,
|
|||
|
|
"at": at, "source": source,
|
|||
|
|
}
|
|||
|
|
auto_rolled_back.append(exp_id)
|
|||
|
|
self._audit_auto_rollback(
|
|||
|
|
world, exp_id, obs_id, baseline, kpi, streak,
|
|||
|
|
actor=actor, source=source, next_id=next_id)
|
|||
|
|
elif degraded:
|
|||
|
|
degraded_now.append(exp_id)
|
|||
|
|
evaluations.append({
|
|||
|
|
"experimentId": exp_id,
|
|||
|
|
"status": rec.get("status"),
|
|||
|
|
"baselineKpi": baseline,
|
|||
|
|
"degraded": degraded,
|
|||
|
|
"degradedStreak": streak,
|
|||
|
|
"autoRolledBack": rolled_back,
|
|||
|
|
})
|
|||
|
|
observation = {
|
|||
|
|
"id": obs_id,
|
|||
|
|
"at": at,
|
|||
|
|
"source": source,
|
|||
|
|
"kpi": kpi,
|
|||
|
|
"evaluations": evaluations,
|
|||
|
|
}
|
|||
|
|
world.setdefault("paramObservations", []).append(observation)
|
|||
|
|
return {
|
|||
|
|
"observation": observation,
|
|||
|
|
"autoRolledBack": auto_rolled_back,
|
|||
|
|
"degraded": degraded_now,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _audit_auto_rollback(world: World, experiment_id: str, observation_id: str,
|
|||
|
|
baseline: dict[str, Any], kpi: dict[str, Any],
|
|||
|
|
streak: int, *, actor: str, source: str,
|
|||
|
|
next_id=None) -> None:
|
|||
|
|
"""自动回滚审计(param.experiment.auto_rolled_back,WORLD_WRITE/P1)。"""
|
|||
|
|
from server.agent_core.audit import write_audit
|
|||
|
|
if next_id is None:
|
|||
|
|
next_id = lambda _kind: len(world.get("auditEvents") or []) + 1
|
|||
|
|
write_audit(
|
|||
|
|
world, next_id, actor=actor, category="WORLD_WRITE",
|
|||
|
|
action="param.experiment.auto_rolled_back",
|
|||
|
|
target={"type": "PARAM_EXPERIMENT", "id": experiment_id},
|
|||
|
|
power="P1",
|
|||
|
|
rationale={
|
|||
|
|
"observationId": observation_id,
|
|||
|
|
"source": source,
|
|||
|
|
"baselineKpi": baseline,
|
|||
|
|
"kpi": kpi,
|
|||
|
|
"degradedStreak": streak,
|
|||
|
|
"reason": "production-degraded",
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---------------- 内部写操作(留 before/after 证据) ----------------
|
|||
|
|
def _apply_full(self, world: World, candidate: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""把候选参数全量写入 scheduleParams(仅 finalize 验证通过后调用)。"""
|
|||
|
|
before = deepcopy(get_schedule_params(world))
|
|||
|
|
sp = world.setdefault("scheduleParams", {})
|
|||
|
|
if "customerLevelWeights" in candidate:
|
|||
|
|
sp["customerLevelWeights"] = {
|
|||
|
|
**(sp.get("customerLevelWeights") or {}),
|
|||
|
|
**candidate["customerLevelWeights"],
|
|||
|
|
}
|
|||
|
|
for key, value in candidate.items():
|
|||
|
|
if key != "customerLevelWeights":
|
|||
|
|
sp[key] = value
|
|||
|
|
return {"before": before, "after": get_schedule_params(world)}
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _restore_params(world: World, prev_params: dict[str, Any] | None) -> None:
|
|||
|
|
"""恢复到上一参数版本(prevParams=None 表示此前为纯默认)。"""
|
|||
|
|
if prev_params is None:
|
|||
|
|
world.pop("scheduleParams", None)
|
|||
|
|
else:
|
|||
|
|
world["scheduleParams"] = deepcopy(prev_params)
|
|||
|
|
|
|||
|
|
# ---------------- 一键闭环(可选入口) ----------------
|
|||
|
|
def run_pipeline(self, world: World, candidates: list[dict[str, Any]] | None = None,
|
|||
|
|
*, scope: dict[str, Any] | None = None,
|
|||
|
|
strategy: str | None = None) -> dict[str, Any]:
|
|||
|
|
"""一键闭环:无候选时先跑敏感性分析取方向,逐候选 灰度→收口。"""
|
|||
|
|
if candidates is None:
|
|||
|
|
report = run_sensitivity(world, strategy=strategy or self.strategy)
|
|||
|
|
candidates = [c["candidate"]
|
|||
|
|
for c in self.candidates_from_sensitivity(report)]
|
|||
|
|
results: list[dict[str, Any]] = []
|
|||
|
|
for cand in candidates:
|
|||
|
|
step = self.promote_gray(world, cand, scope=scope, strategy=strategy)
|
|||
|
|
if not step["accepted"]:
|
|||
|
|
results.append({"candidate": cand, "stage": "rejected",
|
|||
|
|
"reason": step["reason"]})
|
|||
|
|
continue
|
|||
|
|
rec = self.finalize(world, step["experiment"]["id"], promote_full=True)
|
|||
|
|
results.append({"candidate": cand, "stage": rec["status"].lower(),
|
|||
|
|
"experimentId": rec["id"]})
|
|||
|
|
summary = {
|
|||
|
|
"total": len(results),
|
|||
|
|
"rejected": sum(1 for r in results if r["stage"] == "rejected"),
|
|||
|
|
"rolledBack": sum(1 for r in results if r["stage"] == "rolled_back"),
|
|||
|
|
"active": sum(1 for r in results if r["stage"] in ("active", "full")),
|
|||
|
|
}
|
|||
|
|
return {"results": results, "summary": summary}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def optimize(world: World, candidates: list[dict[str, Any]] | None = None, *,
|
|||
|
|
seed: int = DEFAULT_SEED, tolerance: float = DEFAULT_TOLERANCE,
|
|||
|
|
strategy: str = "COMPREHENSIVE",
|
|||
|
|
scope: dict[str, Any] | None = None) -> dict[str, Any]:
|
|||
|
|
"""函数式入口:默认使用敏感性分析产出作为候选方向(矩阵 87 行)。"""
|
|||
|
|
return ParameterOptimizer(seed=seed, tolerance=tolerance,
|
|||
|
|
strategy=strategy).run_pipeline(
|
|||
|
|
world, candidates=candidates, scope=scope, strategy=strategy)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _candidate_summary(candidate: dict[str, Any]) -> str:
|
|||
|
|
"""候选参数人可读摘要(确认卡用)。"""
|
|||
|
|
parts: list[str] = []
|
|||
|
|
for key, value in (candidate or {}).items():
|
|||
|
|
if key == "customerLevelWeights":
|
|||
|
|
parts.append("customerLevelWeights=" + ",".join(
|
|||
|
|
f"{k}:{v}" for k, v in value.items()))
|
|||
|
|
else:
|
|||
|
|
parts.append(f"{key}={value}")
|
|||
|
|
return " ".join(parts) or "(无)"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def confirmation_for_param_promote(experiment: dict[str, Any]) -> tuple[str, list[str]]:
|
|||
|
|
"""参数实验升级确认卡文案:实验摘要 + 候选参数 + 回放 KPI(矩阵 87 P2 门禁)。"""
|
|||
|
|
cand = experiment.get("candidate") or {}
|
|||
|
|
scope = experiment.get("scope") or {}
|
|||
|
|
train = experiment.get("trainReplay") or {}
|
|||
|
|
base_tard = float((train.get("baselineKpi") or {}).get("tardiness") or 0.0)
|
|||
|
|
cand_tard = float((train.get("candidateKpi") or {}).get("tardiness") or 0.0)
|
|||
|
|
scope_text = " / ".join(
|
|||
|
|
f"{k}={','.join(str(v) for v in vs)}" for k, vs in scope.items()) or "全局(无作用域限制)"
|
|||
|
|
lines = [
|
|||
|
|
f"实验:{experiment.get('id')}(策略 {experiment.get('strategy')})",
|
|||
|
|
f"候选参数:{_candidate_summary(cand)}",
|
|||
|
|
f"作用域:{scope_text}",
|
|||
|
|
(f"训练集回放 KPI:基线延期 {base_tard} → 候选 {cand_tard}"
|
|||
|
|
f"({'退化' if train.get('degraded') else '未退化'})"),
|
|||
|
|
"确认后执行验证集回放:退化自动回滚;通过则 GRAY→ACTIVE/FULL(全量生效)。",
|
|||
|
|
]
|
|||
|
|
return "参数实验升级(GRAY→ACTIVE)", lines
|