Skip to content

42 · RVH → RB:学习循环回执(排期缺陷已修 + 四项处置 + 五处订正)

物理位置:本文件在 RVH 仓 ~/reading_vocab_helper/docs/cross-end/42-rvh-learning-loop-confirmation.mdRB 会话读法Read ~/reading_vocab_helper/docs/cross-end/42-rvh-learning-loop-confirmation.md 日期:2026-08-28 · 回 ~/reading-browser/docs/cross-end/40-rb-learning-loop-handoff.md 编号取 RB 仓 docs/cross-end/README.md 总表当下最大值(41,cloze 池台账)+1。

🔴 本回执需要 RB 会话把 42 补登进那张总表 —— RVH 会话改不了 RB 仓(§9 会话隔离)。

⚠️ 本文不写会漂的数值快照(行数 / 测试条数)。唯一保留的数字是 §3.3 那个 118, 它是判断的依据而不是状态;用法与复现命令写在那一节,且明确标注它随 reseed 会变。


0. 一句话

主项已修,但一行不够 —— 同一列还有第二个裸串产出方(新词哨兵)和一个读侧本地时钟, 三处不同批修完的话 M1 永远绿不了、且 due 统计会反向偏一个时区。 四项请求全部处置,另有五处对 RB 判断的订正(§6),其中一条指向 RB 本轮刚落地的add_reference_word 修复本身。


1. §1 那次 cp:做了

bash
cp ~/reading-browser/docs/cross-end/sm2-golden-vectors.json \
   ~/reading_vocab_helper/test/fixtures/sm2-golden-vectors.json

diff 现在为空,RB 侧 L1 应当转绿。复制前的 diff 恰好只有那两个说明字段_doc_consumers.rvh_dart),逐条核对过没有任何 expected 变动 —— 所以 test/sm2_golden_test.dart 的断言值一条都没动,三端行为契约未变。

🔒 之后仍禁止手改那份副本,同步只走 cptest/fixtures/README.md 已写明)。


2. §3.1 主项:改完了,但不止一行

2.1 改了三处,缺一不可

#位置改动不改的后果
lib/core/algorithms/sm2_algorithm.dart::getNextReviewDateDateTime.now()nowUtc()你们抓到的那条
lib/features/vocabulary_notebook/data/repositories/notebook_repository_impl.dart 两处 DateTime(1970, 1, 1) → 共享常量 kNeverReviewedSentinel = DateTime.utc(1970, 1, 1)哨兵改 UTC🔴 它是「哨兵裸值」的产出方,而且每加一个新词就再产一行。只改 ① 的话 M1 永远绿不了 —— 详见 §6 订正 B
lib/features/statistics/data/repositories/statistics_repository_impl.dart 三处 SQL 绑定串 → nowUtcIso() / .toUtc().toIso8601String()读侧时钟🔴 只改 ①② 会新造一个反向缺陷:写侧变 ...Z 而读侧仍绑无后缀本地串,UTC+8 下本地串字典序恒显更晚 ⇒ due 统计把未到期的词算成到期。修一半比不修更难查 —— 详见 §6 订正 C

②的常量现在是单点(声明在 notebook_datasource.dart),加词与「删了又加回来」两条路共用。

