堵住浮点数在求和、比较、ID、方差和百分比中的漏洞
目标
查看 0.1 的实际值,测量同一个数列的和如何随相加的顺序变化,并确定用什么来代替 == 做比较。统计 64 位 ID 在 JavaScript 的 double 中冲突的数对,并写出把它们作为字符串发送的函数;用 Decimal 固定舍入策略,用 Welford 求大偏移量之上的方差,做出合计为 100 的百分比,最后把这一切汇总成一个仪表盘响应。
为什么重要
float 是用 53 位做近似记录的约定,而服务规则通常要求“完全相同”和“合计要对”。这两者冲突的地方不会抛出异常,所以会很晚才被发现,比如每次重跑都相差几韩元的合计、只在浏览器里出错的 ID、负方差。分清什么是表示的局限、什么是契约(舍入位置、比较容差、传输格式),就能看出该修哪里。所以评分器不只看你写下的数字,而是把你的函数放到不在材料里的输入上重新运行,与参考实现对照。
材料
位于 /opt/fixtures/svccs/float/ 之下。只读取,不要修改。每行一个值的 .txt 用 float(줄) 或 int(줄)(占位符为该行文本)来读取。
series.txt 금액 흐름 3,010줄(float). 상쇄되는 큰 이체가 끼어 있다
pairs.csv a,b — 계산한 값과 기대한 값의 쌍(float 표기)
ids.txt 64비트 주문 ID(정수)
lines.csv line_id,amount — 소수 셋째 자리까지 있는 청구 금액(문자열 그대로 쓸 것)
samples.txt 에포크 밀리초로 찍힌 응답 시각(float)
shares.csv category,count — 결제 수단별 건수
records.csv id,amount,latency_ms,category — 대시보드 원본
params.json exact_values — 1단계에서 볼 소수 표기 목록
步骤
- 在
/root/svccs/float/exact.json中写入values(按 params.json 的exact_values的顺序,由{"text": 표기, "exact": str(Decimal(float(표기))), "hex": float(표기).hex(), "is_exact": Decimal(float(표기)) == Decimal(표기)}(占位符均为小数的文本表示)组成的列表)、sum_0_1_0_2(0.1 + 0.2的 float 值)、equals_0_3(0.1 + 0.2 == 0.3的结果)。 - 在
/root/svccs/float/floatkit.py中编写sums(xs)——返回{"forward": 앞에서부터, "reverse": 뒤에서부터, "ascending": 값이 작은 것부터(sorted), "fsum": math.fsum}(占位符依次为:从前往后、从后往前、从小到大(sorted))。前三个要用for循环中的+=来相加(不是内置 sum())。用series.txt把这四个值,以及n(行数)、distinct(四个值中互不相同的个数)、builtin_sum(内置的sum(xs))写入/root/svccs/float/sums.json。 - 在同一个文件里编写
close(a, b)=math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-9)。用pairs.csv把pairs、equal_exact(a == b的数对数)、equal_close(close为真的数对数)、equal_rel_only(math.isclose(a, b, rel_tol=1e-9),即不带 abs_tol 时为真的数对数)写入/root/svccs/float/close.json。 - 用
ids.txt把ids(个数)、unsafe_ids(绝对值大于 2^53 − 1 的数)、float_collision_pairs(float(id)变得相同的数对数——每 n 个相同的值对应 n·(n−1)/2)、ids_in_collisions(属于这种分组的 ID 数)、largest_group(最大分组的大小)写入/root/svccs/float/ids.json。在同一个文件里编写to_wire(ids),让它返回把 ID 全部以十进制字符串装入的 JSON 数组文本,并把结果写入/root/svccs/float/ids_wire.json。 - 在同一个文件里编写
half_up(text, places)——把十进制字符串 text 以ROUND_HALF_UP(并列时取离 0 较远的一侧)舍入到小数 places 位之后的字符串(Decimal(text).quantize(...),不经过 float)。用lines.csv把lines、sum_of_rounded_lines(逐行 half_up(…, 2) 之后的值之和)、rounded_total(把 amount 全部用 Decimal 相加后再 half_up(…, 2))、difference(前者 − 后者)以字符串,lines_builtin_round_differs(round(float(amount), 2)与float(half_up(amount, 2))不同的行数)以整数写入/root/svccs/float/rounding.json。 - 在同一个文件里编写
welford(xs)——返回{"n", "mean", "var"}(总体方差,除以 n),用 Welford 的一次遍历更新来计算。用samples.txt把n、mean(真实均值)、var_naive(用循环累加的 Σx²/n − (Σx/n)²)、var_welford、var_exact(用fractions.Fraction计算的总体方差再转成 float)写入/root/svccs/float/variance.json。 - 在同一个文件里编写
largest_remainder(counts, total)——给每个份额c·total/Σc取整数部分,再把剩下的名额按小数部分从大到小(相同时靠前的项目优先)逐个加 1,得到的整数列表(当 Σc 为 0 时全部为 0)。用shares.csv把categories、naive(各自round(c·100/Σc))、naive_sum、largest_remainder(total=100)、largest_remainder_sum写入/root/svccs/float/percent.json。 - 在同一个文件里编写
summarize(records),并用records.csv写出/root/svccs/float/dashboard.json。records 的每一行 = records.csv 的一行{"id", "amount", "latency_ms", "category"}(值可能是字符串,也可能是数字)。要返回:ids(每个 id 的十进制字符串,按输入顺序)、total_amount(逐行 half_up(amount, 2) 之和的字符串)、latency_mean、latency_var(welford)、categories(互不相同的 category 排序后的列表)、share_pct(对按 categories 顺序的条数做 largest_remainder(…, 100))。
参考
- 评分器会导入
floatkit.py。读取文件并生成结果的代码,请放在单独的脚本里,或者用python3 - <<'PY'。 - JSON 用读回 float 后得到相同值的写法(repr)来写。评分器会精确对照各个和与数对数。
- 想亲眼看 JavaScript 是怎么读的,可以像
node -e 'console.log(JSON.parse("[1873000000000000001]")[0])'这样确认。评分器用json.loads(text, parse_int=float)来模拟同样的事。 - 常见错误:用
==比较;把内置sum()写成“从前往后加的和”;把 ID 读成 int 然后报告冲突为 0;用round()或Decimal(float(x))代替舍入策略;直接使用教科书的方差公式;把合计为 99 的百分比原样输出。 - 产出会在会话结束后消失。需要的话请另行保存。
查看 0.1 的实际值
对 params.json 的 exact_values 中的每一项,把实际存储的值、十六进制表示、是否精确,写入 /root/svccs/float/exact.json 的 values,并一并写入 sum_0_1_0_2、equals_0_3。
像 Decimal(0.1) 这样把 float 转成 Decimal,二进制值就会被无损地转成十进制。再看它是否与用字符串构造的 Decimal("0.1") 相等,就能知道这个写法在 float 中是否精确。只有分母是 2 的幂的小数才是精确的。
相加的顺序会改变合计
在 /root/svccs/float/floatkit.py 中编写 sums(xs),并用 series.txt 把 forward、reverse、ascending、fsum、n、distinct、builtin_sum 写入 /root/svccs/float/sums.json。评分器会用变形数列调用 sums。
前三个和用 for 循环的 += 来加。Python 3.12 的内置 sum() 对 float 求和使用了更精确的算法,可能与循环得到的值不同。差别的原因,是在大值 ±10^15 旁边,小额金额的低位被截掉了。
不用 == 该用什么比较
在 floatkit.py 中编写 close(a, b),并用 pairs.csv 把 pairs、equal_exact、equal_close、equal_rel_only 写入 /root/svccs/float/close.json。评分器会用混有接近 0 的数对的变形数对调用 close。
math.isclose 的默认 abs_tol 是 0.0,所以与 0 比较时,非零的值始终为假。如果是余额应为 0 的检查,就要同时给出 abs_tol。equal_rel_only 是故意去掉 abs_tol 来统计的。
64 位 ID 在浏览器里发生重叠
用 ids.txt 把 ids、unsafe_ids、float_collision_pairs、ids_in_collisions、largest_group 写入 /root/svccs/float/ids.json,并在 floatkit.py 中编写 to_wire(ids),把它的结果写入 /root/svccs/float/ids_wire.json。评分器会像 JavaScript 那样把 to_wire 的结果按 double 读取来检查。
Python 把整数读成 int,所以看上去毫无问题。必须像接收方那样用 float(id) 转换,再把相同的值归为一组,冲突才会显现。在 2^60 附近,相邻 double 的间隔是 256。传输不要用 JSON 数字,而要用字符串。
用 Decimal 固定舍入策略
在 floatkit.py 中编写 half_up(text, places),并用 lines.csv 把 lines、sum_of_rounded_lines、rounded_total、difference、lines_builtin_round_differs 写入 /root/svccs/float/rounding.json。评分器会用变形金额和多种位数调用 half_up。
Decimal("2.675") 恰好是 2.675,而 Decimal(2.675) 是 2.67499…。要直接从字符串构造再 quantize。round() 在并列时舍入到偶数一侧,而无法用 float 表示的值,甚至连并列都算不上。负数的 half-up 是离 0 较远的一侧。
大偏移量之上的方差
在 floatkit.py 中编写 welford(xs),并用 samples.txt 把 n、mean、var_naive、var_welford、var_exact 写入 /root/svccs/float/variance.json。评分器会用不同偏移量的变形样本调用 welford。
Σx²/n 与 (Σx/n)² 是 1.79e12 的平方附近的两个大数,相减时有效数字几乎全部消失。Welford 一点点移动均值,只累加偏差,所以避开了这种抵消。var_exact 是把每个 float 转成 Fraction 来计算的。
合计为 100 的百分比
在 floatkit.py 中编写 largest_remainder(counts, total),并用 shares.csv 把 categories、naive、naive_sum、largest_remainder、largest_remainder_sum 写入 /root/svccs/float/percent.json。评分器会用混有并列的变形条数调用 largest_remainder。
如果用 float 计算份额,相同的余数会有细微差别,使并列的判定发生动摇。用 fractions.Fraction 来计算份额就是精确的。并列时靠前的项目优先。
汇总成一个仪表盘响应
在 floatkit.py 中编写 summarize(records),并用 records.csv 写出 /root/svccs/float/dashboard.json。评分器会用变形记录调用 summarize,并像 JavaScript 那样把结果按 double 读取,看 ID 能否保留下来。
直接使用前面步骤的 half_up、welford、largest_remainder。ID 是字符串,金额合计是 Decimal 的字符串。如果用教科书公式求延迟,可能会得到负数。