Skip to content

专题验证 · 学习循环(查词 / 生词 / SRS 三端一致)

常驻清单,不归档。判断层专用——机械层先跑完再看这里。 对象:commands/dictionary.rs::lookup_word + commands/vocabulary/{crud,query,sm2}.rs

  • commands/known_words.rs + commands/reference_words.rs + commands/srs.rs
  • commands/lemmatizer.rs + db/vocab_scope.rs + docs/cross-end/sm2-golden-vectors.json
  • RVH 的 SM2Algorithm / mastery_tracking_datasource.dart / local_known_words_datasource.dart 体裁与纪律见 README.md
bash
./scripts/learning-loop-verify.sh
cargo test --lib -- srs:: vocab_scope:: lemmatizer::   # 在 src-tauri/ 下跑

0. 这个域为什么危险

README 的「待建」表里,本专题的理由曾写着一句 「用户会自己报障,优先级低」。 建完这一轮之后,那句话该改:用户报得出来的只有「卡片没出现」,报不出来的是「卡片出现得不对」。

这个域的三条链路各有各的静默方式:

链路出事时用户看到什么用户会报障吗
查词 专名该查得到、不该进学习循环双击 Kyiv 有释义(对),但它悄悄进了发现池 / 拉低了页面难度分❌ 看不见
生词word 列漏归一词存进去了、列表里看得见,但任何按已归一 PK 的比较都匹配不上❌ 看不见
SRS 三端算出不同的下次复习日卡片如期出现,只是日子不对❌ 分不清

🔴 「复习卡照常出现」不是证据。 这一域几乎所有失效都保持着「功能看起来在工作」的 外观:词存得进、卡片弹得出、按钮点得动。要问的是它算出来的那个值对不对, 而那个值只有跟另一端比才看得出来 —— 单端自测永远自洽。

⚠️ 元前提一:本域的「三端」不是修辞,且第三端在另一个仓

RB Rust / RB TS / RVH Dart 三份实现共同定义同一个用户可见行为(同一张卡, 在手机和桌面上必须算出同一个下次复习日)。RVH 在独立仓,本会话只能读不能写 (CLAUDE.md §9 会话隔离)。所以本清单里凡判定「RVH 侧该改」的,产出都是 docs/cross-end/NN-*.md 交接单,不是修改

⚠️ 元前提二:「三端测试同步红」曾经只有一半是真的 —— ✅ 2026-08-29 起是全真的

CLAUDE.md §9 写着:改任一条黄金向量的 expected 会让三端测试同步红。

2026-08-29(并入计划 T4-3)之前那只对 RB 的两端成立 —— RB Rust include_str! 与 RB TS readFileSync 读的是同一个文件,而 RVH 读的是它仓里 test/fixtures/ 下的 手工副本(当时的理由成立:RVH 的 CI 不能假设 RB 仓在同机并列存在)。 副本一旦落后:改 RB 的 expected → RB 两端红、RVH 拿着旧向量继续全绿。 这就是 L1/L2 当初存在的理由 —— 在 2026-08-27 之前没有任何东西守着它

合仓(2026-08-28)让那个理由消失:两份文件同属一个 checkout。T4-3 把副本删掉, RVH 的 Dart 测试改为直接读 ../docs/cross-end/sm2-golden-vectors.json(cwd = rvh/)。 三端从此消费同一个物理文件,那句话不再有「一半」。

🔒 L1/L2 的判据随之反转,不是废弃(详见 §3 与 K2):

  • L1 从「两份 sha 相等吗」变成负向断言「全仓不许出现第二份同名文件」。 照旧留着老判据的后果是它走 [ ! -f 副本 ] 那支 → skip「本次没验」—— 不会红,会安静地降级成不检查,正是本目录反复点名的那类假绿。
  • L2grep -c '文件名'(只认文件名,路径改回读副本仍然全绿)收紧为 按 Dart 里那个字面量断言「读的确实是根那一份」。

⚠️ 元前提三:本域的守卫散在三个地方,先盘再补

一半在 Rust 单测、一半在 cross-end-check.sh、还有一份在 RVH 仓。 新增检查之前先读 §1,否则造出来的是第二本账而不是覆盖面 —— 本轮就是在 cross-end-check.sh 里找到了一本(§1 末尾第 3 条)。


1. 机械层已覆盖(不要在判断层重复)