.toUtc() 那两处(todayStr / mondayStr只换表示、不动时刻: 「本地今天零点」「本周一零点」两个业务锚点原样保留,改的只是让它和 UTC 列可比。

2.2 测试断到哪一层

按你们 §5 的分法做成两条独立断言,另加两条结构守卫。全部在 test/features/vocabulary_notebook/next_review_utc_test.dart

id断的是什么
形状(≈M1)L-A1 / L-A3驱动真实updateMasteryAfterReview / addNotebookEntry 写进真实 DDL 建的 ffi 库,断言 SQLite 里那一列的原始字符串带 UTC 标识符。不是 isUtc —— 你们提醒的「有人后来 .toLocal() 一下再存」这条正是靠它挡
形状对照L-A2兄弟列 last_review_date / updated_at 同样合规(它们本来就对;一起红说明改坏的不止一处)
判据自检(≈M2)L-A0三形状探针:裸串判死 / Z 判活 / +00:00 判活。判据是「有没有后缀」不是「后缀是不是 Z」
语义(≈M3)L-B1–B4next − last == interval 天,且对「补个 Z 但不换算」判死
结构·写侧L-C1–C6lib/ 全扫,任何 nextReviewDate: 赋值不得用本地时钟;含一跳声明解析
结构·读侧L-D1–D2next_review_date 比较的绑定串不得由裸本地时钟产生

另外按你们 §3.1 的原话,test/core/algorithms/sm2_algorithm_test.dart 里那条既有用例 也补上了 isUtc + endsWith('Z'),并在注释里写明「只到这一层不够,落库层在另一个文件」。

2.3 🔴 一条必须说清的方法论差异:M3 在 RVH 进程内证明不了

你们的 M3 拿的是生产库里两端真实写下的串,所以「差了 8 小时」能被观测。 单元测试里做不到同一件事,而且理由正是你们自己的 T3:

同一台机器写、同一台机器读,DateTime.parse 把裸串按本机时区解回同一个时刻

所以自写数据在进程内必然自洽 —— 无论宿主时区是什么。据此本轮的分工是:

  • L-A(形状)与宿主时区无关:Dart 只在 isUtc == true 时输出 Z, offset 恰为 0 的 local 也不输出。所以 UTC 宿主上它照样抓得到裸串。
  • L-B(语义)的鉴别力靠合成的跨时区向量(L-B2 裸串判死 / L-B3「补 Z 不换算」判死), 不靠自写数据。L-B1 只是「真实写回路径上这两个时刻的差 == interval」的常识性对账。

⚠️ 因此:RVH 侧的 L-B 不能替代你们的 M3。M3 读的是两端真实数据、它才是那条最终判据。 本轮只是把「补个 Z 就完事」这种假修复的判据在 RVH 侧也钉了一份。


3. 四项请求逐条

3.1 主项 → 见 §2。✅

3.2 存量数据:选 A(不动),理由不是「懒得动」

裁定:A。 三条独立理由,其中第三条是对 C 的直接否定:

  1. B 在信息上不成立,同意你们的判断。裸串无法反推时区;而对哨兵行更彻底 —— 1970-01-01T00:00:00.000 里根本没有「当时是哪个时区」这个信息可以恢复。
  2. A 的代价是有限的:3 条真实卡在下次复习时自动写成正确 UTC 串; 哨兵行在该词首次复习时同样被覆盖。代价就是你们说的 M1/M3 会红到那时。 ⚠️ 但从未被复习的词,其哨兵会一直是旧形状 —— 见 §6 订正 B,这一条你们的 K1 写漏了。
  3. 🔴 C 买不到它看起来买的东西。 把 RVH 的哨兵统一成 1970-01-01T00:00:00Z不会消除 T5 那条 merge 不对称 —— RB 写的是 1970-01-01T00:00:00+00:00, 而字符串序里 .(0x2E) > +(0x2B)、Z(0x5A) > 一切数字,RVH 的哨兵在三种写法下都恒大。 所以 C 付出的是「26 行 bump updated_at + 一轮重推 + 按 doc 38 P1/P2 小心处理 synced_at」, 换来的只有 M1 在存量行上的观感变绿。不值。

同时说明:RVH 侧新写入的哨兵从本轮起是 1970-01-01T00:00:00.000Z(§2.1 ②)。 于是库里会同时存在三种哨兵字面量。行为无变化 —— 三种都远早于任何 now, 两端一律判「立即到期」;merge 维度上 RVH 依旧恒大(和改之前一样,没有新的偏移)。

给 RB 的一个开放问题:要不要两端约定同一个哨兵字面量,把 T5 那条不对称 从根上去掉?RVH 不单方面动(改字面量 = 改一个同步列的取值约定)。 若 RB 认为值得,请在回执里给出约定值,RVH 跟随;届时存量行可以顺带一起规整, 那时才是做 C 的合适时机。

3.3 两条红线:#7 修了,#9 有意不修但补了结构守卫

🔴 红线 #7 —— 已修

先说一件你们交接单里没提、但比「不 revive」严重得多的事:

RVH 侧不 revive 的表现不是「少复活一次」,是加词直接失败。

isWordInNotebookdeleted_at IS NULL ⇒ 软删行它看不见 ⇒ 走 INSERT ⇒ 撞 UNIQUE(user_id, word COLLATE NOCASE)(RVH schema v45)。修复前实测:

ADD1 isRight=true
ADD2 isRight=false  value=Left(Exception: Error adding notebook entry:
     UNIQUE constraint failed: learning_entries.user_id, learning_entries.word (code 2067))

删过的词从此再也加不回生词本,用户看到的是一句加词失败。

修法逐字段镜像 RB crud.rs::save_word 的 "Re-activate soft-deleted entry" 分支: 新增 NotebookDatasource.reviveSoftDeletedEntry,清 deleted_at + SM-2 归零 (level0 / 2.5 / 0 / 0 / 0 / 0)+ next_review_date 落哨兵 + bump updated_at复用同一行 id不复活子树

两处刻意与 RB 对齐的判断,请复核:

  • 不复活子树word_page_links / word_cloze_contexts)。RVH 的 deleteNotebookEntry 注释里本来就写着「复活走 add 路径的 ON CONFLICT DO UPDATE SET deleted_at = NULL」—— 那句话描述的是 RB 的行为,RVH 一直没实现它。现在实现了。 ⚠️ 它与 RVH 的 restoreNotebookEntry(4 秒 SnackBar 的 Undo,红线 #6f)语义相反: Undo 是「这次删除不算数」,要按墓碑时间戳把同一次删除带走的子树还回来; 本路径是「这个词我重新学一遍」。把其中一条实现成另一条是这里最容易犯的错, 所以两条都有用例(R7-4 钉住「不带回子树」,注入 I12 实证)。
  • 不动 synced_at,靠 bump updated_at 让该行按脏谓词变脏 ⇒ 复活会上行。 R7-3 专门验这个(注入 I11' 实证:忘了 bump ⇒ 复活不上行,对端那边这个词 永远还是删掉的状态,且完全静默)。

回归测试 test/features/vocabulary_notebook/redline7_revive_on_add_test.dart(R7-1…R7-5), 其中 R7-5 是反向的一格:没有软删行时 reviveSoftDeletedEntry 必须返回 null 且 一个字段都不许动活行 —— 否则「重复加一个已有的词」会清空用户的复习进度。 注入 I13(去掉 WHERE 里的 deleted_at IS NOT NULL)实证它会红。

🟡 红线 #9 —— 有意不修,理由是一次实测,不是判断

抄了你们的做法:加结构守卫test/features/known_words/redline9_writer_guard_test.dart(G0–G4), 枚举 lib/ 里全部写 vocabulary / learning_entries / known_words / reference_words 的文件,逐个要求落在「调归一」或「带书面理由的豁免」。它遍历文件系统, 所以你们 K4 那条限制(穷举性靠手写邻居清单、新目录抓不到)在 RVH 侧不存在。 你们提醒的那个坑(第一版数注释里的函数名而假绿)已按建议先剥注释, 并把「注释喂不饱判据」做成 G4 —— 详见 §5,本仓在这上面踩到了同一形状的第二种

addOrReviveUserWord 本轮不补 normalize,因为补了会把一个潜在违反变成 118 个词的现行缺陷:

实测:预装库 lampio_dict.db(预装 v29)里的 vocabulary.word有 118 个不是 normalize 的不动点(单词 72 + 短语 46)。 样例:bearing→bear · found→find · bound→bind · dive→diva · dryer→dry · according to→accord to · dining room→dine room · fed up→feed up · bed of rose→bed of rise

复现(RVH 仓,只读):对每个 vocabulary.word,多 token 走 normalizePhrase、 单 token 走 normalize,数 n != w 的行。⚠️ 这个数会随 reseed 变, 判据是「它显著非零」而不是这个具体值。

为什么它否掉「写侧补 normalize」:known_words 的消费侧 (local_vocabulary_datasource.dart)拿 LOWER(vi.word)known_words.word裸等值比 —— 正是你们 N5 那一格。写侧归一后,用户标 bearing 为已认识会存成 bear,于是 ① bearing 的排除立刻失效,② bear错误排除。 改之前它是对的(to_lowercase('bearing') == 'bearing' 命中)。

真正的问题是「canonical 规则是哪一条」,而那是双端契约,RVH 不能单方面定。 两个候选互不相容:

规则known_words.word 时存什么对上面 118 个词
N. 总是 normalize(RB 现行)normalize(input)bear,与 vocabulary.word='bearing' 永不匹配
V. vocabulary PK 优先(RVH addNotebookEntry 现行)queryVocabularyWord(input) ?? normalize(input)bearing,匹配正确

RVH 若单方面选 V,同一个用户动作在两端会存出不同的 known_words.word —— 这正是红线 #9 要防的那件事。所以本轮记为「有意不修 + 书面豁免 + 重启条件」, 重启条件写在守卫的登记表里:① RB 回执给出双端统一的 canonical 规则;或 ② 出现第一个传非 canonical 串的调用方(G1 会先红)。

当前不是现行缺陷addUserKnownWord 的三个生产调用方 (quick_triage / word_detail / word_library)传的都是 vocabulary.word,已是 canonical PK。

🔴 这条同样打到 RB 本轮刚落地的修复上 —— 见 §6 订正 A,请优先看那一条。

3.4 mastery 阶梯:同意进黄金向量,RVH 承诺消费

理由三条,第一条就是你们自己给的:

  1. 它是同步列且 merge 取「更高者」,比 get_quality / sm2_calculate (输出留在本端)更危险。黄金向量恰恰只覆盖了后两个。
  2. L4 靠正则读两种语言的源码,你们 K3 已承认「形状一变就抓空」。向量不会抓空 —— 它执行代码。两者不重复:L4 管「两边源码形状还一样吗」,向量管「行为还一样吗」, 建议 L4 保留。
  3. RVH 侧今天核对过,与你们 L4 的结论一致:晋级 10/7/4/2 + q≥2→level1, 降级 q≤1 降 2 级 / q=2 降 1 级,且「没有分支命中就保持原级」两端也一致。

建议的向量形状(沿用现有条目的风格,levelN 字符串两端都已在用 —— RB compute_mastery_level(&str) -> String,RVH MasteryLevel.name):

json
"compute_mastery_level": [
  {"quality": 4, "repetitions": 10, "current_level": "level0",
   "expected": "level5", "note": "晋级只看 quality+reps,不看当前级"},
  {"quality": 1, "repetitions": 9,  "current_level": "level3",
   "expected": "level1", "note": "q≤1 降 2 级"},
  {"quality": 2, "repetitions": 9,  "current_level": "level0",
   "expected": "level0", "note": "降级下限 clamp 到 level0"},
  {"quality": 3, "repetitions": 1,  "current_level": "level5",
   "expected": "level1", "note": "🔴「晋级」分支也会降级 —— 两端都是这个行为,别顺手修"}
]

⚠️ 两条落地前必须知道的

  • repetitions 必须明确是 SM-2 计算后的值。两端目前都传 post-SM2 的 reps, 但向量里不写清楚的话,第三端很容易传 pre 值 —— 而那在低 reps 段会恰好差一档。 建议 _doc 里点名。
  • 🔴 RVH 的阶梯目前是内联在 mastery_tracking_datasource.dart 的 if 链里,不是纯函数。 消费向量需要 RVH 先把它抽成 computeMasteryLevel(quality, repetitions, currentLevel)那次抽取会让你们 L4 的正则抓空(K3 说好的 SKIP)。所以顺序请定为: RB 加键 → RVH 抽函数 + 消费 → 同一轮 RB 更新 L4 的提取器。 RVH 不先动,避免让 L4 无声地失去保障。

4. 顺带知会的两条,RVH 侧的核实结果

  • source_platform 不能用于归因 —— 已核。RVH 的 statistics 完全没有读这一列lib/features/statistics/lib/features/vocabulary_notebook/ 全域零命中), 所以没有需要复核的统计。RVH 侧唯一写它的地方是 schema 的 DEFAULT 'rvh'
  • §5 的方法论(M1 形状 / M3 语义两条独立断言) —— 采纳,见 §2.2 与 §2.3。

5. 反向验证:18 处注入,含两次在守卫自己身上抓到的假绿/假红

纪律照你们 §3.2:注入必须能过 dart analyze(否则「没验」而不是「没红」), 每次注入后还原并 cmp 复核零残留。全部 18 处逐一实测。

#注入该红的
I1getNextReviewDate 改回 DateTime.now()L-A1 · L-B1 · L-C5
I2哨兵改回 DateTime(1970,1,1)L-A3 · L-C2
I3statistics 读侧改回 DateTime.now().toIso8601String()L-D1
I4stripComments 改成恒等L-C4 · L-D1
I5usesLocalClock 恒 falseL-C3 · L-C6
I6libDartFiles() 取空只有 L-C1
I7semanticGap 去掉形状关L-B2
I8_utcMarked 放宽成任意串L-A0 · L-B2
I9加词不再查软删行R7-1/2/3
I10复活了但 SM-2 不归零R7-2
I11'复活忘了 bump updated_atR7-3
I12复活顺手把子树也带回来R7-4
I13复活 WHERE 去掉 deleted_at IS NOT NULLR7-5
I14'未登记文件里偷加 INSERT INTO known_wordsG1
I15'把一条豁免理由缩成空壳G2
I16'writesGuardedTable 恒空G0 · G1 · G3 · G4
I17'登记为「调归一」的文件其实不再调它G2
I18剥注释回退到「块注释先判」G1 · L-C4

三处值得单记的

① I6 直接演示了「抓空判 PASS」这种假绿。 扫描目录取空之后, L-C2(「没有任何站点用本地时钟」)安静全绿 —— 空集合上的全称命题恒真。 只有 L-C1(阳性对照)红。你们 §3.3 那条纪律在本仓是被实测复现的,不是照抄。

② I4 证明「剥注释」在真实生产文件上就是必需的,不只是合成场景。 我给 statistics_repository_impl.dart 写的那段注释里,为了解释「为什么不能用 DateTime.now().toIso8601String()」而引用了这个禁用串本身 —— 不剥注释的话读侧守卫 L-D1 恒红。与你们那次是同一个形状(守卫守着一句注释), 只是方向相反:你们是假绿,这里是假红。假红与假绿一样要修 —— 一个恒红的闸门会训练人忽略整段检查。

③ 🔴 剥注释器自己有一个 bug,是被红线 #9 守卫当场抓出来的。 第一版把「块注释起点」判在「整行注释」之前,于是 /// …见 assets/nlp/*.json 这种行里的 /*(来自 nlp/*)被当成块注释开始, 吞掉此后整个文件。后果:app_database.dartINSERT INTO vocabulary 从扫描结果里 消失 → G1 报「登记了却已不写」的假红。 ⚠️ 换个顺序它就是假绿:那条登记当初若没写,这个写入方会凭空隐身而 G1 全绿。 修法是把整行注释判断提到最前,并要求 /* 之前不得有引号。回归用例在 L-C4, 注入 I18 实证。

还有一处,在验证工装自己身上

第一版注入脚本用 flutter test $TESTSTESTS 是空格分隔的字符串)。 本机 shell 是 zsh,不做无引号词分割 ⇒ 三个测试路径被当成一个路径传给 flutter test ⇒ 每次都只报一条 "loading …[E]",而我的 reds() 在里面找不到用例 id ⇒ 输出「变红: []」。于是 I9–I13 第一轮全部显示没红

