aps-agent/server/agent_core/param_opt.py

628 lines
31 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 参数优化闭环(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