位置守什么
源码srs.rs::sm2_golden_tests · src/lib/sm2.test.ts · RVH test/sm2_golden_test.dartget_quality + sm2_calculate 的三端逐值一致(共同消费 sm2-golden-vectors.json
源码srs.rs 其余单测阶梯 reps 1→1 / 2→6 · EF 下限 1.3 · compute_mastery_level 升降级
源码db::vocab_scope 5 条可学 / 计难度判据单点(7 个消费点计数守卫 + 旧 LIKE 反模式 + NULL word 灾难模式 + 无别名形)
源码commands::lemmatizer 归一回归NFC 折叠 · trim/lower/lemmatize 管线 · 跨端短语归一 byte-equal(RVH CSV)
源码commands::lemmatizer::redline9_guard 3 条🔴 红线 #9 结构守卫(2026-08-27 新增,此前零守卫):6 个生产写入方逐个落在「调 normalize」或「带书面理由的豁免」;8 个邻居文件任一开始写这四张表即红;写入方探测的阳性对照
跨仓learning-loop-verify.sh L1/L2黄金向量 RVH 副本 byte-equal · 三端消费方在场
跨仓learning-loop-verify.sh L3期望值只许有一份(守住 cross-end-check.sh §D 不再写回散文)
跨仓learning-loop-verify.sh L4/L5mastery 阶梯两端逐条一致 + 提取器阳性对照
部署态learning-loop-verify.sh M1/M2共享表 text 时间戳的 UTC 标识符 + 扫描器阳性对照
部署态learning-loop-verify.sh M3🔴 语义层:复习过的卡 next_review − last_review ≈ interval
部署态learning-loop-verify.sh M4source_platform 的语义(它是「最后 push 者」不是来源)
别处cross-end-check.sh B/C/D/E预装库 byte-equal(红线 #10)· lemmatizer 资产 · EF 常量在场 · 共享表列集合
别处sync-consistency.mdlearning_entries 的搬运(矩阵 / 墓碑 / 归属 / watermark)—— 本域只管算得对不对

新增守卫全部做过注入式反向验证(2026-08-27,见 §3):4 处 Rust 注入 + 5 处脚本注入 各注入一次真实缺陷,全部变红,还原后 226 passed、git diff 零残留。 Rust 注入在独立 worktree 里做(要改 lemmatizer.rs 这种枢纽文件)。

⚠️ 刻意不重做的

  • SM-2 的逐值行为 —— 黄金向量三端消费已经是最强形态(同一份 JSON、机器纪律)。 本域只补它够不到的两段:副本会不会漂(L1)、还有没有人在读(L2)。 不要再写第四份 SM-2 断言。
  • learning_entries 的同步语义 —— 墓碑 / 归属 / watermark / merge 三条 OR 全归 sync-consistency.md。本域与它相邻但不重叠: 它管这一行有没有被正确搬过去,本域管这一行里的数是不是算对的。 它的 S14 只问「映射表还一致吗」;本域是接着那一格往下沉(见元前提二 + §2.3)。
  • 专名判据本身 —— vocab_scope.rs 的文档注释是那条判据的唯一权威处, 连「为什么不误杀 fox」「为什么接受 93 词假阴性」都写在那儿。别在这里抄第二份。
  • 预装库 byte-equal / lemmatizer 资产 —— cross-end-check.sh B/C 已断言, 且 /vocab-reseed 与红线 #10 是它们的归属。本域只在 §2.2 问「归一的输入数据 两端还是同一份吗」。

🔴 建成这一轮时补上的三个洞

  1. 红线 #9 此前没有任何守卫。 #5i 有 USER_SCOPED_DIRTY + 回归测试,#6d 有 tombstone_synced_at_guard,专名判据有 all_seven_consumption_points… —— 只有 #9 全靠人记,而它是最难看出来的那条(漏归一不报错、写得进去, 只是那一行永远匹配不上)。补 redline9_guard 当场抓到一处(见 §2.2 / K5)。
  2. 黄金向量的 RVH 副本无人看守(元前提二)。RVH 自己在 test/fixtures/README.md 里写了「方便未来 /cross-end-check 加 byte-equal diff 兜底」—— 那条兜底一直没落地,当时补成了 L1。 (2026-08-29 / T4-3:副本已删,L1 改为「不许有第二份」的负向断言。)
  3. cross-end-check.sh §D 是第二本账。 它把期望值 (firstHard=1 / reps=2→6 …)散文写死在脚本里供人眼复核, 与黄金向量 JSON 毫无联系。CLAUDE.md §9 早就点名过「那份与 JSON 是两本账, 谁也不守着谁」,但点名不等于修好。现改为从 JSON 现推打印,并由 L3 守住 不许再写回来。

顺带修掉 cross-end-check.sh 的另外两处失效判据(都不是本域独有,但都在 B 段里): §A 是死探针 —— 它 grep init_data.sql 里的 supabase_url / supabase_anon_key, 而那两个 key 自 2026-07-23 起改为构建期注入,文件里只剩一行说明注释。 于是这两条从那天起永久 SKIP,空转五周。改读真相源 src-tauri/.env.local 后 当场变绿(两端确实同项目同 key —— 这件事此前五周一次都没验过)。 §B 的版本判据恒红 —— 它断言 RB 的 v(\d+) 与 RVH 的 _preinstalledVocabVersion 相等,而两个计数器自 2026-07-19 pipeline 反转后各自独立(RVH 还为自己的导入逻辑 单独 bump 过一次 27→28),永远差 1,而 db 明明 byte-equal ✅。 恒红的闸门与恒绿的一样坏:它会训练人忽略整个 B 段。判据改为「两端锚都在场」。


2. 判断层逐格判据

2.1 查词:一张表三个角色

#问句假绿风险
Q1lookup_word / browse_vocabulary 确认没有vocab_scope::exclude_proper_nouns✅ 反向的一格:这里要的是它不在。专名该查得到 —— 双击 Kyiv 返回「无释义」是缺陷,不是过滤生效。三个角色(可查 / 可学 / 计难度)判据必须分开表达,把「可查」也接上过滤是最容易犯的"顺手统一"
Q2新增的「候选池 / 难度分母」类查询,走的是共享谓词还是手搓的等价 SQL?✅ 已下沉成断言(7 个消费点计数)。要人判的是数字变了之后怎么办:计数守卫红了不是让你把数字改大,是让你回答「这个新消费点属于可学还是计难度,判据是不是真的该和那七个一样」
Q3缓冲池回填(backfill_word_inner)进来的新词,词性形状还落在判据覆盖内吗?判据写的是「无 noun/proper noun/name 以外的词性」而不是「只有 noun」,正是为了覆盖回填行的 {proper noun} 形状。⚠️ 若哪天 Edge Function 换了词性词表(比如吐 propn),判据会安静地漏掉整批回填词,而 584 这个数字不会立刻变 —— 它只统计预装库
Q4lookup_word 查不到时的兜底链(本地 → 缓冲池 → Edge Function),断在中间会怎样?断了的表现是「这个词永远没有释义」,与「这个词本来就不在词库里」外观完全相同。本域机械层不覆盖它(要网络 + 配额),属真机验证

2.2 归一:红线 #9 的实际覆盖面

#问句假绿风险
N1新增的写 word 列的路径,登记进 redline9_guard::declared() 了吗?✅ 已下沉成断言。⚠️ 断言的穷举性靠一张邻居文件清单,它不遍历文件系统 —— 在一个全新目录里开写入方,守卫抓不到。加新模块时要顺手把它加进 neighbours
N2🔴 有人在某处写了 to_lowercase() / trim() 当作归一吗?这是本域最贵的一格,而且它已经发生过add_reference_word 写的是 word.to_lowercase():降大小写但不 trim、不 lemmatize、不做 NFC)。守卫只断言「调了 normalize」,判不出「调了个像 normalize 的东西」 —— 那要人看。⚠️ 但「归一了就对」也是错的,见 N5:判据不是「有没有归一」,是「归一后落在哪个键空间
N3豁免(ExemptBecause)的理由还成立吗?唯一合法的豁免形态是「值来自另一张已归一的表,且全程不进 Rust」。recommend.rs 那条是纯 SQL 的 INSERT … SELECT FROM default_stopwords。⚠️ 它哪天改成先读进 Rust 再写,豁免立即作废而守卫不会红 —— 守卫只看文件里有没有 normalize,看不出数据流变了
N4归一的输入数据两端还是同一份吗?Layer 1/2 来自 lampio_dict.dblemma_* 表(红线 #10 byte-equal,cross-end-check.sh B 段守);Layer 3/4 是两端各一份算法代码,只有短语归一有跨端 CSV 回归。单词归一的 Layer 3/4 分叉没有断言(K6)
N5🔴 消费侧比的是哪个键空间本域最容易判错的一格,RB 与 RVH 在 2026-08-28 各判错了一半(cross-end/42 §6A + cross-end/43)。reference_words4 个消费点(不是 1 个),全部拿它跟 vocabulary.word 比、且都是裸等值(无 COLLATE NOCASE)。而 vocabulary 同时装着 lemma 行与非 lemma 行bearbearing 都在表里)⇒ 三个走 learning_entries join 的消费点看到的是已归一键空间,发现候选池那个看到的是全部 vocabulary 行。两个键空间真的不一样,所以「一律归一」和「一律不归一」都会在某一侧失配。正确判据是「解析成一个真实存在的 vocabulary.word」—— 先试原形,不命中再归一

2.3 SRS:三端行为契约的实际边界

#问句假绿风险
R1黄金向量覆盖的是哪两个函数get_quality + sm2_calculate,仅此。复习写回路径上是三个纯函数,第三个(compute_mastery_level)不在向量里 —— 见 R2
R2🔴 mastery 阶梯两端还一致吗?✅ 2026-08-27 起有 L4 断言(今天一致:晋级 10/7/4/2 + q≥2→level1,降级 q≤1 降 2 级 / q=2 降 1 级)。为什么它比前两个更危险mastery_level同步列,而 pull 的 merge 取「更高者」—— 一端把阈值放松,它算出来的等级系统性更高,于是每轮 sync 都赢,另一端的判定被静默覆盖,两端都不报错。⚠️ L4 靠正则读两种语言的源码,形状一变就抓空;抓空判 SKIP 不判 PASS,且 L5 是它的阳性对照
R3黄金向量的 RVH 副本还是逐字节副本吗?还有人在读吗?✅ L1/L2。⚠️ L2 只验「文件里引用了那个路径」,验不到测试有没有被 skip/注释掉,也验不到 RVH 的 CI 有没有真的跑它。那要在 RVH 会话看
R4三端的 quality 上下游还对得上吗?UI 只有 Hard / Easy,quality 由前次质量推导,故 last_review_quality 必须被写回 —— 两端都写了。⚠️ 任何「重置学习记录」的路径若清了 last_review_quality 却没清 repetitions,下一次按 Easy 会拿到 4 而不是 5,序列推导从中间断开(无报错)
R5到期查询的口径两端一致吗?RB get_due_cardsnext_review_date <= now字符串比较,且不看 known_wordssave_word 的注释解释了为什么那是刻意的)。字符串比较正是 §2.4 那个缺陷的放大器

