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 处缺陷:
| # | 文件 | 缺陷 | 契约条款 |
|---|---|---|---|
| 1 | engine/contract.py | 契约读不到 / 字段缺失 / 非法整数时静默回退 DEFAULTS(K=25、门槛 7/5、榜 3)——fail-open | §8 fail-closed rc=3 |
| 2 | engine/duel/evaluate.py | expected_score 未把 opp-self 夹到 ±4000,10**大数 变成 inf,K*(score-inf) → 非法,delta 静默错成 0 | §4 |
| 3 | engine/duel/outcome.py | winner_of() 在 ` | score_a-score_b |
| 4 | engine/duel/outcome.py | reason = "unknown_player",而 season.py 只认 "unknown_opponent" → M-024 被静默吞掉 | §4 |
| 5 | engine/duel/outcome.py | UNRANKED 黑名单写法,未知 status 会被当排名局 | §1(白名单方向反了) |
| 6 | engine/platform/season.py | counts.matches 写成 ranked | §7 |
| 7 | engine/platform/season.py | "ok": True 硬编码 | §7 |
| 8 | engine/platform/season.py | 新秀榜同分按 player_id 排序 | §6(按 nickname 升序) |
| 9 | engine/platform/season.py | 连胜里平局(0.5)走 else: cur = 0,把连胜打断 | §5(平局既不增也不断) |
| 10 | engine/platform/replay.py | 回放自检只比 hearts.a,漏 hearts.b | §7 anomalies |
| 11 | engine/platform/health.py | BAD_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 一字未动(改考卷让判定变绿 = 造假)。
contract.py改成 fail-closed:删掉DEFAULTS回退路径。_env_file读失败、_int()遇 「缺字段 / 非整数」一律抛ContractError;再校验K_FACTOR>0、BOARD_SIZE>0、门槛非负、SEASON_ID非空。evaluate.py夹取:diff = max(-4000, min(4000, opp_rating - self_rating))。 四舍五入保留自定义half_up()(不能用round(),round(0.5) == 0是银行家舍入)。outcome.py:RANKED = ("finished", "forfeit")做正向白名单;winner_of()只信winner列,forfeit由actor反推胜方,删除比分差/残留生命兜底; reason 改成契约词unknown_opponent;所有 id 先strip()。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」,不再静默丢。replay.py:hearts 与 kills 都比双方。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.shN15 实证)。
改完必须重建镜像(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_playervsunknown_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 条)
docker compose exec不加-T直接挂死:compose 里有tty: true,流水线非交互环境必须-T。- 改代码不重建镜像等于没改:
Dockerfile是COPY,compose 无 bind mount, 必须docker compose up -d --build或docker compose cp。 __pycache__掩盖改动:旧字节码会让「已改」变成「没改」,跑前先find . -name __pycache__ -prune -exec rm -rf {} +(ops/lib.sh::clean_pyc已内置)。- 改
reason词要两处同改:我把outcome.py的unknown_player改成契约词后,season.py的判据还是老词,探针继续报counts.rejected:期望 2 实际 1。 - 空判据表要「两刀都修」:只填表不改
return,退出码依旧 0; 只改return不独立重算,报告自洽就还是绿。 - 判定别只写在判据表里:把「有 rejected/anomalies → 真问题」写在表外,
ops/negative.shN15 挖空判据表后闸门依然咬人。 ops/negative.sh会覆写engine/:trap 'restore' EXIT用ops/expected-fixed/engine覆盖, 跑完要git diff复查自己的版本还在。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 就会静默走旧值);
以及一切**「注入缺陷但不重跑被测程序」的负向测试闸门**(测的是上一轮旧产物,等于没测)。