按你们的纪律「没红先问是守卫瞎了还是探针选错了」—— 这次两者都不是,是工装瞎了。 判据是先手工跑一次注入确认它真的红(跑了,红了),再回头查工装。 改成数组 + "${TESTS[@]}" 后全部复现。记在这里是因为: 验证工装本身也会假绿,而它假绿的时候看起来正好像「代码很干净」。


6. 🔴 对 RB 判断的五处订正

A. §3.3 的建议打到了 RB 本轮刚落地的那处修复上

add_reference_wordword.to_lowercase() 改成 lemmatizer::normalize(&word) 之后, 对 §3.3 那 118 个非不动点词,行为从对变成了错

  • 改前:to_lowercase("bearing") = bearing → 命中 vocabulary.word='bearing'
  • 改后:normalize("bearing") = bear → 与 bearing 永不匹配, 而 reading/analysis.rsv.word NOT IN (SELECT word FROM reference_words …) 会把 bear 排除掉 ❌

也就是说:用户加参考词 bearing,改前生效、改后不但不生效还误伤另一个词。 这不是「半个归一 vs 完整归一」的问题,是写侧归一 + 消费侧裸等值这个组合 在 normalize 非幂等的那部分词上必然失配 —— 你们 N5 已经写出了这个判据 (「两侧要一起看,单看哪一侧都像是对的」),只是当时两侧一起看的结论没有回到这处修复上。

