16.2 全链路仿真验收

装配只保证了产物齐全,验收才回答「数对不对」。本篇把前面各章留下的产物汇总进一次全链路验收,逐项断言关键不变量:经济守恒、战力排序、概率归一、匹配收敛、赛季压缩在带内、实验第一类错误率覆盖。检查分声明式与派生两类,覆盖 S 仿真、P 原型、B 基准三层,再补一层余量基准看结论过得稳不稳。

上一节把两卷 30 章的产物装配成了一份可追溯索引,30/30 与 45/45 说明东西齐了。但齐全不等于正确:战力排序是不是真的一致?卡池概率加起来是不是 1000‰?赛季压缩之后进度到底落没落回健康带?这些是各章各自证明过的,可交付之前还需要在同一个地方、用同一套判据、一次性复核一遍——这就是全链路验收。

验收的对象不是散文,是产物。它把 out/n01_* 到 out/n15_* 读进来,对每个关键字段做一次断言,最后给出一份带层级统计的验收报告。任何一条 hard 检查失败,退出码就是 1,设计案不能交付。这一节也是全章唯一同时覆盖 S 仿真、P 原型、B 基准 三层的地方。

验收的两种检查:声明式与派生

一份好的验收清单要能被人读懂、被人改。所以检查被拆成两类:

  • 声明式检查:每条写成 {artifact, path, op, value},从某个产物的某个字段取值,按一个比较算子对阈值求值。这类检查是数据,不是代码——换一条不变量只要改配置,不用动脚本;同一个配置 Python 与 TypeScript 两侧都能读。
  • 派生检查:需要跨字段、跨行计算的,比如「分段发放求和等于总额」「数组反演数全零」「赛季默认档落在健康带」。这些在脚本里用几行代码实现,因为它们不是单点取值,而是聚合。

配置里 op 的取值集合是 eq / ne / ge / le / gt / lt / len_eq / abs_le / exists,kind 是 hard(失败即验收不通过)或 soft(只打软告警)。

