aps-agent/server/agent_core/param_opt.py

628 lines
31 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.

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