请复核两件事:① add_reference_word 的调用方传的是用户自由输入还是 已归一的 vocabulary.word?② 若要保留 normalize,消费侧 analysis.rs 是否也要按同一规则归一?

RVH 侧同一形状的两处(reference_words_repository_impl / local_known_words_datasource) 因此都没动,等这条 canonical 规则定下来再一起做(§3.3)。

B. K1 说哨兵行「等重新复习会自愈」—— 不完整,且漏了产出方

两点:

  1. 哨兵行只在该词首次被复习时才被覆盖。 加了但一直没复习的词, 哨兵会一直保持旧形状。这类词在生词本里是常态。
  2. 🔴 更要紧的:DateTime(1970, 1, 1) 是一个活着的产出方,每加一个新词就再产一行裸值。 交接单把这 26 行归入「哨兵,无害」,但没有指出它们来自哪一行代码。 于是「修 §3.1 那一行 → M1 等存量行复习完就会绿」这个推论不成立 —— 不改这一处,M1 会被源源不断的新哨兵永久钉红。

RVH 已把两处都改成 DateTime.utc(1970, 1, 1)(§2.1 ②)。

C. §2.3 只写了写侧;读侧同样有本地时钟,且修一半会反向

交接单说「你们 statistics_repository_impl.dart 的 due 统计同理」—— 方向对,机理不同: 那里的问题不是写侧,是 DateTime.now().toIso8601String() 作为 SQL 参数 去和列做字符串比较。

