主题
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 整包 SHA256 | ae33c8a4… | ae33c8a42be314b57ed4c326ff600b034e95cd383d8d9e1a69bcc53546220d81 | ✅ |
vocabulary 行数 | 18,925 | 18,925 | ✅ |
| 删除行数 | 53 | 53(与 31-deleted-word-migration.tsv 的 deleted_word 列逐行相同,diff 空) | ✅ |
| 新增行数 | 80 | 80 | ✅ |
lemma_surface_to_base | 140,277 未动 | 140,277,与旧库逐行 hash 相等 | ✅ |
lemma_base_forms | 101,713 未动 | 101,713,与旧库逐行 hash 相等 | ✅ |
proper_noun 标签 | 1,055 | 1,055 | ✅ |
example_translations | 全覆盖 | 12,163 个词条的 pos_definitions 含该键 | ✅ |
| 38 个迁移目标是否都在新库 | 全在 | 0 缺失 | ✅ |
rath 映射 | rather(非 rat) | rath → 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.dart:vocabExists == 0 → getVocabularyAndPersist(remoteWord))。所以只要 RB 端还留着 alway 的生词本条目并推上来, RVH 就会把 alway 以 source='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分:缺的一组是freq17 +freq7 +oxford1, 而有的一组同样含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-POScefr是两个真相源,谁用哪个没有统一约定 —— 拍照管线用后者,快速分级 / 难度直方图用前者。两者不一致时 UI 会自相矛盾。这是 RVH 自己的设计债,不是 RB 的问题。
6. 例句译文泄露(doc 31 §6)——已钉住,但你们的前提在 RVH 不成立
核实结论:RVH 目前零泄露风险。 example_translations 在 lib/ 下零消费, 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
_preinstalledVocabVersion27→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.tsv 里 53 个被删词逐个查询,全部不存在; 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 查明,值得记下来):不按词的属性分,而是按哪条载荷写进去的分 —— _applyFold 造 name 块时代码里压根没写 cefr 键(157/157 全缺)+ _applyAdd 从素材 直灌不补(55/168)= 212,与全库缺口总数一个不多。这解释了我们为什么用 cefr_source 和 POS 键两个假设都切不开。
9.2 资产核验(v29)
| 项 | 期望(doc 33) | 实测 | |
|---|---|---|---|
| SHA256 | 06cbb4dc… | 06cbb4dcde22205d777cd218c1910dacc926cc38d7d8cc95683bfcf0b37b6d59,与 RB 仓源文件逐字节相同 | ✅ |
vocabulary | 18,916 | 18,916 | ✅ |
| 删的 9 个校准词 | 全删 | 0 存留 | ✅ |
| 例句总数 | 86,693 | 86,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 |
always | A1(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…,没有任何交接单为它背书。
未改写历史(沿用 55f909e → 8a97976 的先例),由本轮资产提交把字节推进到正式交付的 06cbb4dc…,并在提交信息里写明经过。教训:跨端资产由对端脚本直接写进本仓工作树, 所以 git add -A 在本项目里始终不安全 —— 提交前必须逐条看 git diff --cached --name-only。