{
  "version": 1,
  "note": "全链路验收不变量:每条声明式写成 {artifact, path, op, value},Python 与 TypeScript 两侧读同一份配置执行,逐位一致。op ∈ {eq, ne, ge, le, gt, lt, len_eq, abs_le, exists}。kind=hard 失败即验收不通过,kind=soft 只打软告警。另有若干需要聚合计算的派生检查(数组全零、求和守恒、单调、区间命中)在脚本内实现。tier 标注该产物来自哪一层。",
  "checks": [
    { "id": "power_same_unit", "tier": "S", "artifact": "n01_power", "path": "concordancePermille.sameUnit", "op": "eq", "value": 1000, "kind": "hard", "desc": "同型对局战力排序全对" },
    { "id": "power_overall", "tier": "S", "artifact": "n01_power", "path": "concordancePermille.overall", "op": "ge", "value": 800, "kind": "hard", "desc": "战力排序整体一致性不低于 800‰" },
    { "id": "attrs_nocycle", "tier": "S", "artifact": "n01_attrs", "path": "hasCycle", "op": "eq", "value": false, "kind": "hard", "desc": "派生属性依赖图无环" },
    { "id": "attrs_no_dangling", "tier": "S", "artifact": "n01_attrs", "path": "danglingRefs", "op": "len_eq", "value": 0, "kind": "hard", "desc": "无悬空属性引用" },
    { "id": "damage_min", "tier": "S", "artifact": "n03_damage", "path": "minDamage", "op": "eq", "value": 1, "kind": "hard", "desc": "伤害地板为 1" },
    { "id": "damage_cap", "tier": "S", "artifact": "n03_damage", "path": "reductionCapPct", "op": "eq", "value": 70, "kind": "hard", "desc": "减伤上限 70%" },
    { "id": "damage_cap_holds", "tier": "S", "artifact": "n03_damage", "path": "maxReductionCappedPct", "op": "eq", "value": 70, "kind": "hard", "desc": "扫描后实际减伤不超过上限" },
    { "id": "roundcross_p", "tier": "P", "artifact": "n03_round_cross", "path": "spearman300_vs_cavalry300.logHash", "op": "eq", "value": 1910851607, "kind": "hard", "desc": "P 层回放指纹与 Python 侧逐位一致" },
    { "id": "cost_bench", "tier": "B", "artifact": "n02_bench", "path": "meanAbsResidualPermille", "op": "le", "value": 100, "kind": "hard", "desc": "成本曲线拟合平均残差在阈值内" },
    { "id": "random_dev", "tier": "B", "artifact": "n03_random_bench", "path": "deviationBp", "op": "abs_le", "value": 100, "kind": "hard", "desc": "随机项模拟均值与解析期望偏差在容差内" },
    { "id": "random_warn", "tier": "B", "artifact": "n03_random_bench", "path": "warnCount", "op": "eq", "value": 0, "kind": "hard", "desc": "随机项基准无告警" },
    { "id": "counter_ok", "tier": "S", "artifact": "n04_counter_matrix", "path": "ok", "op": "eq", "value": true, "kind": "hard", "desc": "克制矩阵方向正确(镜像≈50、克制>50、被克<50)" },
    { "id": "strength_converged", "tier": "S", "artifact": "n04_strength", "path": "converged", "op": "eq", "value": true, "kind": "hard", "desc": "强度平衡迭代收敛" },
    { "id": "pity_hardcap", "tier": "S", "artifact": "n05_pity", "path": "pityHardCapHits", "op": "eq", "value": 0, "kind": "hard", "desc": "有保底时从不触硬上限" },
    { "id": "gacha_sum", "tier": "S", "artifact": "n06_gacha", "path": "allSumOk", "op": "eq", "value": true, "kind": "hard", "desc": "卡池概率归一(合计 1000‰)" },
    { "id": "gacha_exact", "tier": "S", "artifact": "n06_gacha", "path": "allExact", "op": "eq", "value": true, "kind": "hard", "desc": "单卡概率可整除(公示表精确)" },
    { "id": "elo_converge", "tier": "S", "artifact": "n07_elo", "path": "eloConvergeRound", "op": "gt", "value": 0, "kind": "hard", "desc": "ELO 在有限回合内收敛" },
    { "id": "fairness_stable", "tier": "B", "artifact": "n07_fairness_bench", "path": "stable", "op": "eq", "value": true, "kind": "hard", "desc": "匹配公平性结论跨试验稳定" },
    { "id": "inflation_bench", "tier": "B", "artifact": "n08_inflation_bench", "path": "warn", "op": "eq", "value": false, "kind": "hard", "desc": "膨胀闭式对照偏差在阈值内" },
    { "id": "protection", "tier": "S", "artifact": "n09_protection", "path": "allPass", "op": "eq", "value": true, "kind": "hard", "desc": "免费玩家体验保护门槛全过" },
    { "id": "tier_monotonic", "tier": "S", "artifact": "n10_tier_ladder", "path": "monotonic", "op": "eq", "value": true, "kind": "hard", "desc": "定价档位单位价值单调递增" },
    { "id": "elasticity_bench", "tier": "B", "artifact": "n10_elasticity_bench", "path": "pass", "op": "eq", "value": true, "kind": "hard", "desc": "价格弹性解析最优价与离散峰值吻合" },
    { "id": "ltv_payback", "tier": "S", "artifact": "n11_ltv", "path": "paybackDay", "op": "gt", "value": 0, "kind": "hard", "desc": "回本周期有限" },
    { "id": "ltv_bench", "tier": "B", "artifact": "n11_ltv_bench", "path": "pass", "op": "eq", "value": true, "kind": "hard", "desc": "LTV 逐日递推与闭式几何级数一致" },
    { "id": "churn_auc", "tier": "B", "artifact": "n12_churn_bench", "path": "aucPermille", "op": "ge", "value": 700, "kind": "hard", "desc": "流失预警模型 AUC 达标" },
    { "id": "churn_gain", "tier": "B", "artifact": "n12_churn_bench", "path": "matchedRecallGainPermille", "op": "gt", "value": 0, "kind": "hard", "desc": "同告警量下模型召回优于规则" },
    { "id": "stats_bench", "tier": "B", "artifact": "n13_stats_bench", "path": "pass", "op": "eq", "value": true, "kind": "hard", "desc": "第一类错误率覆盖、p 值均匀、功效达标" },
    { "id": "type1_coverage", "tier": "B", "artifact": "n13_stats_bench", "path": "checks.type1.pass", "op": "eq", "value": true, "kind": "hard", "desc": "第一类错误率落在名义水平附近" },
    { "id": "event_budget", "tier": "B", "artifact": "n14_event_budget_bench", "path": "weightedInBand", "op": "eq", "value": true, "kind": "hard", "desc": "加权奖励预算落在目标带内" },
    { "id": "gvg_stable", "tier": "B", "artifact": "n15_gvg_bench", "path": "stable", "op": "eq", "value": true, "kind": "hard", "desc": "GVG 战场结论跨试验稳定" },
    { "id": "power_cross_structural", "tier": "S", "artifact": "n01_power", "path": "concordancePermille.crossUnit", "op": "ge", "value": 700, "kind": "soft", "desc": "跨型一致性有下界(结构盲区仍在)" }
  ]
}