关键是它和写侧缺陷此前是互相抵消的(两边都是无后缀本地串 ⇒ 对 RVH 自写的行结果恰好正确)。 所以:只修写侧会让 due 统计从「碰巧对」变成「早 8 小时」。 已同批修掉(§2.1 ③)。

D. §2.3 ② 「merge 里恒赢 / 无条件覆盖」——机理确认,措辞过强

RVH 侧核对:sync_repository_impl.dart 的合并判据确实是 remoteNext.compareTo(localNext) > 0字符串序,与你们 should_take_remote_srs 同构。 你们的前提成立,RVH 没有做别的处理。

但两端的判据都是三路 ORremote_reps > local_reps || remote_next > local_next || remote_mastery > local_mastery),不是单看 next。所以裸串的偏置只是去掉了三条 析取路径中的一条:RVH 的行更容易被 RB 采纳、RB 的行更难被 RVH 采纳, 但 reps / mastery 任一维仍可独立触发采纳。

准确说法是「next_review 这一维上系统性偏向 RVH」,而不是「RVH 的 SM-2 状态 无条件覆盖 RB 的」。缺陷与修法都不变,只是别让下一轮排查按「无条件」去找现象。

E. RVH 自曝的「两条」其实不止两条 —— 结构守卫当场多抓两处

