排班器:两个上限互相拿反 + 连续上班判定把结论乘回事实,而自述/健康检查/退出码全绿
第127题 · 分支
2026-10-15-1601· 2026-10-15 · 状态:已完成 技术栈:Python 3.11 标准库、Docker Compose、外部独立探针(contract probe) 关键词:两个上限互相拿反 max_shifts_per_person max_people_per_shift、判定结果写回事实、连续上班判定 consecutive、per_person_shift_count 错、空判据表 BAD_FIELDS 假绿健康检查、degraded false、退出码恒 0、fail-closed 退出码分层、CONF_DIR 契约不可用、外部独立探针、排班器 shift scheduler
问题背景
app/ 是一个周排班器:读「员工清单」+「班次需求」,按技能给每个班次挑人(贪心取前 need 个候选人),统计每人上了几个班、有没有连续上班,最后出回执报告。
契约 conf/schedule.env(只读考卷)规定:
| 字段 | 值 | 含义 |
|---|---|---|
SCHED_MAX_SHIFTS_PER_PERSON | 2 | 每人一周最多上几个班 |
SCHED_MAX_PEOPLE_PER_SHIFT | 1 | 单个班次最多几个人 |
SCHED_CONSECUTIVE_POLICY | forbid | 连续上班(同一人相邻两天都有班)一个都不允许 |
员工 4 人(s-01 bar/floor、s-02 kitchen、s-03 bar、s-04 floor/kitchen),班次 14 个、需求共 15 人份,其中 sh-m6(周日 bar)需要 2 人。
跑一轮 python3 -m app.scheduler,看上去一切正常:verdict: "block"、reason: "over_limit"、退出码 0;健康检查 {"degraded": false, "issues": []}、退出码 0;ops/repro.sh 里 scheduler / health 退出码全 0。
但按契约独立重算,三处数量/事实是错的:s-01 一周班次数报 7(实际 10)、s-03 报 0(实际 1)、over_limit 明细写「上限 1」(应为每人上限 2)、sh-m6 明明排了 2 人(> 单班上限 1)却一条 over_staffed 都没报。
复现步骤
git checkout 2026-10-15-1601
cd scenario/2026-10-15-1601
docker compose up -d --build
docker compose exec -T schedule bash # 非交互必须 -T
# 容器内
bash ops/repro.sh ; echo rc=$? # 脚本以探针退出码为退出码,问题态 rc=1(预期信号)
bash ops/verify.sh ; echo rc=$? # 问题态 PASS=7 FAIL=11 / RESULT: BAD
问题态关键输出:
{"assigned_slots": 15, "consecutive_pairs": ["s-01","s-02"], "ok": false,
"per_person_shift_count": {"s-01": 7, "s-02": 4, "s-03": 0, "s-04": 0},
"reason": "over_limit", "required_slots": 15, "verdict": "block",
"violations": [{"code":"over_limit","detail":"一周 10 个班 > 上限 1","subject":"s-01"},
{"code":"over_limit","detail":"一周 4 个班 > 上限 1","subject":"s-02"},
{"code":"consecutive_forbidden","detail":"连续上班 2 人:s-01,s-02","subject":"s-01,s-02"}]}
scheduler rc=0
health rc=0 # 空判据表,什么都不查,照样绿灯
不一致:per_person_shift_count.s-01:期望 10 实际 7
不一致:per_person_shift_count.s-03:期望 1 实际 0
不一致:violations:期望 [over_limit(上限 2)×2, over_staffed(sh-m6), consecutive_forbidden]
实际 [over_limit(上限 1)×2, consecutive_forbidden]
RESULT: BAD(不一致 3 条) probe rc=1
独立手算(先把事实算清楚):
sh-m6 dow=6 bar need=2 -> ['s-01','s-03'] # 其余 13 班各 1 人
手算 per_person: {'s-01': 10, 's-02': 4, 's-03': 1, 's-04': 0}
days: {'s-01': [0..6], 's-02': [1,2,4,5], 's-03': [6]}
根本原因
三个互相掩护的缺陷 + 一层假绿。
根因一(题眼):两个「上限」互相拿反 —— 三个数量口径被当成一回事
app/scheduler.py:
for pid, cnt in sorted(per_person.items()):
if cnt > local["max_people_per_shift"]: # ← 拿「单班上限」当「每人一周上限」
... "一周 %d 个班 > 上限 %d" % (cnt, local["max_people_per_shift"])
for a in assignments:
if len(a["people"]) > local["max_shifts_per_person"]: # ← 拿「每人上限」当「单班上限」
...
2 和 1 是两个不同主体的上限:一个是「人」的周上限,一个是「班次」的人数上限。代码把它们对调使用,于是:
- 判「每人超没超」时用的是
1→ 违约明细写着「一周 10 个班 > 上限 1」(s-01真超了每人上限 2,却因为用的是 1 而看起来只是「数字不同」); - 判「单班超没超」时用的是
2→sh-m6排了 2 人,2 > 2为假,over_staffed一条都不报。
两个都叫「上限」的整数被互换,量纲(主体)不同,语言层面完全无法察觉,错误 100% 静默。
根因二(题眼):「是不是连续上班」的判定结果被乘回了「上了几天班」
touched = 0
for d in sorted(ds):
if any(staff.adjacent(d, other) for other in ds if other != d):
touched = 1
break
days[pid] = set(range(len(ds) * touched)) # ← 判定结果(0/1)乘回事实
for d in sorted(days[pid]): # 相邻判定改用被改写后的 days
if any(staff.adjacent(d, other) for other in days[pid] if other != d):
consecutive.append(pid)
break
len(ds) * touched:判定为「不连续」的人 days 被清零成空集。连带后果:
- 逐人班次数改用被改写后的
days长度报("per_person_shift_count": {pid: len(days.get(pid, ())) ...})→s-01报7(实际 10)、s-03报0(实际 1); - 相邻判定改用被清零的
days→ 名单与事实脱钩; - 而报告里
consecutive_pairs仍写着["s-01","s-02"],看着「连续上班抓到了 2 人」,抓的其实是一套已经作废的口径。
一句话:它是不是连续上班(结论)被乘回了它上了几天班(事实),事实被结论覆盖。
根因三(假绿层):空判据表 + 无条件返回
app/health.py:
BAD_FIELDS = () if False else () # for 一次都不循环 → issues 恒空 → degraded 恒 false
def check():
...
return [], extra, EXIT_OK # 连「拿不到报告」都判健康
def main():
return 0
四个绿灯没有一件指向真相:rc=0(只证明进程没崩)、verdict/ok 字段本身、degraded:false、空判据表。健康检查退化成「给自己发体检合格证」——比没有检查更危险,因为它静默地骗人。
解决办法
只改 app/scheduler.py 与 app/health.py;conf/、docs/、client/、input/、ops/verify.sh、ops/negative.sh 一字未动(改契约/探针让判定变绿 = 造假)。
1) 两个上限各自对号入座(名字里带主体,别只看「上限」)
for pid, cnt in sorted(per_person.items()):
if cnt > local["max_shifts_per_person"]: # 每人一周 → per_person
violations.append({"code": "over_limit", "subject": pid,
"detail": "一周 %d 个班 > 上限 %d"
% (cnt, local["max_shifts_per_person"])})
for a in assignments:
if len(a["people"]) > local["max_people_per_shift"]: # 单班人数 → per_shift
violations.append({"code": "over_staffed", "subject": a["shift"],
"detail": "%d 人 > 上限 %d"
% (len(a["people"]), local["max_people_per_shift"])})
2) 判定只在事实上做一次,绝不写回
consecutive = sorted({pid for pid, ds in days.items()
if any(staff.adjacent(d, other)
for d in ds for other in ds if other != d)})
删掉 days[pid] = set(range(len(ds) * touched)) 整段;逐人班次数直接 "per_person_shift_count": per_person(来自排班事实,不读 days)。
3) 健康检查:非空判据表 + 事实层复核 + 退出码分层
BAD_FIELDS = ("contract", "staff_count", "shift_count", "required_slots",
"assigned_slots", "assignment.people", "per_person_shift_count",
"consecutive_pairs", "violations", "verdict", "reason", "ok")
def check():
contract, issues = _load_contract() # 读不到 / 口径非法 → EXIT_CONTRACT=3
if contract is None:
return issues, {}, EXIT_CONTRACT
try:
staff_pool, shift_pool, report = ... # 拿不到事实 → EXIT_PROBLEM=2
except (OSError, json.JSONDecodeError) as exc:
return ["拿不到事实:%s" % exc], {}, EXIT_PROBLEM
want = _recompute(staff_pool, shift_pool, contract) # 按契约独立重算
... # 逐条比对契约字段/汇总/逐班次取人/逐人班次/连续名单/违约清单/verdict/reason/ok
要点:
_recompute()故意不复用app/scheduler.py—— 复用等于「自己治自己」,判据退化成自述字段自证;- 判据落在「外部能按契约独立重算」的事实上(契约 + 员工清单 + 班次需求 + 报告,报告只被对照);
- 退出码分层
0干净一轮 /2有真问题或拿不到事实 /3契约不可用,「判不了」≠「没问题」(fail-closed)。
验证
find . -name __pycache__ -type d -prune -exec rm -rf {} + # 必清字节码
docker compose up -d --build # 无 bind mount,必须重建
docker compose exec -T schedule bash
bash ops/repro.sh ; echo rc=$? # rc=0,探针 RESULT: OK(不一致 0 条)
bash ops/verify.sh ; echo rc=$? # PASS=18 FAIL=0 / RESULT: OK
bash ops/negative.sh ; echo rc=$? # PASS=10 FAIL=0 / RESULT: OK(闸门有牙)
修复态关键输出:
per_person_shift_count: {"s-01": 10, "s-02": 4, "s-03": 1, "s-04": 0}
violations: over_limit(s-01, 上限 2) / over_limit(s-02, 上限 2)
/ over_staffed(sh-m6, 2 人 > 上限 1) / consecutive_forbidden(s-01,s-02)
health rc=0 → {"assigned_slots":15,"degraded":false,"issues":[],"max_people_per_shift":1,
"max_shifts_per_person":2,"per_person_shift_count":{"s-01":10,...},"verdict":"block"}
RESULT: OK(不一致 0 条)
负向验证逐条(全部 PASS):N1 题眼一回滚 → 探针 rc=1 / health rc=2;N2 反向回滚 → rc=1;N3 题眼二回滚 → rc=1;N4 自述被篡改 → health rc=2;N5 判据表挖空+退出码恒 0 → 对坏数据仍 rc=0(假绿实证);N6 契约不可用 → health/scheduler rc=3;N7 契约口径非法 → rc=3。
额外对抗实验(事实层不变、只篡改报告,health 全部 rc=2):verdict="ok"、consecutive_pairs=[]、violations=[]、max_shifts_per_person=99、assigned_slots=999、assignments[0].people=["s-04"] —— 证明判据落在事实层,不是自述自证。
退出码分层直采:干净一轮 rc=0;CONF_DIR=/tmp/nope → rc=3;mv state/schedule.json → rc=2;SCHED_CONSECUTIVE_POLICY=loose → rc=3。
从零重建复跑:docker compose down -v && docker compose up -d --build 后 verify.sh 仍 PASS=18 FAIL=0、negative.sh 仍 PASS=10 FAIL=0,grep 确认容器里跑的就是手写修复版(不是靠 cp 蒙的)。
经验与避坑
- 两个「上限」互相拿反是最难自查的一类 bug:
max_shifts_per_person=2与max_people_per_shift=1都是正整数、都叫「上限」、比较运算符长得一模一样。自检口诀:「这个上限的主体是人,还是班次?」 —— 判第 X 个主体是否越界时,用的必须是带同主体后缀的那个上限。 - 判定结果绝不能写回事实:
days[pid] = set(range(len(ds) * touched))像是「因为它不连续,所以把它上的天数清零」。凡是看到事实 * 结论、事实 = 事实 * 布尔这种模式(age * alive、size * exists、count * valid),一律高度怀疑。判定只能用来决定结论,不能覆盖输入。 - 汇总字段必须与事实同源:
per_person_shift_count应该直接来自排班累计per_person,而不是从「某个被判定过程改写过的中间结构」再数一遍。中间的days只该用来判连续,不该拿来报数。 - 空判据表 = 没有检查:
BAD_FIELDS = ()时for循环体一次都不执行,代码看着像有检查,其实在空转。判据表必须有内容,且每条都对应一个外部能自己算一遍的事实。 - 绿色信号分三类:
进程没崩(弱)/自己写的自述字段(无判别力)/能被外部按契约独立复核的事实(强)。ok:true、degraded:false、rc=0、「空判据表」都属于前两类。 - 健康检查不能复用业务代码重算:那等于自己治自己。要按契约文字独立实现一遍(哪怕复制算法),并且不复用被测模块。
- 「判不了」≠「没问题」:契约不可用、报告读不到都要判不通过(fail-closed),退出码分层
0/2/3让上层能区分「跑得好」和「没跑成」。except (OSError, json.JSONDecodeError): return EXIT_OK是典型的 fail-open 灾难源。 - 只修题眼不够:不修健康检查,下次同类错误依然四个绿灯全亮。「修好了 bug」只解决了这一次,「修好了闸门」才解决了下一次。
- 工程坑:①
ops/repro.sh以探针退出码为退出码,问题态rc=1是预期信号,用; echo rc=$?接住别用&&;② 非交互docker compose exec必须加-T;③app/是COPY进镜像、compose 无 bind mount,改了宿主机文件必须docker compose up -d --build重建(否则出现「容器内毫无变化」的迷惑现象);④ 改完app/*.py必须清__pycache__;⑤ops/negative.sh收尾会apply_reference()把app/换成参考解,自研修复版要先备份(本次备份到/tmp/myfix/);⑥ops/verify.sh --restore/--fix可一键切换问题态/参考解,但切完记得还原成自己的修复版;⑦ 别改conf/、docs/、client/、ops/、input/,改它们让判定变绿 = 造假;提交前git diff --name-only -- conf docs client ops input应为空。 - 题面文字可能与实际输入不符:README 说「真正连续的
s-03(周一+周日,一周首尾相邻)」是缺陷二的证据,但实际输入下sh-m0(周一 bar)被候选顺序在前的s-01拿走,s-03只上了周日一天 —— 契约规则(周一与周日相邻)是对的,只是这个输入没触发。判定按契约实现,不按题面文字硬凑。
自查口诀:这个上限的主体是人还是班次?我乘的是「事实」还是「结论」?汇总字段是从事实数出来的,还是从被改写过的中间结构数出来的?判据表是不是空的?健康检查读的是事实还是自述?拿不到数据时我判通过还是不通过?把题眼回滚之后,我的闸门会不会转红?
同类影响面:一切**「多个同类型数值上限并存、主体不同」的场景(每人周上限 vs 单班人数上限、并发上限 vs 请求上限、连接池 max vs 应用并发、maxSurge vs replicas、速率 vs 突发容量);一切「判定结果写回事实」的实现(age * alive、size * exists、状态机把结论覆盖到原始输入、ORM 把计算字段存回源字段);一切「汇总从中间结构而非事实源统计」的报告;一切「自述字段自证 + 空判据表 + 退出码恒 0」的假绿健康检查与巡检脚本**;一切**「读不到配置/数据就静默判通过」**的 fail-open 检查。