一次跑完三层:S 仿真、P 原型、B 基准

下面这段是 S 层验收脚本。它读上面那份配置,对每条声明式检查按算子求值,再追加若干派生检查,最后按 tier 分组统计通过率,产出验收报告 JSON + CSV + SVG。

# file: sim/n16_acceptance.py
# tier: S
# verify: python3 sim/n16_acceptance.py --config config/ --out out/
"""全链路验收:把前面各章的产物 out/n01_* … out/n15_* 汇总成一份验收报告,逐项断言关键不变量。

两类检查:声明式的(读 config/n16_acceptance.json,按 {artifact, path, op, value} 求值,TypeScript
侧也读同一份配置)与派生的(数组全零、求和守恒、单调、区间命中,在本脚本内实现)。报告写
out/n16_acceptance.json(+ csv + svg),hard 检查失败则退出码 1。纯标准库,SVG 用 xml.etree 手写。"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

OPS = {"eq": lambda a, v: a == v, "ne": lambda a, v: a != v, "ge": lambda a, v: a >= v,
       "le": lambda a, v: a <= v, "gt": lambda a, v: a > v, "lt": lambda a, v: a < v,
       "len_eq": lambda a, v: len(a) == v, "abs_le": lambda a, v: abs(a) <= v,
       "exists": lambda a, v: a is not None}


def load(path: str) -> dict:
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def resolve(doc: dict, path: str):
    cur = doc
    for key in path.split("."):
        cur = cur[key]
    return cur


def derived(o: dict) -> list[dict]:
    """聚合检查:跨字段/跨行计算的不变量。"""
    r = []

    def add(cid, tier, desc, ok, detail):
        r.append({"id": cid, "tier": tier, "kind": "hard", "desc": desc, "pass": bool(ok), "actual": detail})

    ext = o["n01_ext"]["rows"]
    add("ext_mul_exact", "S", "乘法战力与真值同构(反演恒 0)",
        all(x["mulInversions"] == 0 for x in ext), [x["mulInversions"] for x in ext])
    add("ext_k2_exact", "S", "二阶公式在 k=2 精确", ext[0]["add2Inversions"] == 0, ext[0]["add2Inversions"])
    add("ext_add_degrades", "S", "一阶加法反演数随属性数不降",
        ext[-1]["addInversions"] >= ext[0]["addInversions"], [ext[0]["addInversions"], ext[-1]["addInversions"]])
    sc = {s["id"]: s for s in o["n08_compress"]["schemes"]}
    add("season_default_healthy", "S", "赛季默认档把进度压回健康带",
        sc["season_default"]["healthy"], sc["season_default"]["finalIndexStart"])
    add("season_extremes_unhealthy", "S", "无重置与全额清零都不健康",
        (not sc["keep_all"]["healthy"]) and (not sc["zero_all"]["healthy"]),
        [sc["keep_all"]["healthy"], sc["zero_all"]["healthy"]])
    st = o["n14_settlement"]
    pj = sum(b["payoutJade"] for b in st["bands"])
    ph = sum(b["payoutHonor"] for b in st["bands"])
    add("settlement_conserved", "S", "分段发放求和等于结算总额(玉璧与荣誉各守恒)",
        pj == st["summary"]["totalPayoutJade"] and ph == st["summary"]["totalPayoutHonor"],
        {"jade": [pj, st["summary"]["totalPayoutJade"]], "honor": [ph, st["summary"]["totalPayoutHonor"]]})
    elo = o["n07_elo"]
    add("elo_rmse_dropped", "S", "ELO 收敛后 RMSE 显著低于开局",
        elo["eloFinalRmse"] < elo["eloStartRmse"], [elo["eloStartRmse"], elo["eloFinalRmse"]])
    ib = o["n08_inflation_bench"]
    add("inflation_closedform", "B", "膨胀闭式对照偏差在阈值内",
        abs(ib["closedFormDeviationPermille"]) <= ib["thresholdPermille"],
        [ib["closedFormDeviationPermille"], ib["thresholdPermille"]])
    return r


def write_svg(path: str, by_tier: dict) -> None:
    width, top, bh, gap, left = 640, 34, 30, 12, 200
    height = top + len(by_tier) * (bh + gap) + 20
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(width),
                             "height": str(height), "viewBox": f"0 0 {width} {height}"})
    ET.SubElement(svg, "rect", {"width": str(width), "height": str(height), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "16",
                                "font-family": "sans-serif"}).text = "验收不变量按层通过情况(通过 / 总数)"
    span = width - left - 70
    for i, (tier, c) in enumerate(sorted(by_tier.items())):
        y, w = top + i * (bh + gap), span * c["pass"] // max(c["total"], 1)
        ET.SubElement(svg, "text", {"x": str(left - 8), "y": str(y + bh - 9), "font-size": "12",
                                    "text-anchor": "end", "font-family": "sans-serif"}).text = f"{tier} 层"
        ET.SubElement(svg, "rect", {"x": str(left), "y": str(y), "width": str(span), "height": str(bh), "fill": "#eaeef2"})
        ET.SubElement(svg, "rect", {"x": str(left), "y": str(y), "width": str(max(w, 1)), "height": str(bh),
                                    "fill": "#1f6feb" if c["pass"] == c["total"] else "#d1242f"})
        ET.SubElement(svg, "text", {"x": str(left + span + 8), "y": str(y + bh - 9), "font-size": "12",
                                    "font-family": "sans-serif"}).text = f"{c['pass']}/{c['total']}"
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    cfg = load(os.path.join(args.config, "n16_acceptance.json"))

    cache, checks = {}, []
    for c in cfg["checks"]:
        cache.setdefault(c["artifact"], load(os.path.join(args.out, f"{c['artifact']}.json")))
        actual = resolve(cache[c["artifact"]], c["path"])
        checks.append({"id": c["id"], "tier": c["tier"], "kind": c["kind"], "desc": c["desc"],
                       "artifact": c["artifact"], "path": c["path"], "op": c["op"], "value": c["value"],
                       "actual": actual, "pass": OPS[c["op"]](actual, c["value"])})
    for n in ("n01_ext", "n08_compress", "n14_settlement", "n07_elo", "n08_inflation_bench"):
        cache.setdefault(n, load(os.path.join(args.out, f"{n}.json")))
    checks += derived(cache)

    hard = [c for c in checks if c["kind"] == "hard" and not c["pass"]]
    soft = [c for c in checks if c["kind"] == "soft" and not c["pass"]]
    by_tier: dict[str, dict] = {}
    for c in checks:
        t = by_tier.setdefault(c["tier"], {"total": 0, "pass": 0})
        t["total"] += 1
        t["pass"] += 1 if c["pass"] else 0
    summary = {"version": 1, "checks": checks,
               "totals": {"checks": len(checks), "passed": sum(1 for c in checks if c["pass"]),
                          "hardFail": len(hard), "softFail": len(soft)},
               "byTier": by_tier, "verdict": "PASS" if not hard else "FAIL"}

    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n16_acceptance.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n16_acceptance.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "tier", "kind", "artifact", "path", "op", "value", "actual", "pass"])
        for c in checks:
            w.writerow([c["id"], c["tier"], c["kind"], c.get("artifact", "-"), c.get("path", "-"),
                        c.get("op", "-"), c.get("value", "-"), c["actual"], c["pass"]])
    write_svg(os.path.join(args.out, "n16_acceptance.svg"), by_tier)

    print(f"{summary['verdict']}  检查 {len(checks)} 项 · 通过 {summary['totals']['passed']} "
          f"· hard 失败 {len(hard)} · soft 告警 {len(soft)}")
    for c in checks:
        flag = "ok " if c["pass"] else ("!! " if c["kind"] == "hard" else "~  ")
        print(f"  [{c['tier']}] {flag}{c['id']:<26} {c['desc']}")
    print("  " + " · ".join(f"{t} {v['pass']}/{v['total']}" for t, v in sorted(by_tier.items())))
    return 0 if not hard else 1


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑(python3 sim/n16_acceptance.py --config config/ --out out/)最后一行按层统计:

PASS  检查 39 项 · 通过 39 · hard 失败 0 · soft 告警 0
  ...
  B 14/14 · P 1/1 · S 24/24

过得稳不稳:验收余量基准

验收只说「过 / 不过」,余量基准说的是「过得稳不稳」。对每条带阈值的检查算一个余量:ge / gt 看实际超出下界多少,le / lt 看离上界还有多少,abs_le 看离绝对上界还有多少,都按 |实际 − 阈值| / |阈值| 归一成百分比。余量低于 10% 的判为「贴线」,只打软告警——贴线不是失败,是提醒你这条不变量经不起设计变更的折腾。

# file: sim/n16_acceptance_bench.py
# tier: B
# verify: python3 sim/n16_acceptance_bench.py --config config/ --out out/
"""验收余量基准:读 out/n16_acceptance.json,对每条带阈值的检查算「离失效还有多远」。