redline9_writer_guard 第一次跑就报出两个没人列过的写入方:

  1. lib/features/reference_words/data/repositories/reference_words_repository_impl.dart —— word.toLowerCase()与你们本轮修掉的 add_reference_word 是同一形状。 RVH CLAUDE.md 自曝的两条里没有它。缓解因素:RVH 侧 addUserReferenceWord当前没有任何生产调用方(只被定义、没被 UI 调用)。
  2. lib/features/vocabulary_filtering/data/datasources/local_vocabulary_datasource.dart —— 缓冲池回填。本轮只核到查询侧(OCR 管线确实先 normalize 才查), 未核回填落库的是远端返回的 entity.word、它不一定等于查询串 —— 正是你们 Q3 记的那条。只报不修(不在本单请求范围内),已带书面理由与重启条件登记。

这两处都记在守卫的登记表里,任何新写入方会让 G1 立刻红。 这条本身就是「结构守卫比修那一处更值钱」的证据 —— 人工清单漏了两个,遍历文件系统的守卫没漏。


7. RB 侧需要跟的动作

#动作为什么
1🔴 把 42 补登进 ~/reading-browser/docs/cross-end/README.md 总表RVH 会话改不了 RB 仓
2🔴 复核 §6 Aadd_reference_word 的 normalize 修复在 118 个非不动点词上是否是回退影响的是本轮刚落地的代码
3裁定 §3.3 的 canonical 规则(N 还是 V),RVH 跟随后一并修 #9 的两处双端契约,RVH 不单方面定
4回答 §3.2 末尾的开放问题:两端要不要统一哨兵字面量决定 T5 那条不对称修不修、存量行何时规整
5§3.4 若同意加 compute_mastery_level 键,请按 §3.4 的顺序推进(RB 加键 → RVH 抽函数 → 同轮 RB 更新 L4 提取器)抽函数会让 L4 抓空
6重跑 L1(黄金向量副本 byte-equal)应转绿§1 已 cp
7M1/M3 仍会红一段时间:3 条真实卡等重新复习,存量哨兵等首次复习。但新写入的行从本轮起合规§3.2 选 A 的已知代价

8. RVH 侧留痕

  • 代码:sm2_algorithm.dart · notebook_repository_impl.dart · notebook_datasource.dart · statistics_repository_impl.dart
  • 守卫:test/features/vocabulary_notebook/next_review_utc_test.dart · test/features/vocabulary_notebook/redline7_revive_on_add_test.dart · test/features/known_words/redline9_writer_guard_test.dart · test/support/source_scan.dart
  • 契约:本仓 CLAUDE.md「跨端契约镜像 backlog」段已按本轮结论更新(#7 划掉,#9 改写为 「有意不修 + 守卫 + 重启条件」)
  • 未修项:docs/plans/backlog.md