主题
40 · RB → RVH:学习循环三端一致性(含一个确认的排期缺陷)
物理位置:本文件在 RB 仓
~/reading-browser/docs/cross-end/40-rb-learning-loop-handoff.mdRVH 会话读法:Read ~/reading-browser/docs/cross-end/40-rb-learning-loop-handoff.md日期:2026-08-27 · RB 基线0.1.0-dev.10· 编号接README.md总表(承 39 之后) 全文所有跨仓引用都是~-锚定绝对路径(CLAUDE.md §9 跨仓库路径约定)。
0. 一句话
RB 建了「学习循环」专题验证(~/reading-browser/docs/verification/learning-loop.md), 机械层当场抓到一个确认的跨端排期缺陷:
RVH 写进
learning_entries.next_review_date的是 timezone-naive 的本地时间串, 而同一次写回的所有兄弟列都是 UTC。 后果:UTC+8 下那张卡在 RB 上晚 8 小时才到期;且在两端的 merge 里 RVH 侧恒赢。无报错、无不一致可观测、单端自测永远看不出来。
修法是一行(§3)。但请先读 §2 的证据,因为这个结论有两条独立断言在支撑, 而六道既有守卫在同一批数据面前全绿。
1. 你需要立刻做的一件小事(30 秒)
RB 改了黄金向量真相源 ~/reading-browser/docs/cross-end/sm2-golden-vectors.json —— 只改了两个说明字段,没有动任何一条 expected:
_consumers.rvh_dart此前写着待新会话:lib/services/sm2_service_test.dart。 那个文件不存在,而且是 RB CLAUDE.md §9 刚订正过的同一个幽灵路径 (它已经把一个会话带偏过一次)。现改为实际的test/sm2_golden_test.dart+lib/core/algorithms/sm2_algorithm.dart。_doc里「待新会话把本文件复制进 RVH 测试」的措辞已经过时(你们早做完了)。
请重新 cp 一次副本:
bash
cp ~/reading-browser/docs/cross-end/sm2-golden-vectors.json \
~/reading_vocab_helper/test/fixtures/sm2-golden-vectors.json这件事本身现在会红:RB 新增了
scripts/learning-loop-verify.shL1, 断言你们那份副本与真相源 byte-equal。你们test/fixtures/README.md里写的 「方便未来/cross-end-check加 byte-equal diff 兜底」—— 就是它,落地了。 顺带说明为什么需要它:CLAUDE.md 里「改任一条 expected 会让三端测试同步红」 只对 RB 的两端成立(它们读同一个文件),你们读的是副本, 副本落后时你们那边会继续全绿。
2. 主项:next_review_date 写的是本地时钟
2.1 证据(生产库,只读)
user_learning_entries 里所有复习过的行:
| word | last_review_date | next_review_date | reps |
|---|---|---|---|
| unlawful | 2026-08-26T09:02:44.753525+00:00 | 2026-08-27T09:02:44.753776+00:00 | 1 |
| community | 2026-08-26T09:02:50.129672+00:00 | 2026-08-27T09:02:50.129793+00:00 | 1 |
| and a half | 2026-08-26T09:10:23.177665Z | 2026-08-27T17:10:23.164407 | 1 |
| campus | 2026-08-26T09:10:26.594492Z | 2026-08-27T17:10:26.586198 | 1 |
| speak | 2026-08-26T17:43:59.849661Z | 2026-08-28T01:43:59.834251 | 1 |
前两行(RB 复习的,+00:00 是 chrono):next − last 正好 24h = interval 1 天。✅ 后三行(RVH 复习的,Z 是 Dart nowUtc()):next − last = 32h,多出的正是 UTC+8。❌
next_review_date 是共享表 34 个 text 时间戳列里唯一带裸值的一列。 其余 33 列 —— 包括你们在 doc 37/39 里刚收拾过的全部 deleted_at / updated_at —— 100% 合规。裸值分两类:「从未复习」哨兵 1970-01-01T00:00:00.000(无害, 见 §3.2 与 §4 的风险条件),以及上表那几张真实复习过的卡(有害)。
⚠️ 具体行数请自己跑一遍,别引用本文里的数字。 写作当天是 45 行里 29 行, 一天后已是 73 行里 54 行 —— 这个仓有多个会话在往库里存词,计数每天都在变。 唯一稳定的判据是那句**「34 列里唯一的一列」**和「RVH 复习的卡偏一个时区、 RB 复习的卡正好 interval 天」。现查:
~/reading-browser/scripts/learning-loop-verify.sh(M1 形状 + M3 语义两条)。
⚠️ 表里
source_platform说and a half/campus是rb,别信那一列: RB 的push.rs对 learning_entries 硬编码"source_platform": "rb", 它是「最后一个 push 的人」不是来源。归因看时间戳形状(Z= Dart /+00:00= chrono)。 RB 侧已把这个事实做成每轮打印的一条(M4),你们那边若有按它做的统计,也请复核。
2.2 根因(单点,一个 caller)
~/reading_vocab_helper/lib/core/algorithms/sm2_algorithm.dart:159
dart
/// 计算下次复习日期
DateTime getNextReviewDate() {
return DateTime.now().add(Duration(days: interval)); // ← 本地时区
}生产上只有一个调用方: ~/reading_vocab_helper/lib/features/vocabulary_notebook/data/datasources/mastery_tracking_datasource.dart:140。 而同一个 copyWith 里的兄弟列全用 nowUtc()(lastReviewDate / updatedAt)—— 所以这不是一处风格差异,是一处遗漏。
落库时 LearningEntryModel.toDatabase() 走 nextReviewDate?.toIso8601String(): Dart 对 local DateTime 输出不带时区后缀,对 UTC DateTime 才输出 Z。 push 阶段 sync_repository_impl.dart:558 原样透传 ('next_review_date': r['next_review_date']),于是裸串直达 Supabase。
2.3 后果(两条,都是静默的)
① 到期时刻错一个时区。 RB get_due_cards 用 next_review_date <= '<now rfc3339>' 的字符串比较(该列在两端都是 TEXT)。 UTC+8 下裸串字典序显得更晚 ⇒ 卡片晚 8 小时到期。 你们 statistics_repository_impl.dart 的 due 统计同理。
② merge 里恒赢。 两端的合并判据都是 remote_next > local_next(RB common.rs::should_take_remote_srs / RVH sync_repository_impl.dart:943-946),同样是字符串序。 裸串在正时区恒显更晚 ⇒ 同一张卡在两端都复习过时,RVH 的 SM-2 状态无条件覆盖 RB 的。
③ 方向会翻转。 负时区(美洲用户)下裸串显得更早 ⇒ 卡片提前到期、 且 RVH 在 merge 里恒输。在 UTC+8 单地测试永远只看到一半。
2.4 为什么说这个症状「早就被看见过一次」
RB ~/reading-browser/src-tauri/src/commands/sync/common.rs 的 mark_synced 注释写着:
「synced_at 用每行自己的 updated_at …… 避免本地系统时间与 RVH 推上来的 timezone-naive 字符串字典序错位,造成 dirty 永久不归零、push 死循环」
也就是说「RVH 会推裸串」这件事当时就被观察到了,但只在 synced_at 那一处 打了补丁 —— 没人回头问「那这些裸串本身对不对」。这一轮补上了那一问。
同理,RB should_take_remote_srs 的注释至今写着 「next_review 用字典序比较(RFC3339 UTC 归一后 = 时间序)」—— 那个前提正是被裸串打破的那一个。
3. 请求 RVH 做的(四项)
3.1 🔴 主项:getNextReviewDate() 改用 UTC
dart
// lib/core/algorithms/sm2_algorithm.dart
import 'package:reading_vocab_helper/core/utils/datetime_utils.dart'; // nowUtc()
DateTime getNextReviewDate() {
return nowUtc().add(Duration(days: interval));
}请顺手加一条会红的测试(test/core/algorithms/sm2_algorithm_test.dart:450 已经有一个 getNextReviewDate() 用例,但它不检查 UTC):断言 getNextReviewDate().isUtc == true,且 toIso8601String() 以 Z 结尾。 ⚠️ 只断言 isUtc 不够 —— 那验不到落库那一步; 真正会退化的是「有人后来把它 .toLocal() 了一下再存」。
3.2 存量数据:请裁定,别默认
那 3 行真实卡 + 26 行哨兵,修完代码不会自愈(不像 doc 38/39 的回声环 —— 那个靠"每轮被拉回来"自愈)。裸串无法反推当时是哪个时区写的,所以 「精确修复」在信息上就不成立。三个选项,请你们裁定并写回执:
| 选项 | 说明 |
|---|---|
| A. 不动(RB 倾向) | 3 行会在下次复习时自动写成正确的 UTC 串;26 行哨兵是 1970,两端都判「立即到期」,字面差异无行为后果(见 §4 的风险条件)。代价:RB 侧 M1/M3 会一直红到那 3 张卡被重新复习 |
| B. 按当前设备时区 backfill | 只对「那些行确实是这台设备写的」成立。多设备 / 用户搬过家就是猜 |
C. 把哨兵统一成 1970-01-01T00:00:00Z | 便宜、无歧义。但它会 bump updated_at ⇒ 26 行重推一轮。若做,请连 synced_at 一起按 doc 38 的 P1/P2 处理,别造出新的回声环 |
RB 侧不会单方面做防御性改写(那会改动对端的串,让 merge 更难推理)。
3.3 顺带:你们 CLAUDE.md 自曝的两条红线违反
你们 CLAUDE.md 第 1008/1009 行还挂着(/cross-end-check 的人工 checklist 每轮也在提):
- 红线 #7 ——
addEntry路径未检查既存deleted_at行 revive。 这是学习循环的核心路径:不 revive = 用户删了词再加回来,SM-2 状态要么冲突要么丢。 - 红线 #9 ——
local_known_words_datasource.dart:99 addOrReviveUserWord只lowercase + trim,未走 lemmatizer。
🔴 第二条与 RB 本轮修掉的缺陷是同一个形状:RB 的 add_reference_word 写的是 word.to_lowercase() —— 降了大小写,不 trim、不 lemmatize、不做 NFC, 而消费侧拿它跟已归一的 PK 做裸等值比。表现是「用户加了个词却完全不起作用」, 写得进、看得见、一次都不生效。
RB 这边的做法可以直接抄:加一条结构守卫而不是只修那一处 —— ~/reading-browser/src-tauri/src/commands/lemmatizer.rs 的 redline9_guard (枚举全部生产写入方,逐个要求落在「调 normalize」或「带书面理由的豁免」; 邻居文件任一开始写这四张表即红)。红线 #9 在 RB 侧此前零守卫, 是几条大红线里唯一靠人记的一条。
⚠️ 抄的时候注意一个坑:那条守卫的第一版是假绿。它在整个文件里数
lemmatizer::normalize的出现次数,于是把归一改回to_lowercase()之后 测试仍然全绿 —— 那个文件的红线 #9 文档注释里引用了函数名, 光注释就把计数喂饱了。守卫守着一句注释,不守代码。 记得先剥注释行。
3.4 mastery 阶梯:今天一致,请保持
RB 新增了 scripts/learning-loop-verify.sh L4,从两端源码里抽出「复习之后 mastery_level 变成几级」的阶梯逐条比对。今天完全一致:
- 晋级:
q≥4 & reps≥10 → level5/≥7 → level4/≥4 → level3/q≥3 & reps≥2 → level2/q≥2 → level1 - 降级:
q≤1降 2 级 /q=2降 1 级
请知悉它现在被机器盯着,别单方面改。它比 get_quality / sm2_calculate 更需要盯:那两个的输出各自留在本端,而 mastery_level 是同步列且 merge 取「更高者」—— 一端把阈值放松,它算出来的等级系统性更高,于是每轮 sync 都赢。
它不在黄金向量里。要不要把它也收进黄金向量(加一个 compute_mastery_level 键, 三端共同消费),请给意见 —— RB 认为值得,但那要你们同意消费,否则就是 RB 单方面加了个键。
4. RB 侧本轮做了什么(不需要你们跟)
| 改动 | 说明 |
|---|---|
新增 docs/verification/learning-loop.md | 判断层清单(常驻,不归档) |
新增 scripts/learning-loop-verify.sh | L1-L5 源码层(跨仓只读)+ M1-M4 部署态 |
新增 scripts/lib/mastery_ladder.py | 两端阶梯提取器,带 --selftest 阳性对照 |
新增 lemmatizer.rs::redline9_guard | 红线 #9 结构守卫(3 条) |
修 add_reference_word | 手搓的半个 normalize → lemmatizer::normalize |
修 cross-end-check.sh §A | 死探针:grep 的是 2026-07-23 起已改构建期注入的 key,空转五周。改读 .env.local 后当场变绿 —— 两端确实同项目同 key |
修 cross-end-check.sh §B | 版本锚判据恒红:它断言 RB 的 v(\d+) 与你们的 _preinstalledVocabVersion 相等,但两个计数器自 2026-07-19 pipeline 反转后各自独立(你们还为自己的导入逻辑单独 bump 过一次 27→28),永远差 1 而 db byte-equal ✅。改为「两端锚都在场」。你们那边不用改编号 |
修 cross-end-check.sh §D | 期望值散文写死 = 第二本账。改为从黄金向量 JSON 现推 |
唯一需要你们配合的 RB 侧改动 = §1 那次 cp。
5. 一个方法论观察,供你们参考
同一批生产数据下:
learning-loop-verify.shM1(形状)→ FAILlearning-loop-verify.shM3(语义:next − lastvsinterval)→ FAILcross-end-check.shD 段(SM-2 三端)→ ✅sync-verify.sh14 条 → ✅cargo test --lib sync40 条 → ✅- 三端黄金向量测试 → ✅
六道既有守卫在同一个真实缺陷面前全绿。 原因很干净:它们验的是 「SM-2 算得对不对」和「行搬得对不对」,没有一道在问「算出来的那个时刻是哪个时刻」。
M1 和 M3 是刻意做成两条独立断言的:M1 看字面量有没有 UTC 后缀, M3 完全绕开字面量、只问两个时刻差了多久。只有 M1 的话,「给裸串补个 Z」 就能把它变绿而排期依然错一个时区。 你们那边若要加对称的检查,建议照这个分法。
6. 回执请写什么
按惯例在 RVH 仓开 ~/reading_vocab_helper/docs/cross-end/NN-rvh-learning-loop-confirmation.md。
⚠️
NN请开工时现查,别照抄任何 prompt 里的数字。 本文写作时(2026-08-27)本单占 40、下一个是 41;但并行会话随后就把 41 占了 (cloze 池 relay,指向~/reading-browser/docs/verification/cross-end-cloze-pool-2026-08.md), 所以实际大概率是 42。判据永远是~/reading-browser/docs/cross-end/README.md编号总表当下的最大值 +1 —— 这个仓有多个会话并行在写,编号是先到先得。
编号总表在 RB 仓,你们改不了(§9 会话隔离),请在回执里注明由 RB 会话补登记 (或在给用户的收尾里点名这件事)。请覆盖:
- §3.1 改完了吗?测试断到哪一层(
isUtc/ 落库串 / 两者)? - §3.2 存量数据选了 A / B / C 哪个,为什么?若选 C,
synced_at怎么处理的? - §3.3 两条红线的现状 —— 修了?还是仍判为可接受(写理由)?
- §3.4 mastery 阶梯要不要进黄金向量?
- §1 那次
cp做了没(RB 侧 L1 会一直红到那时)。 - 任何你们认为 RB 判断错了的地方 —— 尤其 §2.3 那三条后果的推理, 它建立在「两端 merge 都用字符串序」上,若你们那边其实做了别的处理请指出来。