2.4 🔴 时间与排期(本轮的主战场)

这一节是 2026-08-27 那一轮的产物。它此前不存在,因为没人想到「SM-2 算对了」 和「排期是对的时刻」是两件事

#问句假绿风险
T1共享表里每个 text 时间戳字面量,都自带 Z±HH:MM 吗?✅ M1。判据是「有没有后缀」,不是「是不是 UTC」 —— 一个无后缀的串无法判断是哪个时刻,这就是问题本身。本轮实测:34 个列里恰好一个有裸值,而它偏偏是 next_review_date(K1)
T2🔴 形状对了,时刻就对了吗?不。 给裸串直接补一个 Z 而不做时区换算,T1 会立刻变绿而排期依然错一个时区。故 M3 绕开字面量,直接问那两个时刻差了多久:SM-2 说好了是 interval 天,差出来的几个小时就是时区偏移。T1 与 T2 必须都在,少任何一条都能被"修好形状"骗过
T3单端自测为什么看不出来?同一台机器写、同一台机器读,本地时区自洽 —— 写 DateTime.now()DateTime.parse 回来是同一个时刻。只有跨端比、或跨时区比,才暴露。这也是为什么本域必须有部署态那一段:源码层看不见
T4偏移的方向是什么?取决于用户时区,且会翻转:正时区(UTC+8)下裸串字典序显得更晚 → 卡片晚到期、且该端在 merge 里恒赢;负时区(美洲)下反过来 → 卡片早到期、该端恒输。在 UTC+8 单地测试永远只看到一半
T5哨兵值(「从未复习」)两端不同形,为什么这次无害?RB 写 1970-01-01T00:00:00+00:00,RVH 写 1970-01-01T00:00:00.000。字符串序里 +(0x2B) < .(0x2E),所以 RVH 的哨兵恒大 → merge 里 remote 恒胜。今天无害是因为两边都是「立即到期 + 全默认值」,覆盖是幂等的。⚠️ 它无害的理由和它是对的不是一回事 —— 哪天默认值不再对称(比如某端给新词一个非零 interval),这条就变成「RVH 的新词状态无条件覆盖 RB 的」
T6source_platform 能用来归因吗?不能,M4 每轮把这个事实打出来。push.rs 对 learning_entries 硬编码 "source_platform": "rb",RB 把从 RVH 拉来的行重新推上去时会改写它。本轮实测 3 张 RVH 复习过的卡里 2 张标着 rb。任何按它做的统计都是错的

