Skip to content

32 · round30 预装库已接收并落地(RVH → RB 回执)

物理仓库位置:~/reading_vocab_helper/docs/cross-end/32-rvh-round30-confirmation.md(RVH)。 日期:2026-08-26 · 方向:RVH 回执 对应交接单:~/reading-browser/docs/cross-end/31-rb-round30-reseed-handoff.md 前情:RVH 请求单 doc 29 · RB 本轮范围与实测 doc 30


1. 一句话

doc 31 §4 的 7 项待办全部完成,含真机端到端复验(§7.1)。预装库整包接收(未改一字节)、_preinstalledVocabVersion 27→28、导入逻辑落方案 A(两阶段 prune)、FK 决策已定并实现、跨端 phase0 复验 byte-equal、 例句译文泄露已加回归测试钉死。

RB 侧可以发版。但请先看 §5.2 —— 真机实测挖出新补的 80 个词里有 25 个缺 per-POS cefr, 其中 9 个正经可学词(含 ourselves)会在拍照识词里被静默丢掉。不阻塞发版,但需下一轮修。


2. 资产核验(全部通过)

期望(doc 31)实测
db 整包 SHA256ae33c8a4…ae33c8a42be314b57ed4c326ff600b034e95cd383d8d9e1a69bcc53546220d81
vocabulary 行数18,92518,925
删除行数5353(与 31-deleted-word-migration.tsvdeleted_word逐行相同,diff 空)
新增行数8080
lemma_surface_to_base140,277 未动140,277,与旧库逐行 hash 相等
lemma_base_forms101,713 未动101,713,与旧库逐行 hash 相等
proper_noun 标签1,0551,055
example_translations全覆盖12,163 个词条的 pos_definitions 含该键
38 个迁移目标是否都在新库全在0 缺失
rath 映射rather(非 ratrath → rather

抽查:alway rath trie ye porno 已消失;always rather during ourselves 现在都有本地词条(A1/A2/A1/A2);doc 31 §2 明确保留的 lat rend sooth uninterest 都还在。

⚠️ doc 31 §4 第一条的 SHA 写错了:那里写的是 c875b3db…,与同文件 §1 的 ae33c8a4… 不一致。实际文件是 ae33c8a4…(= §1 正确)。建议 RB 侧订正 §4,免得下次有人拿它当校验基准。


3. 跨端 phase0 byte-equal 复验(红线 #5e)

RB : cargo run --example phase0_normalize --release --quiet < test/data/lemma-regression.txt
RVH: dart build cli -o <out> && <out>/bundle/bin/phase0_normalize < test/data/lemma-regression.txt
diff → 空                                            ✅
layer 词表 → {base, dictionary, fallback}            ✅(在预期词表内)

本轮 lemma_* 一行未动,所以 assets/nlp/*.json fixture 无需同步、两个 JSON 的 SHA 不变。 这与 v26/v27 相反(那两轮改的正是 lemma 表),CLAUDE.md 红线 #5e 已加一段⚠️防惯性误改。


4. 落地内容

4.1 方案 A:导入逻辑加删除步骤

app_database.dart 原先是纯 UPSERT,唯一的 DELETE 只清 source='backfilled'。现在 UPSERT 之后多一步 prune:本地 source='preinstalled' 但新库已不含的词条要被删掉。

🔴 实施中翻掉了一个此前双方文档都默认成立的前提:软删并不释放 FK。

doc 31 §3 / RVH doc 29 §5 / RVH backlog 的选项设计,都隐含「软删用户那条 rath → 就能删词条」。 实际不成立:软删只是给行打 deleted_at行还在、word 列仍指向被删词条ON DELETE RESTRICT 照样把 DELETE FROM vocabulary 拒掉。

处理方式是拆两阶段,没有PRAGMA foreign_keys = OFF 绕过去(那会留下指向不存在词条的 悬空 FK,正是红线 #6b 那类 footgun):

  • 阶段一prunePreinstalledVocabulary,跟着导入跑):无人引用的词条直接 DELETE; 被引用的先写墓碑 —— 软删生词本条目 + 其 word_page_links + 该词的 word_cloze_contexts (红线 #5g),词名记进 app_metadata['pending_vocab_prune']
  • 阶段二retryPendingVocabPrune,每次启动跑一次,待办为空时是一次 KV 读的 no-op): 该词的所有残行都「已软删」「墓碑已 push」(synced_at >= updated_at)时, 硬删残行释放 FK,再删词条。

为什么阶段二要等 push:删除得跨端传播(红线 #6)。墓碑没上行就把本地行抹了, 删除信号永久丢失,RB 会把 alway 条目再推回来。 word_page_links 也要单独等 —— 硬删 entry 会 CASCADE 掉它们,把尚未上行的 v55 墓碑物理抹掉

回归测试 test/shared/database/preinstalled_prune_test.dart:8 例,跑的是真实生产代码 (两个方法只依赖传入的 Database,已标 @visibleForTesting),建库用真实 DDL,无 SQL 转录。 其中「🔴 软删本身不释放 FK」一例专门钉住上面那条反直觉约束。 两处注入式反向验证已实测:去掉阶段二的 push 检查 → 该例立刻红;去掉阶段一对 word_page_links 的级联软删 → links 那例立刻红。

4.2 FK 产品决策

53 个被删词一律软删用户生词本条目,不做词形迁移。 31-deleted-word-migration.tsv 给的 38 个迁移目标未采用(但已用它做过校验:删除集逐行一致、目标全部存在、rath→rather 正确)。

理由:迁移会改同步表的自然键 UNIQUE(user_id, word) —— 保 id 改 word 则 push 时 on_conflict=(user_id,word) 匹配不上、退化成 INSERT 又撞 PK(id);而当时双端用户数据已全清, 没有真实条目可迁,为一个零命中的路径引入跨端风险不划算。

4.3 版本号

_preinstalledVocabVersion 27 → 28,并在版本注释里补了 round30 的完整 as-built。


5. 请 RB 跟两件事

5.1 清理你们自己的 learning_entries

doc 31 只说了预装库的 prune,没提 RB 端用户数据。RVH 这边发现一条能让 prune 白做的路径:

RVH 的 pull 对缺失词条有 backfill 兜底(sync_repository_impl.dartvocabExists == 0getVocabularyAndPersist(remoteWord))。所以只要 RB 端还留着 alway 的生词本条目并推上来, RVH 就会把 alwaysource='backfilled' 回填进 vocabulary 复活它 —— 而 OCR 纠错器的 候选表是 SELECT LOWER(word) FROM vocabulary 全表不分 source,于是 P1 回潮。

当前判断:不可达,不是在发生的事(双端用户数据 2026-08-25 已全清,两端字典也都没有这些词形, 用户造不出这种条目)。所以 RVH 本轮没有建 blocklist —— 那是给一个零命中路径加机制。

但请 RB 确认一下:RB 端本地库里若还有指向这 53 个词的 learning_entries 残行, 清掉或写墓碑。RVH 侧的墓碑会经 sync 传播过去,正常情况下 RB 会自动收到。


5.2 🔴 新补的 80 个词里,25 个的 pos_definitions 缺 per-POS cefr

这条是真机实测挖出来的,单测和 SHA 校验都发现不了。

现象:ourselves 在 OCR 管线里被判成 UNKNOWN 并被 CEFR 筛选丢掉,尽管它的 primary_cefr_level 明明是 A2

根因:RVH 的难度筛选读的是 pos_definitions 里的每个 POS 自己的 cefr, 不是 primary_cefr_level 列。对比:

json
always      {"adv": {"cefr":"A1", "definitions":[...]}}    ✅
rather      {"adv": {"cefr":"A2", "definitions":[...]}}    ✅
ourselves   {"pron":{            "definitions":[...]}}    ❌ 没有 cefr 键

量化(非短语词,按「该词的每个 POS 是否都带 cefr」统计):

集合全 POS 都有部分 POS 缺全缺
存量词(12,226)98.7%1.3%0.03%(4 个,全是专名)
round30 新增 80 词46.2%22.5%31.2%(25 个)

98.7% vs 46.2% —— 新增行是由一条基本不写 per-POS cefr 的路径产出的, 不是零星漏。存量那 4 个全缺的还都是专名(corey gary johnson virginia)。

两个已排除的假设(省得 RB 再走一遍): · 不是按 cefr_source:缺的一组是 freq 17 + freq 7 + oxford 1, 而有的一组同样含 freq 的 C1/C2 各 20/11 —— 两组都有 freq,切不开。 · 不是按 POS 键分('name','noun') 在两组都出现(缺 5 / 有 14), ('noun',) 也是(缺 3 / 有 8)。

剩下的可能性指向 pipeline 里「新词补入」那条支路本身 —— 具体是哪一步 RB 侧看代码更快, RVH 只能看到产物。

25 个里 16 个是专名(kansas massachusetts philippines reuters ellis searsdali sara …)—— 它们被筛掉反而符合专名治理的意图,不算问题。

真正的问题是另外 9 个正经可学词,它们会在拍照识词里被静默丢掉、永远不进候选:

ourselves(A2)  measles(C2)  confetti(C2)  impending(C2)  insignia(C2)
undersigned(C2)  unqualified(C2)  bioethics(C2)  cheerleading(C2)

ourselves 尤其讽刺:它正是本轮为补 lemmatizer 缺口而加的词之一。加完之后 「查词能查到了」(不再走 Supabase 兜底)✅,但「拍照时会被推荐」❌ —— 修好了一半。

请 RB 在下一轮(不必为此单开一轮)给新增词补上 per-POS cefr,取值与 primary_cefr_level 对齐即可。RVH 侧不自行修补 —— 预装库是 RB 单点产出(红线 #10), RVH 就地改会破坏 byte-equal。

顺带一个 RVH 侧可以自查的问题(已记进 backlog,本轮不动):primary_cefr_level 与 per-POS cefr 是两个真相源,谁用哪个没有统一约定 —— 拍照管线用后者,快速分级 / 难度直方图用前者。两者不一致时 UI 会自相矛盾。这是 RVH 自己的设计债,不是 RB 的问题。

6. 例句译文泄露(doc 31 §6)——已钉住,但你们的前提在 RVH 不成立

核实结论:RVH 目前零泄露风险。 example_translationslib/零消费PosDefinition.parseFromDatabaseJson 只读 item['examples'],译文进不了任何 UI。

⚠️ doc 31 §6 有一处对 RVH 的事实误判:那里说 buildFallbackCloze 是「复习卡在没有真实 语境时的例句回落路径」。实测 buildFallbackCloze 在 RVH 全仓无人调用(只有定义, cloze_builder.dart:159)—— RVH 的复习卡走的是 buildContextCloze,语境来自 word_cloze_contexts(用户真实阅读句),不碰预装库例句。所以它与 RB pickFallbackCloze 虽然同构,但当前不在活路径上。

仍按建议补了同构 Dart 测试 (test/features/vocabulary_notebook/cloze_example_translation_leak_test.dart,3 例), 守的是将来有人给 buildFallbackCloze 接线时别把译文一起喂进去:

  • fixture 用 v28 库里 abide真实行(含 examples / example_source / example_translations 三个平行数组)
  • 判据是「卡面正面出现任何汉字即失败」,比逐字比对稳
  • 第 3 例是注入式反向验证:故意把译文当例句喂进去,证明这个判据真的会红 (否则前两例可能因判据太松而永远绿)

「例句底下要不要常驻中文」这个产品决策 RVH 侧同意 doc 30 §5.4 的结论:数据进库 ≠ 默认显示, 之后随时可迭代,不需要再来一轮 reseed。本轮不做展示。


7. RVH 侧 §4 清单逐条

  • [x] 提交已同步的资产(未改一字节,红线 #10)
  • [x] bump _preinstalledVocabVersion 27→28
  • [x] 落 A:导入逻辑加删除步骤(两阶段,见 §4.1)
  • [x] FK 决策 + 实现(一律软删,见 §4.2)
  • [x] 跨端 phase0 byte-equal 复验
  • [x] 真机复验:OCR 路径不再把 always 改成 alway(见 §7.1)
  • [x] 抽查 rather / during / ourselves 本地命中(ourselves 有词条但有别的问题,见 §5.2)
  • [x] 确认复习卡正面不渲染 example_translations + 补同构 Dart 测试
  • [x] 本回执 + 更新 RB 仓 cross-end README 编号总表

flutter analyze 无 error/warning;flutter test 1200 例全绿。

7.1 真机实测(Redmi 24094RAD4C,2026-08-26)

安装 + 导入:全新安装 → v0 → v28✅ Upserted 18925 words in 6647ms, prune 步骤正常执行并报 🧹 Prune: no stale preinstalled words

⚠️ 如实标注:全新安装时本地词库 = 资产词库,所以 prune 的删除分支在全新安装上 天然测不到(正是 doc 25 §7 提醒的「全新安装测出来是好的」那个陷阱的同款)。 删除分支由 preinstalled_prune_test.dart 的 8 个用例覆盖。

设备库核验rvh_db_query 直查):vocabulary 18,925 行全部 source='preinstalled'31-deleted-word-migration.tsv53 个被删词逐个查询,全部不存在always(A1) / rather(A2) / during(A1) / ourselves(A2) / latter(A2) / outer(B1) 都在。

P1 端到端(走的是 OCR 拍照路径,不是文本路径):造了一张文字铺满的图, 经 ACTION_SEND + image/jpeg 喂进 app,走完整 OCR 管线。模糊匹配是开着的 (本次实际修正了 5 个词:not→note for→fore would→world shall→shallow opti→optic), 即这确实是 enableOcrFuzzyMatch=true 的那条路径:

✅ 通过单词 (13): world, rather, always, wait, fore, the, latter,
                 shallow, note, deceive, during, outer, optic
   📊 A1: world, always, during     ← always 原样通过,未变 alway
   📊 A2: rather, latter            ← rather 未变 rath;latter 未变 letter

机制上也是闭合的:always 现在在 vocabulary 里,OcrFuzzyMatcher.matchBatch 第一步 if (dictionaryWords.contains(lowerWord)) continue; 直接跳过它,根本不进纠错; 即使进了,alway 已不在候选集里(候选只从 dictionary.contains(candidate) 产生), 也生不出来。P1 两半都闭合。

踩到的两个环境坑,记下来省得下次重查:① 图片分享 intent 静默失败,因为 app 没被授予 READ_MEDIA_IMAGES —— copyUriToCache 的 catch 吞掉异常后 paths.isEmpty() 直接 return, 日志里什么都没有。adb shell pm grant com.lampio.app android.permission.READ_MEDIA_IMAGES 解决。 ② 第一张测试图是大片留白 + 顶部小字,ML Kit 报 No text detected;换成文字铺满整页才识别。


8. 本轮 RVH 没做的

  • 不建 backfill blocklist(§5.1,零命中路径)
  • 不展示例句译文(§6,独立产品决策)
  • 不做词形迁移(§4.2,产品决策)
  • 露骨例句残留(doc 31 §7 自陈的 eff you see kay why oh you 那类)RVH 侧未处理, 等 RB 下一轮 —— 它在 pos_definitions 里,RVH 是纯消费方。

9. 小补轮(RB doc 33)接收记录 —— 预装库 v28 → v29

RB doc 33 说「回执如无异常可并进你们下一份文档」,故并在此处,不单开一份。 交接单:~/reading-browser/docs/cross-end/33-rb-explicit-example-cleanup-handoff.md

9.1 §5.2 已被 RB 修掉

我们在 §5.2 上报的 per-POS cefr 缺失,RB 当轮修完(doc 33 §4):212 个 POS 块补齐 (取值 = 该行 primary_cefr_level只补缺失、不覆盖已有值)。

本端复验:非短语词 12,306 个,全 POS 齐全率 100%,缺口 0;剩余 6,607 个缺口 全部落在短语库(phrasal_verb/idiom,设计上就无 CEFR)。我们点名的 9 个受损可学词 现已全部带 per-POS cefr:ourselves(A2) measles confetti impending insigniaundersigned unqualified bioethics cheerleading(后 8 个均 C2)。

根因(RB 查明,值得记下来):不按词的属性分,而是按哪条载荷写进去的分 —— _applyFoldname 块时代码里压根没写 cefr 键(157/157 全缺)+ _applyAdd 从素材 直灌不补(55/168)= 212,与全库缺口总数一个不多。这解释了我们为什么用 cefr_source 和 POS 键两个假设都切不开。

9.2 资产核验(v29)

期望(doc 33)实测
SHA25606cbb4dc…06cbb4dcde22205d777cd218c1910dacc926cc38d7d8cc95683bfcf0b37b6d59,与 RB 仓源文件逐字节相同
vocabulary18,91618,916
删的 9 个校准词全删0 存留
例句总数86,69386,693
lemma_*一行未动与 v28 版逐行 hash 相等(140,277 / 101,713)
三个平行数组等长examples/example_translations/example_source 0 处长度不齐

lemma_* 未动 ⇒ assets/nlp/*.json fixture 无需同步、两个 JSON 的 SHA 不变。

9.3 🔴 prune 的删除分支这次在真机上真正跑了

doc 32 §7.1 当时如实标注过一个局限:全新安装时本地词库 = 资产词库,prune 的删除 分支天然测不到。这一轮是 v28 → v29 升级路径,设备上本来就有那 9 个词,于是删除 分支第一次在真机上真实触发:

🔄 Preinstalled vocabulary: v28 → v29
✅ Upserted 18916 words in 20648ms
🧹 Prune: 9 stale preinstalled words → [blowjob, bukkake, cunt, handjob,
          milf, trackback, creampie, deepthroat, footjob]
🧹 Prune: deleted 9, deferred 0 (FK-referenced)

设备库复查:vocabulary = 18,916、9 个词 0 存留、preinstalled_vocab_version = 29、 pending_vocab_prune = [](无 FK 阻塞,符合预期 —— 生词本是空的)。 再次启动确认早退分支正常:✅ Preinstalled vocabulary is up-to-date (v29), skipping

doc 33 §1 的判断成立:我们的 prune 是通用集合差、不是硬编码 53 词清单, 本轮 9 个词零改动自动被清掉。

9.4 §4 复验:ourselves 不再被判 UNKNOWN(真机,前后对照)

同一张图、同一条 OCR 分享路径,v28 与 v29 两次实测对照:

v28(修复前)v29(修复后)
CEFR 筛选14 → 13 通过, 1 过滤14 → 14 通过, 0 过滤
ourselves🚫 CEFR 不匹配 (-1): ourselves(UNKNOWN)📊 A2: rather, latter, ourselves
alwaysA1(P1 已修)A1(未回潮)
✅ 通过单词 (14): world, rather, always, wait, fore, the, latter, shallow,
                 note, deceive, ourselves, during, outer, optic

唯一仍是 UNKNOWN 的是 the(走「模糊匹配仍未识别 → UNKNOWN 保留」分支,与本轮无关)。

⇒ doc 33 §4 的修复在 RVH 侧确认生效。

9.5 收到但本轮未做的两条 RB 提醒

  • 拼读脏话扫不到(doc 33 §5):词界锚定的词表扫不到 eff you see kay why oh you 这类按字母拆开拼读的脏话,是必要不充分的。RVH 目前没有同类内容扫描,故无动作; 若将来要做,补一层掩码/拼读变体规则。
  • 三个平行数组的下标脆弱性(doc 33 §5 末):examples / example_translations / example_source 按下标配对,只删其一会让此后每条例句配上别人的译文和来源, 无异常无日志。已写进 CLAUDE.md「项目当前状态」的 example_translations 那条。 本轮实测新库 0 处不齐。

9.6 一条要如实记下的操作事故

e10dbfc(标题 docs: 补强 per-POS cefr 缺失的证据…)里混进了 lampio_dict.db。 经过:RB 会话在 13:58 重跑同步脚本覆盖了 RVH 工作树里的资产,而我用了 git add -A —— 正是 doc 25 那轮 55f909e 踩过的同一个坑(当时也是二进制被扫进标题是 docs 的提交)。 更糟的是它当时的字节 be7a0582…同步过程中的中间态,既非 ae33c8a4… 也非 最终的 06cbb4dc…,没有任何交接单为它背书。

未改写历史(沿用 55f909e8a97976 的先例),由本轮资产提交把字节推进到正式交付的 06cbb4dc…,并在提交信息里写明经过。教训:跨端资产由对端脚本直接写进本仓工作树, 所以 git add -A 在本项目里始终不安全 —— 提交前必须逐条看 git diff --cached --name-only