4.3 退化解与退化模式
上一节把「有多少种打法」变成了一个可算的数。但 平衡治理流程 的榜单真正要抓的不是数量,而是一种特定的坏:牌堆里明明有几十种合法组合,实战里所有人却只反复打同一条线。这条线就是退化解——它常常还很强(胜率拉满),所以光看胜率根本抓不住它。本节给「退化」一个可算的定义,并说明它比涌现度更容易被阈值左右。
退化:模式很多,但都在一条上
涌现度(4.2)量的是可达模式有多少种。退化量的是这些模式被用得多不均匀。两者不矛盾:一个游戏可以「可达 35 种模式」同时「实战 63% 的回合都在打同一种」。把每个策略在一局里打出的非空回合程序收集起来,数出出现次数最多的那一种(主模式)占总回合数的比例,就是退化指数:退化指数 = 1000 × 主模式出现次数 // 非空回合程序总数。
它和涌现度一样是代理指标,同样依赖口径——但比涌现度更脆,因为它还依赖一条人定的线(多高算退化)。本节把这条线的脆弱性也跑出来。
S 层:64 个优先序策略
sim/gd_play_04_degenerate.py 构造 64 个优先序策略:4 种卡(jab/strike/guard/heavy)按优先序排列(长度 1–4 的全排列共 4+12+24+24=64 个),每回合打第一张付得起的牌,都不行就结束回合。对每个策略跨 8 个种子聚合胜率与退化指数,再按胜线 500‰、退化线 500‰ 分成四象限。
#!/usr/bin/env python3
# file: sim/gd_play_04_degenerate.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_04_degenerate.py
"""卷二 4.3 退化解与退化模式(S 层):把「退化」写成主模式占比。
策略族:64 个优先序策略——4 种卡按优先序排列(长度 1..4 的全排列 = 4+12+24+24),
每回合按优先序打第一张付得起的牌,都不行就结束回合。对每个策略跨种子聚合:
winPermille 胜率千分比
dominantShare‰ 退化指数 = 1000 × 主模式出现次数 // 非空回合程序总数
distinct 并集里出现过的不同回合程序数
四象限:胜 + 高退化 = **退化解**;胜 + 低退化 = 健康解;负 + 高退化 = **退化模式**;
负 + 低退化 = 弱解。产物 out/play04_degenerate.{json,csv,svg}。全整数、无浮点。
"""
from __future__ import annotations
import copy
import itertools
import json
import os
import sys
import time
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_04_degenerate.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
IDS = ["jab", "strike", "guard", "heavy"]
FILL = {"退化解": "#ef4444", "健康解": "#22c55e", "退化模式": "#f59e0b", "弱解": "#475569"}
def run_priority(spec, priority, cost, cap=400):
"""按优先序贪心:每回合打第一张付得起的牌,否则结束回合。返回命令序列。"""
seq = []
for _ in range(cap):
s = ashlib.run_spec(spec, seq)
if s["over"]:
break
pick = None
for cid in priority:
for i, c in enumerate(s["hand"]):
if c == cid and cost[cid] <= s["energy"]:
pick = i
break
if pick is not None:
break
seq.append("p" + str(pick) if pick is not None else "e")
return seq
def turn_programs(spec, seq):
"""只取**非空完整回合**的回合程序(排序元组),跳过结尾未结束的残回合。"""
out, cur = [], []
for k in range(len(seq)):
if seq[k] == "e":
if cur:
out.append(tuple(sorted(cur)))
cur = []
else:
s = ashlib.run_spec(spec, seq[:k])
i = int(seq[k][1:])
if 0 <= i < len(s["hand"]):
cur.append(s["hand"][i])
return out
def classify(win_pm, share_pm, win_line, share_line):
win, deg = win_pm >= win_line, share_pm >= share_line
if win and deg:
return "退化解"
if win:
return "健康解"
return "退化模式" if deg else "弱解"
def main():
t0 = time.perf_counter_ns()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds, win_line, share_line = cfg["seeds"], cfg["winLinePermille"], cfg["shareLinePermille"]
spec = ashlib.load_spec()
cost = {c["id"]: c["cost"] for c in spec["cards"]}
rows, csv = [], []
for prio in [p for r in range(1, 5) for p in itertools.permutations(IDS, r)]:
wins = turns = dominant = 0
distinct = set()
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
seq = run_priority(s, prio, cost)
snap = ashlib.run_spec(s, seq)
wins += 1 if snap["won"] else 0
progs = turn_programs(s, seq)
if not progs:
continue
counts = {}
for p in progs:
counts[p] = counts.get(p, 0) + 1
turns += len(progs)
dominant += max(counts.values())
distinct |= set(progs)
win_pm = 1000 * wins // len(seeds)
share_pm = 1000 * dominant // turns if turns else 0
row = {"priority": ">".join(prio), "r": len(prio), "winPermille": win_pm,
"dominantSharePermille": share_pm, "distinct": len(distinct),
"kind": classify(win_pm, share_pm, win_line, share_line)}
rows.append(row)
csv.append([row["priority"], row["r"], win_pm, share_pm, len(distinct), row["kind"]])
rows.sort(key=lambda r: (-r["winPermille"], -r["dominantSharePermille"], r["priority"]))
n = ashlib.write_csv(os.path.join(OUT, "play04_degenerate.csv"),
["priority", "r", "winPermille", "dominantSharePermille", "distinct", "kind"], csv)
counts = {k: sum(1 for r in rows if r["kind"] == k) for k in FILL}
deg = [r for r in rows if r["kind"] == "退化解"]
root = ashlib.svg_begin(720, 340)
ashlib.svg_text(root, 20, 28, "64 个优先序策略:胜率 vs 退化指数", 15)
ashlib.svg_text(root, 20, 48, "红=退化解 绿=健康解 橙=退化模式 灰=弱解", 11, "#94a3b8")
for i, r in enumerate(rows):
x, y = 24 + (i % 8) * 86, 70 + (i // 8) * 30
ET.SubElement(root, "rect", {"x": str(x), "y": str(y), "width": "80", "height": "24",
"fill": FILL[r["kind"]], "rx": "3"})
ashlib.svg_text(root, x + 40, y + 16,
f"{r['winPermille'] // 10}%/{r['dominantSharePermille'] // 10}%", 10, "#0f172a", "middle")
size = ashlib.svg_save(root, os.path.join(OUT, "play04_degenerate.svg"))
report = {"id": "gd_play_04_degenerate", "strategies": len(rows), "seeds": len(seeds),
"winLinePermille": win_line, "shareLinePermille": share_line, "counts": counts,
"worstDegenerateSolution": deg[0] if deg else None, "rows": rows,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "play04_degenerate.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"策略族 {len(rows)} 个 种子 {len(seeds)} 胜线={win_line}‰ 退化线={share_line}‰")
print(f"{'优先序':<28}{'r':>2}{'胜率‰':>7}{'退化‰':>7}{'模式数':>7} 类别")
for r in rows[:12]:
print(f"{r['priority']:<28}{r['r']:>2}{r['winPermille']:>7}{r['dominantSharePermille']:>7}"
f"{r['distinct']:>7} {r['kind']}")
print(f"象限计数: {counts}")
print(f"最退化的解: {deg[0]['priority']} 胜率={deg[0]['winPermille']}‰ "
f"退化={deg[0]['dominantSharePermille']}‰ 模式={deg[0]['distinct']}")
print(f"csv={n} 行 svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play04_degenerate.json out/play04_degenerate.csv out/play04_degenerate.svg")
ET.parse(os.path.join(OUT, "play04_degenerate.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_04_degenerate.py(前 6 行,完整 64 行见 out/play04_degenerate.csv):
策略族 64 个 种子 8 胜线=500‰ 退化线=500‰
优先序 r 胜率‰ 退化‰ 模式数 类别
heavy>strike>guard 3 1000 631 4 退化解
heavy>jab>strike 3 1000 625 5 退化解
heavy>jab>strike>guard 4 1000 625 5 退化解
heavy>strike>guard>jab 4 1000 625 5 退化解
heavy>strike>jab 3 1000 625 5 退化解
heavy>strike>jab>guard 4 1000 625 5 退化解
象限计数: {'退化解': 22, '健康解': 16, '退化模式': 10, '弱解': 16}
最退化的解: heavy>strike>guard 胜率=1000‰ 退化=631‰ 模式=4
csv=64 行 svg=13187 bytes elapsed=138 ms
wrote out/play04_degenerate.json out/play04_degenerate.csv out/play04_degenerate.svg
判读:胜率满分的解,恰恰是最退化的
四象限计数是 退化解 22 / 健康解 16 / 退化模式 10 / 弱解 16。注意胜率 1000‰ 的策略大多落在「退化解」,其中最极端的 heavy>strike>guard 只打出了 4 种不同回合程序、63% 的回合都在重复主模式——它赢,但它让这局游戏只剩一条路。
| 象限 | 胜率 | 退化指数 | 含义 | 数量 |
|---|---|---|---|---|
| 退化解 | 高 | 高 | 强但单调,最该警惕 | 22 |
| 健康解 | 高 | 低 | 强且多样,理想目标 | 16 |
| 退化模式 | 低 | 高 | 又弱又单调,设计失败 | 10 |
| 弱解 | 低 | 低 | 又弱但多样,尚可救 | 16 |
这张表正是「涌现度会骗人」的证据:heavy>strike>guard 所在的牌池可达 35 种模式(4.2),可它实战只走 4 种。光报涌现度,会把这个最该修的解法说成「模式丰富」。退化指数补的就是这一刀。
B 层:退化线一动,名单就变
退化指数依赖一条人定的退化线。sim/gd_play_04_degenerate_bench.py 把这条线从 300‰ 扫到 700‰,看「被判为退化解的策略集合」会不会变。B 档软告警不硬失败,脚本永远 exit 0。
#!/usr/bin/env python3
# file: sim/gd_play_04_degenerate_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_04_degenerate_bench.py
"""卷二 4.3 退化检测基准(B 层):把「退化线」本身当成可扰动参数。
4.3 的「退化解」判定依赖一条人定的线(主模式占比 ≥ 500‰)。该问的是:**线换一个,
被判退化的策略集合会不会翻盘**——若翻盘,说明「哪些策略退化」是阈值相关的,
告警清单须人工复核。复用 sim/gd_play_04_degenerate.py 的 run_priority / turn_programs /
classify。**永远 exit 0**:B 档软告警不硬失败——翻盘只打印 SOFT,不抛异常。
"""
from __future__ import annotations
import copy
import itertools
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import ashlib # noqa: E402
import gd_play_04_degenerate as dg # noqa: E402
def main() -> int:
t0 = time.perf_counter_ns()
with open(dg.CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
seeds, win_line = cfg["seeds"], cfg["winLinePermille"]
spec = ashlib.load_spec()
cost = {c["id"]: c["cost"] for c in spec["cards"]}
# 先把每个策略的 (胜率, 退化指数) 算一次,阈值扫描只是重新分类,不重跑仿真
stats = []
for prio in [p for r in range(1, 5) for p in itertools.permutations(dg.IDS, r)]:
wins = turns = dominant = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
seq = dg.run_priority(s, prio, cost)
snap = ashlib.run_spec(s, seq)
wins += 1 if snap["won"] else 0
progs = dg.turn_programs(s, seq)
if not progs:
continue
counts = {}
for p in progs:
counts[p] = counts.get(p, 0) + 1
turns += len(progs)
dominant += max(counts.values())
stats.append({"priority": ">".join(prio), "win": 1000 * wins // len(seeds),
"share": 1000 * dominant // turns if turns else 0})
rows = []
for line in cfg["perturb"]["shareLinePermille"]:
kinds = [dg.classify(s["win"], s["share"], win_line, line) for s in stats]
counts = {k: kinds.count(k) for k in ("退化解", "健康解", "退化模式", "弱解")}
deg = sorted(s["priority"] for s, k in zip(stats, kinds) if k == "退化解")
rows.append({"shareLine": line, "counts": counts, "degenerateSolutions": deg})
base = next(r for r in rows if r["shareLine"] == cfg["shareLinePermille"])
flipped = [r["shareLine"] for r in rows if r["degenerateSolutions"] != base["degenerateSolutions"]]
for r in rows:
flag = " <-- 基准" if r["shareLine"] == cfg["shareLinePermille"] else ""
print(f"退化线={r['shareLine']}‰ {r['counts']} 退化解数={len(r['degenerateSolutions'])}{flag}")
print(f"基准退化解集合稳定={not flipped} flippedLines={flipped}")
if flipped:
print(f"SOFT WARN THRESHOLD_FLIP: 退化线换到 {flipped} 时被判退化的策略集合改变,"
f"告警清单须人工复核")
else:
print("SOFT OK: 退化线在 300‰–700‰ 之间变动时,被判退化的策略集合不变")
print(f"SOFT: hardFail=false(B 档软告警不硬失败) elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_04_degenerate_bench.py:
退化线=300‰ {'退化解': 38, '健康解': 0, '退化模式': 26, '弱解': 0} 退化解数=38
退化线=400‰ {'退化解': 36, '健康解': 2, '退化模式': 20, '弱解': 6} 退化解数=36
退化线=500‰ {'退化解': 22, '健康解': 16, '退化模式': 10, '弱解': 16} 退化解数=22 <-- 基准
退化线=600‰ {'退化解': 8, '健康解': 30, '退化模式': 7, '弱解': 19} 退化解数=8
退化线=700‰ {'退化解': 0, '健康解': 38, '退化模式': 6, '弱解': 20} 退化解数=0
基准退化解集合稳定=False flippedLines=[300, 400, 600, 700]
SOFT WARN THRESHOLD_FLIP: 退化线换到 [300, 400, 600, 700] 时被判退化的策略集合改变,告警清单须人工复核
SOFT: hardFail=false(B 档软告警不硬失败) elapsed=121 ms
结论很直接:退化名单对阈值极度敏感。线从 500‰ 挪到 400‰,退化解就从 22 个涨到 36 个;挪到 700‰,一个都不剩。这跟 4.2 的排序稳定形成鲜明对比——涌现度的名次换粒度不变,退化的名单换阈值就崩。所以退化检测的正确用法是:只拿它排优先级、提示人工复核,绝不拿它当自动判罚的依据。
自检表
| 检查项 | 合格标准 |
|---|---|
| 退化有定义 | 退化指数 = 主模式次数 // 非空回合程序总数,写清分母 |
| 与涌现度划界 | 明确「可达模式多」不等于「实战模式多」,给出反例 |
| 阈值敏感显式 | B 层证明换线名单就变,结论是「排优先级而非自动判罚」 |
| 软告警不硬失败 | 两个 B 块都 return 0,翻盘只打印 SOFT,不抛异常 |
本章三节合起来给了一套可算的口径:规则 → 可达模式(4.1)→ 度量它(4.2)→ 抓退化解(4.3)。下一章换到「平衡」本身,先把它拆成定义与层次。
阅读导航:上一节:4.2 涌现的度量 · 下一节:5.1 平衡的定义与层次 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。