2.5 生词的生命周期

#问句假绿风险
V1save_word 复活软删条目了吗(红线 #7)?复活时 SM-2 清零了吗?RB 侧两样都做了。⚠️ RVH 侧自曝未做(其 CLAUDE.md 明写 addEntry 路径未检查既存 deleted_at 行)—— 那是跨端待办,不是 RB 的绿
V2「存词」与「标已认识」互为撤销,两个方向都清干净了吗?这是一个没有出口的死状态的入口:save_word 不软删 known_words 的话,卡片进了复习队列(get_due_cards 不看 known_words)却在任何页面上永不高亮,且列表同时给出 in_notebook=1is_excluded=1 两个自相矛盾的标志。RB 已修并写进注释
V3删词 / 标已认识时,子行(links / cloze)跟着走了吗?✅ 归 sync-consistency.md S3a + 红线 #6a,别在这里重复。本域只关心它对学习循环的后果:残留的 links 会让「首次遇见于」指向一个已经不存在的学习记录
V4语境池的采集闸门(整词命中 / created_at 形状)还在吗?✅ 归 sync-consistency.md S12/S13。⚠️ 但 T1 的扫描顺带覆盖了 word_cloze_contexts.created_at 的形状(本轮全绿),两处判据一致、不冲突

2.6 边界(这些不属于本域,别在这里验)

#问句
B1learning_entries 的搬运(矩阵完备性 / 墓碑 / 归属过滤 / watermark / merge 三条 OR)→ sync-consistency.md。本域管「算得对不对」,它管「搬得对不对」
B2预装库 byte-equal(红线 #10)与 reseed 流程 → cross-end-check.sh B 段 + /vocab-reseed skill
B3短语识别质量(idiomaticity 分档、误报率)→ /phrase-eval skill + docs/plans/phrase-eval-log.md。那是质量评测,本域是一致性验证
B4快照正文的三态回落 → 「阅读内容管线」专题(尚未建,README 待建表)
B5迁移链与冻结 → data-baseline.md。本域新增列时会用到它,但纪律归那边

3. 反向验证配方

只验通过路径 = 没验。 以下全部不依赖「记得改回来」。

3.1 🔒 先说不要做的事

  • 绝不改 RVH 仓的任何文件(CLAUDE.md §9)。要验 L1/L2/L4 的鉴别力, 用 RVH_ROOT=<临时目录> 指向一份拷贝(脚本已支持该环境变量), 在拷贝上注入。本轮就是这么做的。
  • 不要为了验证而改黄金向量 JSON。 它是三端契约的真相源,改它 = 宣告一次 三端行为变更。要试鉴别力,改副本
  • 不要在共享工作树里做 Rust 注入。 要改的是 lemmatizer.rs / srs.rs 这种 枢纽文件,并行会话一次 checkout . 就连你的注入一起卷走(或者反过来)。
  • 不要往生产库写任何一行。 M 段全是 SELECT。要造一个「排期错了」的样本, 等下一次真实复习,别 UPDATE。
  • ⚠️ 共享 CARGO_TARGET_DIR 跨 worktree 会留下一颗哑弹data-baseline.md §3.2 记过,本轮又踩了一次):worktree 删掉后回主树跑 cargo test 报 「Missing build-time config RB_SUPABASE_URL」,看起来像密钥没配, 其实是 build-script 缓存指着已删除的 worktree。touch src-tauri/build.rs 即可。

3.2 注入 → 必须红 → 还原 → 复核零残留

九种值得常备的注入(2026-08-27 全部实测变红):

注入该红的检查
add_reference_word 的归一改回 to_lowercase()every_declared_normalizer_actually_normalizes建成这轮之前,这一处零守门,而且它当时真的是坏的
在邻居文件(dictionary.rs)里偷加一条 INSERT INTO vocabularyno_undeclared_writer_of_the_four_tables
把某条豁免的理由缩成空壳("没事"同上那条测试的理由长度断言
writes_guarded_table 改成恒 falsewriter_detection_is_not_blind阳性对照的对照
临时 RVH 副本的黄金向量末尾加一个空格L1 byte-equal
删掉临时 RVH 副本里的 test/sm2_golden_test.dartL2 第三端在场
临时 RVH 副本的 mastery 阈值 10 → 5L4 阶梯逐条一致
cross-end-check.sh 写回一行散文期望值L3 第二本账守门
RVH_ROOT 指向一个不存在的目录L1/L2/L4 必须判 SKIP 而不是 PASS(「没拿到值」≠「值对」)

⚠️ 注入必须能通过编译,否则测试根本没跑 —— 那既不是红也不是绿,是没验, 而输出里看起来只是一条报错。

3.3 🔴 阳性对照:证明扫描器不是瞎子

这个域有三条断言天生是「没找到问题」形式的:

  1. 「两端抽出来的阶梯相等」 —— 正则两边都抓空,两个空集合相等 = 全绿。 故 mastery_ladder.py --selftest 断言:同构输入判等 · 改一个阈值判漂 · 函数不在时抓空 · 抓空一律判 SKIP 不判 PASS(L5 每轮跑它)。
  2. 「时间戳全都合规」 —— 库是空的 / 正则恒不命中 / 列清单取空,M1 都会安静全绿。 故 M2 用三个人造串探判据(裸串判裸 / Z 判合规 / +00:00 判合规), 并断言本次确实扫到了非零个非空值。
  3. 「没有未登记的写入方」 —— writes_guarded_table 恒假的话, 反向断言会一直绿而新写入方一个都抓不到。故 writer_detection_is_not_blind

🔴 本轮实测到一次真实的假绿,值得单记。 redline9_guard 第一版直接在整个 生产段上数 lemmatizer::normalize 的出现次数。注入「把归一改回 to_lowercase()」 之后 测试仍然全绿 —— 因为那个文件的红线 #9 文档注释里引用了这个函数名, 光注释就把计数喂饱了。守卫守着一句注释,不守代码。 已改为先剥注释行(同 vocab_scope.rs::nobody_hand_rolls_the_old_like_pattern 的惯例),并把「注释喂不饱计数」写进阳性对照。 教训:注入没红的时候,先问是守卫瞎了还是探针选错了 —— 这次是守卫瞎了。

3.4 🔴 让字节真的走一趟

前面每一条都只是「源码里两边写的一样」。真正让字节走一趟的是 M 段那三条, 它们读的是生产库里两端真实写下的串

① M1 的形状普查。 34 个 text 时间戳列 × 全部非空值,逐个问「有没有 UTC 标识符」。 它抓到的不是一个推测,是 45 行里 29 行的裸值。

② M3 的语义对账。 它不看字面量,只问 next_review − last_reviewinterval 差多少。这才是鉴别力所在 —— 它对「补个 Z 就完事」的假修复免疫。 本轮两条独立得出同一个结论、指向同一批行,这才坐实了 K1 不是解读错误。

③ 鉴别力对照。 同一批数据下:

  • learning-loop-verify.sh M1 → FAIL(29/45 裸值)
  • learning-loop-verify.sh M3 → FAIL(5 张复习过的卡里 3 张偏 8h)
  • cross-end-check.sh D 段(SM-2 三端)→ ✅ 全绿
  • sync-verify.sh 14 条 → ✅ 全绿
  • cargo test --lib sync 40 条 → ✅ 全绿
  • 黄金向量三端测试 → ✅ 全绿

六道既有守卫在同一个真实缺陷面前全绿。 原因很干净:它们验的是 「SM-2 算得对不对」和「行搬得对不对」,没有一道在问「算出来的那个时刻是哪个时刻」。 这就是本专题存在的理由。

3.5 判据类不需要造故障

本域的纯判据有两个,都已做成向量自检、不需要真的弄坏什么:

  • 「SM-2 该算出什么」 → 黄金向量本身(11 + 10 条),三端共同消费。
  • 「时间戳算不算合规」 → M2 的三形状探针(正向两个 + 🔴 反向一个裸串)。

4. 已知未修 / 有意接受

不要当成新发现报上来,但每轮仍要独立确认现状(还在?变严重了?条件变了?)。

#事情为什么现在不修什么条件下重新处理
K1🔶 根因已修(RVH 2026-08-28,cross-end/42),存量未修。 getNextReviewDate() 已改 UTC —— RVH 侧实测改了三处、缺一不可(比 RB 交接单说的一行多)。新写入的行从此合规:本轮新复习的 announce 偏差 0h。存量 3 张真实卡(and a half / campus / speak)仍偏 8hRVH 按交接单 §3.2 选 A(不动):裸串无法反推当时是哪个时区写的,「精确修复」在信息上就不成立;backfill 只能猜那 3 张各自被重新复习一次即自愈。⚠️ RVH 订正了 RB 原来的说法:哨兵那批不是「等重新复习」而是「等首次复习」,且产出方(新建 entry 时写哨兵)也要一并合规才不会持续产生新的裸串 —— 已确认 RVH 侧产出方在本轮修复覆盖内。🔴 别因为 M1/M3 红得久就把闸门关掉:它们红得,要关就等它自己变绿
K2已消解(2026-08-29,rvh-merge-plan T4-3):副本删除,RVH 的 Dart 测试直接读 ../docs/cross-end/sm2-golden-vectors.json(cwd=rvh/),三端消费同一个物理文件。🔴 原因不是问题被解决了,是前提没了 —— 本栏原来的「不修理由」是「让 RVH 跨仓读 RB 的文件会让它的 CI 依赖『RB 仓在同机并列存在』」,那在双仓时期成立;合仓(2026-08-28)之后两份文件同属一个 checkout,理由自动失效。(与 K11 同款处理)遗留的不是「同步」而是「别让副本长回来」:L1 已反转为负向断言(全仓不许出现第二份同名文件,两处注入实测变红),L2 收紧为按字面量断言 Dart 读的是根那一份(两处注入实测变红)
K3L4 靠正则读两种语言的源码。 谁把 if 链改成 match / 抽成查表,提取器就抓空写真解析器(syn + Dart analyzer)是另一个量级的工程,而这两处代码形状十年不变一次抓空一次就修。抓空判 SKIP 不判 PASS,所以它不会假绿,只会不再提供保障
K4redline9_guard 的穷举性靠一张邻居文件清单,不遍历文件系统(测试跑在 target/ 里,cwd 不可靠)。在一个全新目录里开写入方,守卫抓不到遍历文件系统的测试要么脆、要么要 build script 配合新增 commands/ 子目录时顺手加进 neighbours;或某次真的漏掉一个写入方时
K5🔶 半修(2026-08-27 改归一,2026-08-28 被 cross-end/42 §6A 挑战后重新定性)。三件事:① add_reference_word 全仓零调用方lib.rs 注册了,前端 / content-script / Rust 都不调),本机库 193 行全部 source='system' ⇒ 改前改后都不可达,那条修复是钉不变式、不是修活缺陷(原 CHANGELOG 与交接单把它写成了活缺陷,已订正);② 真正的活缺口在种子里:193 条中 4 条(olympics / pbs / gps / https不在任何消费点的键空间里 —— 它们连 vocabulary 行都不存在,而它们本想挡的 olympic / http 确实存在却没被挡。这 4 条是死条目;③ 种子在 seed_reference_words.sql = v3 已冻结迁移(红线 #11),改不得,只能走新编号迁移① 无调用方 ⇒ 无用户影响;② 4 条死条目的实际危害 = 两个专名没被挡,而专名过滤的主力早已是 vocab_scope 的 584 词判据,不是这张 193 词的表reference_words 接上 UI 时(那时 canonical 规则必须先定,见 cross-end/43);或某次顺手发新迁移规整那 4 条
K6单词归一的 Layer 3/4 是两端各一份算法代码,没有跨端断言。 只有短语归一有 rvh-phrase-normalize.csv 回归集Layer 1/2(数据层)已由预装库 byte-equal 覆盖,是绝大多数流量;Layer 3/4 是 fallback出现第一例「同一个词在两端归一成不同 PK」时。修法与短语那条同构:造一份跨端 CSV 回归集
K7两条均已修(2026-08-28,RVH 侧):红线 #7(addEntry 未 revive 软删行)由 v63 修掉 —— 原表现不是「少复活一次」而是加词直接失败isWordInNotebook 看不见软删行 → INSERT → 撞 UNIQUE ⇒ 删过的词再也加不回去),回归 redline7_revive_on_add_test.dart;红线 #9 由键空间规则落地(word_key.dart 单点 + key_space_guard_test.dart),回执 cross-end/46🔴 这一栏本身就演示了它警告的那件事:本条写着「仍在」,而事实早已相反 —— 同一份「RVH 未对齐」名单当时存在三处rvh/CLAUDE.md 的 backlog、本条、cross-end-check.sh 的人工 checklist),后两处都停在 4 条。2026-08-29(T4-4)已定死:唯一真相源 = rvh/lib/features/sync/CLAUDE.md §跨端契约镜像 backlog,另两处改为指针仍未对齐的是 #2(33 处 LOWER()与 #6clearLearningDataForUser 用户切换时 hard DELETE 5 张表,「单设备单用户」定位下可接受,多账号本地共存上线前必须重审)—— 状态去那节看,别回填到这里
K8get_due_cards 不看 known_words —— 标了「已认识」的词若还有活的 learning_entry,仍会进复习队列save_word / add_known_word 两个方向都会清对面,正常路径下不会出现这种行跨端并发(一端存词、另一端同时标已认识)造出这种行时。判据同 sync-consistency.md K1 的残余:写侧根治不了,要靠巡检
K9已修(2026-08-28,f43470e:键空间分流落地 —— db/word_key.rs 单点 + add_known_word 按调用方分流 + commands.ts 出口改名 addKnownWordFromVocab(名字即键空间声明)+ 3 条结构守卫。落地时查出第 5 个调用方:content-script 查词弹窗传的是页面 surface ⇒ RB 必须分流(RVH 45 §1.3 说他们不需要,那对他们成立 —— 他们的拍照路径根本不写 known_words存量刻意不回填:① 不能做成迁移 —— data-baseline.md F2「预装词汇不在迁移链里」,而回填判据恰恰要查 vocabulary,那种迁移只会在真机上炸;② 本机实测 population = 0(211 行活的 known_words 全是 source='recommended',走纯 SQL 豁免路径,用户标过的 0 条)野外出现中招行时。检测查询在 cross-end/43 §6.4,别猜。⚠️ 若将来给 reference_words 接 UI,K5 那 4 条死种子行与本条一起处理
K10已处置(2026-08-28):那 3 行 RVH 调试夹具已软删,且 debug 入口已改 nowUtc()46)。但软删不会让 M1 变绿 —— 墓碑按定义不删行,裸 created_at 仍在表里🔴 裁定 D(按列分,不按行分),不是 RVH 倾向的 A(M1 加 deleted_at IS NULL):红线 #6d P2 规定墓碑行的 synced_at = MAX(远端 updated_at ?? 远端 created_at, deleted_at)created_atupdated_at 可空的那三张表上就是比较操作数,按行放过全部墓碑等于对那类回声环永久失明。现按列判:参与比较的列裸值一律 FAIL(墓碑不豁免),不参与的列墓碑裸值只提示。分类由 information_schema 现推,不手抄名单结果与 A 相同(不动生产数据)但不失明。⚠️ 阳性对照已加:把分类写成恒 plain 时 M2 当场红(实测)。详见 cross-end/43 §7.2
K12已修(2026-08-28,77ed0e7remove_known_word裸 word,不走键推导 —— 由 RVH 收尾反馈点出(他们那边是「写读都改了、删侧漏了」)RB 一度看起来没事:唯一按词删的调用方传的恰好是 vocabulary 行 ⇒ canonical_word 恒等 ⇒ 与写侧凑巧一致。巧合不是保证。⚠️ 时序值得记:在 add 侧改成 canonical_word 之前这条路是一直坏的(存 person、删找 people),上一个 commit 把它从「一直坏」变成「凑巧对」,本次才「有保证」守卫 write_and_delete_sides_are_paired 钉住(canonical_wordsurface_word 出现数相等且 ≥2),注入实测变红
K11已修(2026-08-29,rvh-merge-plan T4-2):§E 改为三方比对,仲裁者是 supabase/sql 的 DDL —— 列在远端表里 ⇒ 同步列 ⇒ 两端都必须有(缺 = ❌);不在 ⇒ local-only ⇒ 两端不同合法(只打印)。那 3 条里 cached_file_path/content_html/linked_at/added_at 全都不在远端表里,自动转绿。🔴 没有采纳本栏原来开的方子(手工 local-only 白名单) —— 本栏自己就写了「白名单本身会腐烂」。仲裁者版本不需要维护名单:判据是「参与同步的列」,而那件事本来就有一份真相源(远端 DDL),白名单只是把它抄第二遍。剩 1 条走显式豁免名单 COLUMN_WAIVERSreading_notes:RVH:source_platform,RVH 那侧自带 TODO,补齐要升 RVH schema、须开 RVH 会话)。豁免条目必须带理由且每轮打印在报告里。§E 现随 .github/workflows/ci-cross-end.yml 进 CI

⚠️ 这张表本身也会腐烂。 每轮验收后更新它 —— 一条「已知未修」若其实已被修, 会让下一轮跳过一次本该做的检查,那是最贵的一种过期。


5. 验收台账

每轮一行。不新建报告文档 —— 产物按 README.md 的规矩拆散。

日期基线机械层判断层结论留痕
2026-08-270.1.0-dev.10Rust 43 条(srs / vocab_scope / lemmatizer)· 脚本 PASS=8 FAIL=3 SKIP=0(首次)—— 三条 FAIL 各有归属,没有一条是闸门接错线:M1/M3 = K1 那个真缺陷(待 RVH 修 + 那 3 张卡重新复习);L1 = 本轮订正黄金向量的两个说明字段后,RVH 副本待 cpcross-end/40 §1,机制按设计工作)建成本专题。🔴 抓到一个真实的跨端缺陷next_review_date 是 34 个共享 text 时间戳列里唯一带裸值的一列(45 行 29 行),根因是 RVH 的 getNextReviewDate() 用本地时钟而兄弟列全用 UTC ⇒ UTC+8 下卡片晚 8h 到期、且该端 merge 恒赢(K1,已交接)。两条独立断言(M1 形状 + M3 语义)指向同一批行,而六道既有守卫在同一缺陷面前全绿(§3.4③)。补上三个洞:红线 #9 此前零守卫(新增 redline9_guard,当场抓到 add_reference_word 手搓半个 normalize 并修掉)· 黄金向量的 RVH 副本无人看守(L1/L2)· cross-end-check.sh §D 的第二本账(改为从 JSON 现推 + L3 守住)。顺带修 cross-end-check.sh 两处失效判据:§A 死探针(空转五周,修完当场变绿)· §B 恒红版本判据。新增 L4 钉住 mastery 阶梯(黄金向量管不到的第二个跨端纯函数,今天两端一致)。9 处注入全部变红、零残留;其中第一次注入暴露出守卫自己的假绿(注释喂饱计数,§3.3)。新记 K1–K8本目录;K1/K7 进 cross-end/40;K2/K6 进 backlog;CHANGELOG
2026-08-280.1.0-dev.10脚本 PASS=9 FAIL=2 SKIP=0(L1 转绿,M1/M3 仍红)RVH 回执 42 落地后复验。主缺陷端到端验证通过:新复习的 announce(Dart 写的 Z 串)偏差 0h,三张旧卡仍 8h ⇒ 修复生效、存量待自愈(K1)。L1 转绿(副本已 cp)。🔴 RVH §6A 挑战 RB 本轮的 add_reference_word 修复,复核后两边各错一半normalize("bearing")="bear" 属实,但「改前是对的」不成立(那三个消费点的 v.word 恒为已归一形)。复核查出两边都没看到的两件事 —— ① 该命令全仓零调用方、193 行全 source='system',争论的行为两边都不可达(RB 原文写成活缺陷,已订正 CHANGELOG 与 N2/K5);② 消费点是 4 处不是 1 处分属两个键空间vocabulary 同时装 lemma 与非 lemma 行)⇒ 新增 N5 的两键空间判据,裁定见 cross-end/43。顺带查出种子里 4 条死条目(在 v3 冻结迁移里,只能走新迁移)。K1 按订正 B 改写;T2/T4 按订正 C/D 收敛措辞本目录;cross-end/43;CHANGELOG
2026-08-28(补)0.1.0-dev.10未重跑(本轮无代码改动)裁定回合收尾:RVH 回执 45 确认 §2.1 规则、零生产代码改动,并给出三条使裁定可落地的补充 —— 🔒 第三步钉死整串 normalize(他们以为「RB 只有一个 normalize」,实际 RB 也有 normalize_phrase,且实测数字与 RVH 逐字相同:6744 个多词条目,0 vs 46,故这条对称成立、更该进契约)· 🔒 读侧必须与写侧调同一个 canonical 函数(升格为落地要求 + 结构守卫)· 抓出 43 §5 待办表与 §2.2 正文自相矛盾(「修」vs「先别动」,已订正 —— §5 正是最容易被下一轮当行动清单执行的地方)。计数 72 vs 57 判为口径差(RB 那个是只覆盖 Layer 1/2 的 SQL 近似 = 下界),两端一致同意不写成基线断言。44/45 已登记本目录;cross-end/43 二次订正;README 总表
2026-08-28(收尾)0.1.0-dev.10Rust 236 passed · tsc 0 errorK9 落地:键空间分流 + 判据单点 + 3 条结构守卫,5 处注入全红。🔴 其中一处第一次没红:把第三步换成逐 token 的 normalize_phrase 之后测试全绿 —— 内存测试库里 dining room 本身就是一行,EXISTS 分支直接返回,第三步根本没执行;而能走到第三步的多词输入其结果依赖运行时 lemma_* 表,单测里没有。探针选错了,不是守卫瞎了 —— 改成源码层钉住「调的是哪个函数」。这恰是 RVH 45 §2.2 请求钉死的那条,我先前只写进文档没有断言。落地时另查出 add_known_word第 5 个调用方(content-script 弹窗传 surface)⇒ RB 必须在调用方分流。存量回填刻意不做(F2 + population=0,见 K9)本目录;cross-end/43 §6;CHANGELOG
2026-08-28(收尾二)0.1.0-dev.10Rust 237 passed · tsc 0 error · 脚本 PASS=9 FAIL=2处理 RVH 收尾反馈三条。🔴 第三条命中一个活缺陷remove_known_word 走裸 word,写删两侧不对称(K12,已修 + 补成对守卫)。第二条裁定 D 而非 RVH 倾向的 A —— A 会让整类墓碑对 #6d P2 的 created_at 回落操作数失明(K10)。本轮自己造了两个坑并当场修掉:重写 M1 时弄丢 ::int::text 让 M2 探针恒空(假红)· say 函数从未定义,M1 那两行「仅墓碑行」提示一直是死的——命令找不到、报错进 stderr、没有 set -e 于是照常往下跑,输出里什么都看不到。判据都一样:没输出 ≠ 没问题本目录;cross-end/43 §7;CHANGELOG