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

排班器:两个上限互相拿反 + 连续上班判定把结论乘回事实,而自述/健康检查/退出码全绿

第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_PERSON2每人一周最多上几个班
SCHED_MAX_PEOPLE_PER_SHIFT1单个班次最多几个人
SCHED_CONSECUTIVE_POLICYforbid连续上班(同一人相邻两天都有班)一个都不允许

员工 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 检查。

版权声明

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

分享这篇文章