跳转至

「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 小时)、边界语义必须自己定义并测试。
  • 这个取舍本身值这个价——前提是债被显式记账。它没有。

可复用检查清单

  1. 每条涉时承诺先声明粒度(秒/分/日),文案与实现粒度一致——"72 小时法则"要么改实现、要么改文案。
  2. 粒度换算显式写明取整方向,并配"差一秒 / 差一天"的边界单测。
  3. 时间比较永远在同粒度、同表示之间进行;禁止拿时间戳的前 10 个字符冒充日期。
  4. "到期当天"含不含当天,用单测钉死——字符串 < 比较的语义没人背得下来。
  5. 任何用本地语言描述时间的规则(深夜 / 月初 / 月底),先问一句:此刻用的是谁的钟?

证据与复现

G3/G4 实验全部输出见仓库 docs/drafts/evidence/g3-*.txt、g4-*.txt,一键重跑:bash docs/drafts/evidence/run-experiments.sh。实验为合成数据(东八区视角),不含任何真实账本。


本文是「给个人账本引擎挑刺」系列第 3/5 篇:①纯函数内核的污染 → ②没有事务的账本 → ③"72 小时冷却"的真相 → ④三种"没数据" → ⑤让政策会输的测试