验收只说「过 / 不过」,余量基准说的是「过得稳不稳」。对 ge/le/gt/lt/abs_le 这类不等式,
余量 = |实际 − 阈值| / |阈值|;余量低于 warnPct 的判为「贴线」,只打软告警。软告警不硬失败,
退出码恒为 0。纯标准库。"""

from __future__ import annotations

import argparse
import json
import os


def load(path: str) -> dict:
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def margin_of(op: str, actual, value):
    """返回 (余量, 方向);非不等式或非数值返回 None。"""
    if op not in ("ge", "gt", "le", "lt", "abs_le"):
        return None
    if not isinstance(actual, (int, float)) or isinstance(actual, bool):
        return None
    base = abs(value) or 1
    if op in ("ge", "gt"):
        return (actual - value) / base, "下界"
    if op in ("le", "lt"):
        return (value - actual) / base, "上界"
    return (value - abs(actual)) / base, "绝对上界"


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    ap.add_argument("--warn-pct", type=float, default=10.0)
    args = ap.parse_args()
    rep = load(os.path.join(args.out, "n16_acceptance.json"))

    rows = []
    for c in rep["checks"]:
        m = margin_of(c.get("op", ""), c.get("actual"), c.get("value"))
        if m:
            rows.append({"id": c["id"], "tier": c["tier"], "op": c["op"], "actual": c["actual"],
                         "value": c["value"], "direction": m[1],
                         "marginPct": round(m[0] * 100, 1), "tight": m[0] * 100 < args.warn_pct})
    rows.sort(key=lambda r: r["marginPct"])
    tight = [r for r in rows if r["tight"]]
    with open(os.path.join(args.out, "n16_acceptance_bench.json"), "w", encoding="utf-8") as f:
        json.dump({"version": 1, "warnPct": args.warn_pct, "compared": len(rows), "tightCount": len(tight),
                   "tight": tight, "rows": rows, "verdict": "tight" if tight else "comfortable"},
                  f, ensure_ascii=False, indent=2)

    print(f"{'WARN' if tight else 'OK'}  比较 {len(rows)} 条阈值检查 · 贴线(余量 < {args.warn_pct}%){len(tight)} 条")
    for r in rows[:8]:
        print(f"  [{r['tier']}] {r['id']:<26} 实际 {r['actual']} / 阈值 {r['value']} "
              f"余量 {r['marginPct']}%{'  ~ 贴线' if r['tight'] else ''}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑(python3 sim/n16_acceptance_bench.py --config config/ --out out/):

WARN  比较 8 条阈值检查 · 贴线(余量 < 10.0%)1 条
  [S] power_overall              实际 848 / 阈值 800 余量 6.0%  ~ 贴线
  [S] power_cross_structural     实际 791 / 阈值 700 余量 13.0%
  [B] churn_auc                  实际 881 / 阈值 700 余量 25.9%
  [B] cost_bench                 实际 2 / 阈值 100 余量 98.0%

读这份验收报告:三层都过了,但有一条贴线

  • 39 项检查全过,hard 失败 0。覆盖 S 仿真 24 项、B 基准 14 项、P 原型 1 项——这就是全链路的意思:不是某一章自证,而是跨章、跨层一次性复核。经济守恒(settlement_conserved)、战力排序(power_same_unit)、概率归一(gacha_sum)、匹配收敛(elo_converge)、赛季压缩在带内(season_default_healthy)、实验第一类错误率覆盖(type1_coverage)都在其中。
  • 唯一贴线的是 power_overall,余量 6.0%。战力排序整体一致性实测 848‰、阈值 800‰,只差 48‰。这条不是错,是脆:任何一次属性表微调都可能把它推下阈值。第 1 章就说过跨型一致性是结构盲区,power_cross_structural 那条 soft 检查(791‰ / 700‰)也正是为它留的告警位。
  • P 层只放一条检查是刻意的。roundcross_p 断言的是原型层战斗回放指纹与 Python 侧逐位一致——一条就够,因为它证明的是「两个语言算出同一个数」这件事本身;16.3 会把这条链路展开成完整回放。
  • 余量基准是验收的补集。验收回答「对不对」,基准回答「稳不稳」。一份能交付的设计案,两者都要给:前者是门禁,后者是风险清单。

小结

  • 全链路验收把 out/n01_* … out/n15_* 汇总成一份报告,声明式检查(配置驱动,跨语言可复用)+ 派生检查(聚合计算,脚本内实现)。
  • 本机实跑:39 项全过,hard 失败 0,S 24 / B 14 / P 1;关键不变量——经济守恒、战力排序、概率归一、匹配收敛、赛季压缩在带内、实验第一类错误率覆盖——全部命中。
  • B 层余量基准进一步指出:power_overall 是唯一贴线项(余量 6.0%),是需盯的风险点,不是失败。
  • 验收过了,但设计案还没交付。下一节用一段配置驱动的原型回放,把「同一份配置在 S 与 P 两侧算出同一串数」这件事亲手跑一遍,然后收尾交付。

阅读导航:上一节:16.1 把两卷装配成完整设计案 · 下一节:16.3 原型回放与设计案交付 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引