跳转至

我的「纯函数内核」,是被自己的评分引擎污染的

「给个人账本引擎挑刺」系列 · 第 1/5 篇。文档承诺内核不碰 IO,评分主入口的签名里却躺着一条数据库连接。纯度不是一次性丢掉的,是逐层衰减的。

TL;DR 设计文档承诺"内核不碰 IO",评分主入口的签名里就躺着一条数据库连接:53 处查询点、三层纯度梯度、两处文档漂移。这篇讲它是怎么发生的,以及怎么让架构不变量从"愿望"变成可机检的事实。

「分层」是架构文档里最便宜的两个字。

现象

我的设计文档里有两句很硬的承诺:

「内核不碰 IO 和浮点,前端(CLI/TUI/AR/硬件固件)都只是壳。」—— docs/DESIGN.md:105

「v1(当前)单 crate,模块即分层:money/analysis/sim(纯逻辑)|db(SQLite)|cli(薄壳)」—— docs/DESIGN.md:110

然后是评分引擎主入口的签名:

pub fn build_review(conn: &Connection, ctx: &PurchaseCtx) -> Result<Review, String>
// src/analysis.rs:256

五维评审里,预算维度现查预算表和当月聚合,应急金维度现查刚需类目和可动资金,行为维度现查历史满意度——打分路径上整整 53 处 conn。

更有意思的是,纯度不是一次性丢掉的,是梯度衰减的:

  • money:全纯;
  • sim:半纯——baseline() 查库(src/sim.rs:28),run() 是纯函数;
  • analysis:打分本身就查库。

污染沿着分层逐层渗漏,越靠近业务,越脏。

我一开始的错误判断:把「分层」当成了「纯度」

这个判断不需要回忆——它白纸黑字写在我自己的设计文档里,上面那两行引用就是物证。拆开看,它叠了三层想当然:

  1. 把模块目录当成了依赖方向。 模块叫 analysis,不等于它不依赖 db;名字是愿望,签名才是事实。
  2. 把"不碰 IO"理解成了"不做写操作"。 SELECT 也是 IO,而且是最隐蔽的一种——它让函数的行为取决于数据库里恰好有什么,测试从此必须先造一个世界。
  3. 把"以后拆 crate 时再清理"当成了零成本推迟。 隐式 IO 的数量随维度线性增长:每加一个评分维度,就多两三个查询点。这笔债有利息。

排查与关键证据

证据一:文档 vs 签名,直接对照。 上面两处引用均可逐字核对,不需要任何实验。

证据二:IO 泄漏点清单。 grep -c conn src/analysis.rs = 53。评分路径上的查询点:

函数 打分时现查什么
budget_dim(src/analysis.rs:107) 预算 JSON、当月类目聚合
cashflow_dim(src/analysis.rs:151) 刚需类目、近三月聚合、可动资金
behavior_dim(src/analysis.rs:191) 类目历史满意度
build_review(src/analysis.rs:256) 定投年化、时薪;建议引擎里把预算、聚合再查一遍

证据三:同一作者在 sim 里做对过。 sim::run 的签名只吃 Baseline 和 Policy 两个值,一个数据库连接都不碰(src/sim.rs:81)——查库被隔离在入口的 baseline() 里。这不是能力问题,是意识问题:同一个仓库里,有人(我)已经演示过正确做法,analysis 只是没被要求照做。

证据四:文档声称与仓库实际的漂移。 docs/ROADMAP.md:18 写「15 项测试全绿」,实际 cargo test 是 19 个单元测试 + 1 个端到端测试;docs/EVALUATION.md:106 写「~2k 行纯函数」,实际 wc -l 全仓 3357 行(src 3294 + tests 63)。没有任何机制校验的声称,漂移只是时间问题——本文的被告包括我自己写的设计文档。

证据五:连 money 也不是完全干净。 today_ymd() / now_iso() 直接读 std 系统时钟(src/money.rs:158)。对"核心可编译到 no_std"的目标来说,"现在几点"也必须是注入的上下文——内核连时间都不该自己拿。

根因

不是"忘了抽象",而是维度的输入天然长在库里:预算余量、历史满意度、月度聚合,都是"此刻的上下文",不是这笔购买自身的属性。v1 没有给"上下文"一个显式形状(比如一个 ReviewContext 结构体),于是每次新增维度,新查询都就近水楼台——IO 从签名渗进内核。

不变量在 v1 破坏后,v2 拆 core crate 的成本不是重构,是考古:每一处隐式 IO 都要被找出来、变成显式字段。越晚拆,越拆不动。

约束与取舍

  • 放弃了"现在就拆 core crate"(docs/ROADMAP.md 明文 v2 才拆)。换来:单 crate 的迭代速度,让理论引擎先跑起来验证。付出:不变量持续被侵蚀——这笔债有利息。
  • 放弃了"立刻引入 ReviewContext 结构体"。换来:现有签名不动。付出:每加一个维度要多传一坨参数的痛苦被推迟并累加。
  • 保留的底线:sim::run 和整个 money 至今保持纯函数——证明这不是做不到,是没做全。

可复用检查清单

  1. 每条写进文档的架构不变量,必须能映射到一条可机器检查的东西(签名约束 / 编译开关 / CI 静态检查),否则它只是愿望——而愿望会漂移(见证据四)。
  2. 内核函数新增依赖时先问:这是"数据"还是"上下文"?数据走参数,上下文必须显式注入,不允许函数体内现查。
  3. CI 放最便宜的守门员(哪怕一条 grep 禁用符号),成本远低于一次人工审查。
  4. 给"拆分"定量化准入标准(例如 IO 泄漏点数阈值),到期用数字说话,不凭感觉。
  5. 文档里"已完成 / 已保证"的结论,每季度对照代码复核一次——本文就是现成的反例。

证据与复现

文中所有实验可一键重跑:仓库内 docs/drafts/evidence/run-experiments.sh;grep -c conn src/analysis.rs、cargo test、wc -l src/*.rs tests/*.rs 均可直接验证。文中不含任何真实账本数据。


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