Skip to content

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.sh L1, 断言你们那份副本与真相源 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 里所有复习过的行:

wordlast_review_datenext_review_datereps
unlawful2026-08-26T09:02:44.753525+00:002026-08-27T09:02:44.753776+00:001
community2026-08-26T09:02:50.129672+00:002026-08-27T09:02:50.129793+00:001
and a half2026-08-26T09:10:23.177665Z2026-08-27T17:10:23.1644071
campus2026-08-26T09:10:26.594492Z2026-08-27T17:10:26.5861981
speak2026-08-26T17:43:59.849661Z2026-08-28T01:43:59.8342511

前两行(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_platformand a half / campusrb别信那一列: 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_cardsnext_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.rsmark_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 addOrReviveUserWordlowercase + trim未走 lemmatizer

🔴 第二条与 RB 本轮修掉的缺陷是同一个形状:RB 的 add_reference_word 写的是 word.to_lowercase() —— 降了大小写,不 trim、不 lemmatize、不做 NFC, 而消费侧拿它跟已归一的 PK 做裸等值比。表现是「用户加了个词却完全不起作用」, 写得进、看得见、一次都不生效。

RB 这边的做法可以直接抄:加一条结构守卫而不是只修那一处 —— ~/reading-browser/src-tauri/src/commands/lemmatizer.rsredline9_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.shL1-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.sh M1(形状)→ FAIL
  • learning-loop-verify.sh M3(语义:next − last vs interval)→ FAIL
  • cross-end-check.sh D 段(SM-2 三端)→ ✅
  • sync-verify.sh 14 条 → ✅
  • cargo test --lib sync 40 条 → ✅
  • 三端黄金向量测试 → ✅

六道既有守卫在同一个真实缺陷面前全绿。 原因很干净:它们验的是 「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 会话补登记 (或在给用户的收尾里点名这件事)。请覆盖:

  1. §3.1 改完了吗?测试断到哪一层(isUtc / 落库串 / 两者)?
  2. §3.2 存量数据选了 A / B / C 哪个,为什么?若选 C,synced_at 怎么处理的?
  3. §3.3 两条红线的现状 —— 修了?还是仍判为可接受(写理由)?
  4. §3.4 mastery 阶梯要不要进黄金向量?
  5. §1 那次 cp 做了没(RB 侧 L1 会一直红到那时)。
  6. 任何你们认为 RB 判断错了的地方 —— 尤其 §2.3 那三条后果的推理, 它建立在「两端 merge 都用字符串序」上,若你们那边其实做了别的处理请指出来。