朕喜技术(ZhenxiJS)
2026.10.21
1 分钟阅读

Python Elo 赛季结算榜「该上的没上、该扣的没扣、平局把连胜打断了」——健康检查空判据表 + 退出码恒 0 的假绿排查记

日期:2026-10-21|分支:2026-10-21-1001|期数:第138题 技术栈:Python 3.12(纯标准库)/ TSV 台账 / NDJSON 对局回放 / Elo rating / Docker Compose 关键词:Elo rating 结算误差、half-up 四舍五入 vs bankers rounding(round(0.5)==0)、 math domain/10**x 溢出成 inf 使 delta 静默变 0、aborted 对局被计入排名、 win streak 被平局打断、rookie board 边界 >= 写成 >、同分排序键、 契约默认值 fallback / fail-open 配置回退、空判据表假绿 BAD_FIELDS = ()、 健康检查退出码恒 0、docker compose exec -T、__pycache__ 掩盖改动

一、问题背景

season-settlement 是某人机对战游戏的赛季结算引擎:赛季结束后跑一轮,产出 state/settlement.json(新秀榜 / 场次统计 / Elo 评级分增量)。运行方式:

python3 -m engine.platform.run        # 退出码 0,打印 wrote .../settlement.json (ok=True)
python3 -m engine.platform.health     # {"degraded": false, "issues": []},退出码 0

三个绿灯、报告字段一个不缺,但运营看到的榜是这样的:

  • 新秀榜只有 3 人,按契约(joined_round >= 5 且 played >= 3)应该有第 4 人 → 「该上的没上」;
  • counts.matches 报 19,台账里明明有 24 行 → 「口径错」;
  • counts.rejected 只报 1 条(M-016 invalid_duration),另一条「对手不在名册」的 M-024 被静默吞掉;
  • anomalies 只有 1 条(M-004),实际回放里 M-016 尾部还有两处对不上;
  • 「该扣的没扣」:Elo delta 被算成 0(下文根因三);
  • 「平局把连胜打断了」:连胜里平局走了 else: cur = 0;
  • 报告里 ok: true 是硬编码,健康检查 BAD_FIELDS = () 空判据表 + 无条件 return EXIT_OK,于是一路绿灯、退出码 0。

榜面还有一层障眼法:rating 列是玩家表快照(不随本轮 delta 滚动), 所以「榜上 rating 对不对」和「delta 算得对不对」是两件事 —— 榜是对的,账是错的,这类问题最难被发现。

唯一说真话的是外部独立探针 client/season_probe.py(只读契约 + 台账 + 回放 + 报告, 不 import engine/,按 docs/settlement-contract.md 独立重算):

$ python3 client/season_probe.py
RESULT: BAD(不一致 2 条)
  - ok:期望 false 实际 true
  - counts.matches:期望 24 实际 19

二、复现步骤

git checkout 2026-10-21-1001
cd scenario/2026-10-21-1001
docker compose up -d --build
docker compose exec -T season bash        # 非交互必须加 -T

bash ops/repro.sh      # 一键:run + health + probe
bash ops/verify.sh     # 28 条断言(问题态 PASS=24 FAIL=4)
bash ops/negative.sh   # 16 道回滚注入(修复后 NPASS=16 NFAIL=0)

问题态输出:

== 1) 结算一轮 ==
wrote /srv/state/settlement.json (ok=True)     run rc=0
== 2) 健康检查 ==
{"degraded": false, "issues": []}              health rc=0
== 3) 外部独立探针 ==
RESULT: BAD(不一致 2 条)                      probe rc=1
  - ok:期望 false 实际 true
  - counts.matches:期望 24 实际 19

PASS=24 FAIL=4 / RESULT: BAD
  ✗ counts.matches:期望 24 实际 19
  ✗ ok:期望 false 实际 true
  ✗ probe rc:期望 0 实际 1
  ✗ health rc:期望 2 实际 0

三、根本原因

契约 docs/settlement-contract.md 是唯一权威,conf/engine.env 只是引擎出厂默认值。 逐条对照后共 11 处缺陷:

