一批口算题准备发货,72 册,12.7 万道题,每道后面带答案。我每册抽 30 题出来对,用精确分数比「题面算出来的值」和「答案栏印的值」,跑完一题不差。
这批校验我跑了不止一天。既然 30 题全对,我以为可以发了。
后来全量跑了一遍,错了 231 处。
先说结论,省得你翻到最后:抽 30 题值全对,只能证明值对,证明不了答案栏写对了。卡住我的是两件事,一件是括号被吃掉,一件是值对但格式不对。
抽 30 题的时候,全是对的
抽验这步我写得挺规矩。不能用小数比,小数除法会带出一串零头,我用的是精确分数,两边化成最简分数再比。
from fractions import Fraction as F
from decimal import Decimal
def to_frac(x):
return F(Decimal(str(x))) # 不经过 float,尾数不丢
# 题面算一遍,答案栏抽一个,两边比最简分数
assert to_frac(题面结果) == to_frac(答案栏的值)
一天下来 30 册 × 30 题,900 次比较全过。这个结果在当时是可信的,不是糊弄过去的。
问题在于抽多少题是我定的,而错的分布不均匀。
全量跑完,错了 231 处
我把校验从抽 30 题改成全量跑,脚本 full_recheck.py 大概 250 行,跟出题那套引擎完全独立 —— 独立是故意的,用同一份逻辑验自己,等于没验。
| 题型 | 错题数 |
|---|---|
| 分数加减混合(二) | 144 |
| 分数的加减法 | 87 |
| 其余 70 册 | 0 |
错的题全带括号。一开始我以为是随机分布,可错 231 道、231 道全带括号,那就不是巧合。

真凶是括号不见了
出题那步会把题目拆成一串记号,答案就是拿这串记号算出来的。问题出在拆记号那一行:正则里只写了数字、分数和加减号,左右括号没进字符集。
括号就这么被丢了。后面拿到的记号流是 4/6 - 4/8 + 1/9,而题面印的是 4/6-(4/8+1/9)。
我把出题那步的求值逻辑原样搬出来跑了一遍,不是猜的:
# 分词:括号没进字符集,直接被丢掉
re.findall(r'\d+/\d+|\d+|[+\-*/]', '4/6-(4/8+1/9)')
# → ['4/6', '-', '4/8', '+', '1/9']
# 答案栏印的是这个结果
left_to_right(['4/6', '-', '4/8', '+', '1/9']) # 5/18
答案栏印的是 5/18。这道题正确答案应该是 1/18。
4/8 + 1/9 = 11/18
4/6 - 11/18 = 12/18 - 11/18 = 1/18
按 5/18 那个算法:4/6 - 4/8 + 1/9 = 1/6 + 1/9 = 5/18
家长按竖式一算就知道不对。可它不是「算错」—— 是先丢了括号,再算的没错。这两件事在报告里长得一模一样,在家长眼里一样致命。
修法是重写求值。不用左右横跳,改成按优先级一层层往下剥:先剥最外层加减,再剥乘除,再剥括号前后的正负号,剥到剥不动为止。七道单测过一遍,两册重新生成,2000 题和 3000 题全对。
值对,答案栏写的是 813/25
第二个 bug 更隐蔽。9 册里所有小数题的答案栏,写的是没约分的分数:
72.85-40.33=813/25
值是对的,813/25 精确等于 32.52。这道题我的抽验脚本查不出来,它俩比的是值,而值一直是对的。
根子在格式化那一步:它只认「答案是整数」,不是整数就往分数上写,不管题面里有没有小数点。改法是反过来看题面 —— 题面带小数点,答案栏就必须出精确小数。
这一步的教训更难接受:我拿 30 题值全对当成了「答案栏没问题」的证据,其实我验的东西只有一个维度。值对了和答案写对了,是两件事。

校验器自己先翻车
改完这三处我以为完事了,全量跑第二轮报了 1356 处错。查下去全是假的 —— 校验器第一版拿 eval 比值。
小数在这儿全是尾数问题。我当场跑了四条:
72.85-40.33 float算得 32.519999999999996 float比较=False
0.1+0.2 float算得 0.30000000000000004 float比较=False
1.005*100 float算得 100.49999999999999 float比较=False
8.4-3.6 float算得 4.800000000000001 float比较=False
四条全不等。出题可以拿小数,校验绝对不能。校验改成先把两边都转成精确分数再比,那 1356 处假错当天清零。
校验器自己还翻过四次车,根因都是同一个 —— 假设跟产物对不上。切答案的列间距阈值用 15 太松,紧凑排版下两列答案会粘成一团;分数上下两行的容差用 2.0,两层重叠导致整片路由错;负数比大小丢了前导负号;页眉识别带宽不够,某几页的页眉漏了。每一处都是先把字的实际坐标打出来,看清差在哪,再改阈值。
没暴露的 bug,不等于没有
改完再回头看那 231 道错题,有件事比错题本身更值得记。
丢括号这个 bug,不是每道带括号的题都会错。4/6+(4/8-1/9) 这种形态,括号丢掉之后按从左到右算,结果恰好是对的,两边都是 19/18。(a+b)-c 也一样。
也就是说,我抽 30 题的时候如果正好抽到这种形态,再抽 100 题也照样全过。那 231 处错是靠题型分布撞出来的,不是我验出来的。
后来我改了做法:不去碰运气,主动构造必然暴露的用例 —— 生成器只要吐出 -(...) 形态的分数题,这一类就必须过;吐不出说明这册没覆盖到,先造出来再验。这条把「有没有覆盖」从运气变成了检查项。
python tools/full_recheck.py # 全量复算,不抽题
python tools/verify_all.py # 结构校验,72 册逐册过
grep -rn "calc_frac_tokens" src/ # 确认求值入口只有一处
最后一遍是 72 册、12.7 万个答案、0 错、0 弃、0 异常册。
现在我拿到一份口算题 PDF,只问一句:带括号的题,答案栏那个括号还在不在。不在,这份文件就别发出去,一百道题里错一道也是错。这批册子还有一个别的问题 —— 体积压不动,因为题目是图片不是文字,写在PDF 太大传不上去怎么办那篇里。体积和答案是两件事,我原来总想一起解决,其实得分开。