「72 小时冷却」,实际最短只冷了 48 小时¶
「给个人账本引擎挑刺」系列 · 第 3/5 篇。文案承诺 72 小时,实现给 3 个自然日,截止日当天全天算"熬过",深夜判定用的是 UTC 的钟——三层时间语义错配,没有一层写在文档里。
TL;DR 选低分辨率时间是文档里明文记录的正当决定;错的是选完之后,每条依赖时间的业务规则都静默继承了实现细节。实验可复现:实际冷却时长区间是 (48h, 72h),深夜规则对东八区用户整体平移了 8 小时。
文案说 72 小时,代码说 3 天,边界比较看字符串脸色。
现象¶
产品文案承诺"72 小时冷静期"。实现是三个自然日。截止日当天成交即判"熬过"。"深夜决策"建议按 UTC 小时判定。三件事互相独立、各自错位,没有一件写在文档里——全靠读代码和跑实验发现。
我一开始的错误判断:选完粒度,就以为做完了时间设计¶
粒度选择本身有明文、且站得住:docs/DESIGN.md:57「日期 YYYY-MM-DD 字符串 + Hinnant 整数算法,不引入时区依赖」——为 no_std 硬件路线让路,日期字符串可直接比较,固件不用背 tz 数据库。
错在后半段:我把"用日期字符串"当成了一个存储层决定,没意识到它同时是一条行为规则——从那一刻起,系统里所有"几点算深夜""哪天算熬过""最短隔离多久"都继承了这个粒度和 UTC 时钟,而没有任何一处被迫显式回答:你的钟是谁的?你的边界算不算当天?
还有个小判断:字符串比较 ISO 日期"显然安全"。前 10 位截断再比较的语义,没人背得下来,也没有测试钉住。
排查与关键证据¶
第一层:粒度。 冷却截止的计算在 src/impulse.rs:46:
let cooldown_until = money::add_days(&today, (hours + 23) / 24)?;
(72+23)/24 = 3(整数除法)——"72 小时"向上取整成了 3 个自然日。文案里的小时,到数据库里已经变成天了。
第二层:边界。 "是否熬过冷却"是日期字符串的前缀比较(src/impulse.rs:123):
resolved_at.get(..10).unwrap_or("") < c.as_str()
截止日当天全天都算"已过"。实验(合成数据,docs/drafts/evidence/g3-*.txt):两个样本同日登记、同一截止日——
| 样本 | 登记时刻(UTC) | 成交时刻(UTC) | 实际隔离 | 系统判定 |
|---|---|---|---|---|
| A | 当日 09:00 | 次三日 08:00 | 71 小时 | 「熬过冷却期后才成交,决策通过了验证」 |
| B | 当日 23:50 | 次三日 00:10 | 48 小时 20 分 | 「熬过冷却期后才成交,决策通过了验证」(逐字相同) |
且月报给出:冷却战胜率 100%。推导可得:对"72 小时"的承诺,实际隔离时长落在 (48h, 72h) 区间——最短只兑现了承诺的三分之二,边界处的"小时"其实是"天"。
第三层:时区。 "深夜决策质量低"的建议吃的是 UTC 小时(src/money.rs:172 生成、src/analysis.rs:359 判定)。实验(东八区视角,docs/drafts/evidence/g4-*.txt):
| 样本 | UTC 时刻 | 本地时刻 | 深夜建议 |
|---|---|---|---|
| M | 18:00 | 凌晨 02:00(真深夜) | 不弹 |
| N | 23:30 | 早上 07:30(真清晨) | 弹:「深夜/凌晨的决策质量普遍偏低:这条冲动请留到天亮后再做决定」 |
真深夜不拦,真清晨反而劝——对主用户(东八区)来说,这条行为规则的触发窗口整体平移了 8 小时,而行为维度得分完全不受影响(两样本同为 38/100):规则错了,分数毫无察觉。
根因¶
低分辨率时间不是一个 bug,是一笔语义债:粒度换算、当天边界、UTC 小时——每一条依赖时间的业务规则都静默继承了实现细节,没有任何机制强迫它们显式声明"我用的是谁的钟、什么粒度、边界算不算当天"。选粒度是设计;把粒度的后果写回每一条规则和每一句文案,才是时间设计的全部。
约束与取舍¶
- 放弃了 chrono / 完整时间戳方案。换来:无 tz 数据库依赖、日期字符串可直接比较、同一套换算逻辑未来可编译进固件。
- 付出:小时级承诺失真(最短缩水到三分之二)、本地体感错位(深夜规则平移 8 小时)、边界语义必须自己定义并测试。
- 这个取舍本身值这个价——前提是债被显式记账。它没有。
可复用检查清单¶
- 每条涉时承诺先声明粒度(秒/分/日),文案与实现粒度一致——"72 小时法则"要么改实现、要么改文案。
- 粒度换算显式写明取整方向,并配"差一秒 / 差一天"的边界单测。
- 时间比较永远在同粒度、同表示之间进行;禁止拿时间戳的前 10 个字符冒充日期。
- "到期当天"含不含当天,用单测钉死——字符串
<比较的语义没人背得下来。 - 任何用本地语言描述时间的规则(深夜 / 月初 / 月底),先问一句:此刻用的是谁的钟?
证据与复现¶
G3/G4 实验全部输出见仓库 docs/drafts/evidence/g3-*.txt、g4-*.txt,一键重跑:bash docs/drafts/evidence/run-experiments.sh。实验为合成数据(东八区视角),不含任何真实账本。
本文是「给个人账本引擎挑刺」系列第 3/5 篇:①纯函数内核的污染 → ②没有事务的账本 → ③"72 小时冷却"的真相 → ④三种"没数据" → ⑤让政策会输的测试