#文件缺陷契约条款
1engine/contract.py契约读不到 / 字段缺失 / 非法整数时静默回退 DEFAULTS(K=25、门槛 7/5、榜 3)——fail-open§8 fail-closed rc=3
2engine/duel/evaluate.pyexpected_score 未把 opp-self 夹到 ±4000,10**大数 变成 inf,K*(score-inf) → 非法,delta 静默错成 0§4
3engine/duel/outcome.pywinner_of() 在 `score_a-score_b
4engine/duel/outcome.pyreason = "unknown_player",而 season.py 只认 "unknown_opponent" → M-024 被静默吞掉§4
5engine/duel/outcome.pyUNRANKED 黑名单写法,未知 status 会被当排名局§1(白名单方向反了)
6engine/platform/season.pycounts.matches 写成 ranked§7
7engine/platform/season.py"ok": True 硬编码§7
8engine/platform/season.py新秀榜同分按 player_id 排序§6(按 nickname 升序)
9engine/platform/season.py连胜里平局(0.5)走 else: cur = 0,把连胜打断§5(平局既不增也不断)
10engine/platform/replay.py回放自检只比 hearts.a,漏 hearts.b§7 anomalies
11engine/platform/health.pyBAD_FIELDS = () 空判据表 + 恒 return EXIT_OK → 假绿§8

假绿的根因:BAD_FIELDS 是空元组 → for 循环一次都不执行 → issues 恒空 → degraded 恒 false;main() 又无条件 return EXIT_OK,连「报告读不到」都算健康。 于是报告里的 ok:true、错的 matches、被吞掉的 M-024,它一个都看不见。 这就是「自述字段自证 + 空判据表 + 退出码恒 0」的典型假绿组合。

四、解决办法

只改 engine/;docs/ client/ ops/ match/ conf/ state/season.env 一字未动(改考卷让判定变绿 = 造假)。

  1. contract.py 改成 fail-closed:删掉 DEFAULTS 回退路径。_env_file 读失败、_int() 遇 「缺字段 / 非整数」一律抛 ContractError;再校验 K_FACTOR>0、BOARD_SIZE>0、门槛非负、 SEASON_ID 非空。
  2. evaluate.py 夹取:diff = max(-4000, min(4000, opp_rating - self_rating))。 四舍五入保留自定义 half_up()(不能用 round(),round(0.5) == 0 是银行家舍入)。
  3. outcome.py:RANKED = ("finished", "forfeit") 做正向白名单;winner_of() 只信 winner 列,forfeit 由 actor 反推胜方,删除比分差/残留生命兜底; reason 改成契约词 unknown_opponent;所有 id 先 strip()。
  4. season.py:counts.matches = len(rows);ok = not rejected and not anomalies; 排序键 (-rating, nickname);连胜循环里 if score == 0.5: continue; reject 分支改成「unknown_opponent / invalid_winner 都记 reject」,不再静默丢。
  5. replay.py:hearts 与 kills 都比双方。
  6. health.py:BAD_FIELDS 填成 7 个必填项;_issues() 独立重算 (counts.rejected == len(rejected)、len(board) <= board_size、 ok == (not rejected and not anomalies));有 rejected/anomalies 一律追加真问题项; 契约错误 → rc=3,报告缺失/不可读 → rc=2。 判定写在 BAD_FIELDS 表之外,所以再把表挖空也挖不掉它(ops/negative.sh N15 实证)。

改完必须重建镜像(Dockerfile 是 COPY,compose 无 bind mount):

docker compose up -d --build
docker compose exec -T season bash -lc 'bash ops/verify.sh'

五、验证

$ bash ops/repro.sh
wrote /srv/state/settlement.json (ok=False)          run rc=0
{"degraded": true, "engine": "season-settlement",
 "issues": ["真问题:rejected=2 anomalies=3"]}        health rc=2
RESULT: OK                                            probe rc=0

$ bash ops/verify.sh          → PASS=28 FAIL=0 / RESULT: OK
$ bash ops/negative.sh        → NPASS=16 NFAIL=0 / RESULT: OK(闸门有牙)

修复后 state/settlement.json 关键字段:

counts    = {players:14, ranked:19, matches:24, rejected:2, aborted:3}
rejected  = [M-016 invalid_duration, M-024 unknown_opponent]
anomalies = [M-004:replay_kill_count_mismatch,
             M-016:replay_hearts_mismatch, M-016:replay_kill_count_mismatch]
ok        = false
board     = 灰羽(P-003, played 4, cur 0 / longest 1)、南烛(P-011, played 3, cur 2)、玄青(P-006, played 4, cur 2)
totals    = {delta_sum_abs:444, delta_max:25, delta_min:-25}

fail-closed 手工验证:

mv state/season.env /tmp/k.env
python3 -m engine.platform.run      # contract error: 契约不可读:/srv/state/season.env   rc=3
python3 -m engine.platform.health   # issues: ["contract_error: 契约不可读:…"]            rc=3

sed -i 's/^SETTLE_K_FACTOR=.*/SETTLE_K_FACTOR=abc/' state/season.env
python3 -m engine.platform.run      # contract error: 契约字段 SETTLE_K_FACTOR 不是整数:'abc'  rc=3
python3 -m engine.platform.health   # rc=3

注意:ops/repro.sh 最后一行仍会打印 RESULT: MISMATCH,因为脚本只在 run/health/probe 全为 0 时才判「全绿」,而本题正确终态就是 health rc=2 (本轮台账确有真问题)。看 probe 的 rc 和 verify.sh 即可,这不是没修好。

六、经验与避坑

6.1 这类 bug 的通用识别信号

  • 「榜是对的、账是错的」:报表里既有「快照值」又有「本轮增量」时, 快照列正确会盖住增量计算的错误。必须有一条外部独立重算的探针,不能只看报表。
  • 报告 ok:true + 字段齐全 + 退出码 0,但业务方说数不对:先怀疑 BAD_FIELDS = () / 无条件 return EXIT_OK / ok: true 硬编码。 「有字段」≠「有闸门」,ok 是算出来的还是写死的,grep "return EXIT" 一眼见分晓。
  • 两条同类 reject 只报了一条:八成是 reason 字面量在两处不一致 (unknown_player vs unknown_opponent),或 if reason == "..." 只认一个值。 凡 reason / error_code / verdict 是跨模块字符串契约,就该用常量或至少两处同改。
  • 某个数值恒为 0 / 恒为极值:查是否有 10 ** x 溢出成 inf、x / 0、 math.log(0)。静默比报错更可怕 —— 1/(1+inf) == 0 会让 delta 变成 0 而完全不报错。
  • 胜负被翻转:查是否有「用比分 / 残留生命 / 击杀数去猜 winner」的兜底逻辑。 契约说 winner 列是权威,就只信 winner 列;|score_a - score_b| <= 1 这种「差得少所以不可靠」 的启发式直觉是错的。
  • 连胜 / 连续成功计数被重置:查平局 / 跳过 / neutral 这类「中性结果」是不是掉进了 else 分支。 if score > 0: cur += 1 else: cur = 0 就会把 0.5 当失败。
  • 边界少一个人 / 少一条:>= 写成 >、< 写成 <=。契约写「含等于」就要有 >=。

6.2 配置加载:fail-open 是灾难源

「契约读不到就回退默认值」看着稳,实则让契约形同虚设:正常时看不出问题, 契约一改服务就按旧值静默运行。该 fail-closed 就要拒跑,退出码分层 0 干净 / 2 有真问题或拿不到事实 / 3 契约不可用,让「判不了」和「没问题」在上层可区分。

本题还演示了多来源默认值的危害:conf/engine.env 的 ENGINE_K_FACTOR=25 和契约的 SETTLE_K_FACTOR=32 并存,代码一 fallback 就用了 25, 而验证脚本按 32 校验 —— 数值差 7 却不报错。

6.3 排查操作上的坑(8 条)

  1. docker compose exec 不加 -T 直接挂死:compose 里有 tty: true,流水线非交互环境必须 -T。
  2. 改代码不重建镜像等于没改:Dockerfile 是 COPY,compose 无 bind mount, 必须 docker compose up -d --build 或 docker compose cp。
  3. __pycache__ 掩盖改动:旧字节码会让「已改」变成「没改」,跑前先 find . -name __pycache__ -prune -exec rm -rf {} +(ops/lib.sh::clean_pyc 已内置)。
  4. 改 reason 词要两处同改:我把 outcome.py 的 unknown_player 改成契约词后, season.py 的判据还是老词,探针继续报 counts.rejected:期望 2 实际 1。
  5. 空判据表要「两刀都修」:只填表不改 return,退出码依旧 0; 只改 return 不独立重算,报告自洽就还是绿。
  6. 判定别只写在判据表里:把「有 rejected/anomalies → 真问题」写在表外, ops/negative.sh N15 挖空判据表后闸门依然咬人。
  7. ops/negative.sh 会覆写 engine/:trap 'restore' EXIT 用 ops/expected-fixed/engine 覆盖, 跑完要 git diff 复查自己的版本还在。
  8. ops/verify.sh --restore 会把 engine/ 变成参考解:那是给你对照自检用的, 别拿参考解当自己的提交;验证完 git checkout -- engine/ 换回自己的版本。

6.4 自查口诀

报告的 ok 是算出来的还是写死的?BAD_FIELDS 是空元组吗? 健康检查读的是事实还是自述?拿不到契约时我判通过还是不通过? 胜负是 winner 列说了算,还是我自己猜的? 中性结果(平局 / 跳过)会不会掉进 else 被当成失败? 边界「含等于」写对了吗?同分排序键和契约一致吗? counts 每一个字段数的是「整份台账」还是「参与排名的子集」? 有没有 10 ** x 会溢出成 inf,把结果静默变成 0? 把题眼回滚之后,我的闸门会不会转红?

6.5 影响面

一切Elo / Glicko / TrueSkill 等评级系统结算(四舍五入用 round() 而非 half-up、 分差未夹取导致 inf、aborted/pending 计入排名、快照 rating 与滚动 rating 混用); 一切排名榜 / 排行榜(同分排序键、边界条件、Top-N 截断); 一切连胜 / 连续成功 / 连续失败计数(中性结果如何参与); 一切带 ok 自述字段的报表与健康检查(ok: true 硬编码、BAD_FIELDS = ()、 main() 无条件 return 0、except: return EXIT_OK); 一切多来源配置(构建期默认值 / 启动快照 / 运行时契约 —— 凡 fallback 就会静默走旧值); 以及一切**「注入缺陷但不重跑被测程序」的负向测试闸门**(测的是上一轮旧产物,等于没测)。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-21-1001-elo

分享这篇文章