Skip to content

24 · lemmatizer 资产 v26 修正 + 预装库 reseed 交接(RB → RVH)

物理仓库位置:~/reading-browser/docs/cross-end/24-rb-lemmatizer-asset-v26-reseed-handoff.md 日期:2026-08-18 · 方向:RB 产出 → RVH 整包接收(纯消费方,红线 #10 / RVH #5e) 触发:RVH 侧缺陷交接「P2 — lemmatizer 资产:高频功能词被映射成生僻/错误 base」 (原文 ~/reading_vocab_helper/docs/plans/backlog.md


1. 一句话

AGID 把一批现代高频词当成古语/残根的屈折形(ratherrathalwaysalwaybasedbas),修正落在 src-tauri/examples/build_dict.rs 的修正层,重跑生成器 + 只重烤预装库的 lemma_* 三张表。两个资产的 SHA256 都变了(下面 §3)。


2. 三个与交接单预期不同的地方(先读这段)

2.1 ⚠️ 只删错映射不够 —— Layer 3 会把答案原样推回来

交接单写的修复路径是「照 v18 的做法:删除错映射,让 Layer 2 base_forms 自映射返回原词」。 这在 as 上成立(as 当时已在 base_forms 数组里),在这批词上不成立

rather   不在 base_forms → 删 Layer 1 映射后落到 Layer 3
         后缀规则 "er" 提议 stem = "rath"
         rath ∈ base_forms → 裁决通过 → 仍然返回 rath   ❌
always   同理:"s" 规则提议 alway,alway ∈ base_forms → 仍然返回 alway ❌

实测复现过(删映射后跑 4 层管线,rather 依旧输出 rath)。所以修正必须是两步surface_to_base 映射 + 把该词补进 base_forms,让它在 Layer 2 就被截住, 根本走不到 Layer 3。生成器里对应 force_as_base 机制(它本来就是这两件事一起做)。

落到测试:lemmatizer.rs::v26_high_frequency_words_lemmatize_to_self 除了断言结果, 还断言 layer == "base" —— 只断言结果的话,将来若有人只删映射而漏补 base_forms, 在 rath 恰好也被移出 base_forms 的巧合下测试仍会绿,而缺陷已经复发。

2.2 ⚠️ base_forms.json 也变了(交接单预期「若未动则不变」)

补进 base_forms 的有 63 个词,所以两个资产的 SHA 都要更新,不是只有一个。

2.3 修正的落点是 build_dict.rs,不是 patch_lemmatizer_assets.py

交接单指的是后者。但那个 Python 补丁当前是坏的,且已被取代:

  • v18 的三条修正 2026-06 就被回迁进了生成器 build_dict.rs §2.4 corrections / §2.5 force_as_base——实测重跑生成器可字节复现仓库里的资产(两个 SHA 一致), 说明生成器才是真相源。
  • 那个 Python 脚本的 REPO 路径停在迁仓前(指向不存在的 assets/nlp/,实际在 src-tauri/assets/nlp/),且它断言「原串恰好出现 1 次」——v18 已应用后必然失败。 即它今天跑任何一次都是直接报错退出。
  • 已于本次删除(git rm)。修正层单一真相源 = build_dict.rs

3. 资产变更

资产旧 SHA256新 SHA256条数
surface_to_base.jsonca75b17972fc239d82affdb13634477d22f0bbc461527d3d2e4c05e73195614798a24cf47ac457a05f1e172211bdd14ed59291732ada3fbfc5fcaf4e52e31d97140,370 → 140,281(−89)
base_forms.json3094c829fee1bcaf430917a9f77377d146cc93e8f6c591cd622b45381f7deadca4ef3b1047c7252556721525da97c378d57b1c4686652567b3e37ca02aabcf59101,646 → 101,709(+63)

生成器统计:corrections 92 条(v18 的 3 + v26 的 89)· force_as_base 170 条(旧 81 + v26 的 89)。 共 178 个 surface 的归一结果改变


4. 全量扫描:方法与判据(可复现)

交接单要求全量扫,不是只修点名的 8 条。做法:

第一层 · 机械撒网(高召回,不追求精度)

  • 主网:surface 频率排名 ≤ 20000 ∧ base 排名 > surface 排名 × 3 → 283 条
  • 正交网:base 完全不在 33 万词频表里 ∧ surface 排名 ≤ 30000 → 32 条
  • 频率源 tools/vocabulary_builder_v3/data/google_word_freq.txt,危害判定用预装库 vocabulary 表(base 是否会成为生词本词条)

第二层 · 逐条人工裁定(并集 303 条)。纯机械规则无法分离真假阳性—— people→personwomen→womandetails→detail 全部命中频率反转但都是对的 (复数在网页语料里就是比单数常见)。判据定为:

= base 在现代英语里不是该 surface 的词形(截断残根 / 古语 / 另一个词) 不修 = base 是同一词的规范词典形(person/woman/criterion/medium/datum搁置 = 两读都成立、需 POS 上下文才能判(Layer 1 是无上下文查表,架构上做不到)

第三层 · 完整性清扫(这一层最关键) 按 base 反查该残根下的全部 surface。首轮只修「频率网命中的那几个」时, 实测漏下 64 条同源映射——修了 changed→change 却留着 changing→changciting→citleased→leas。只修点名的词等于修一半。


5. 裁定结果

5.1 REMAP(89 条)— surface 确是屈折形,base 是残根/错词

代表:based→base(原 bas)· changed/changing→change(原 chang)· raised/raises→raise(原 rais)· installed/installing/instals→install(原 instal)· discussed/discussing→discuss(原 discus)· batteries→battery(原 batterie)· tries→try(原 trie,与 v18 的 trying 同源)· latest→late(原 lat)· routing→route(原 rout=溃败)· soothing→soothe(原 sooth=古语「真实」)· automated→automate(原 automat=自动售食餐厅)· comics→comic(原映射到短语 comic strip

全表见 src-tauri/examples/build_dict.rs §2.4。

5.2 SELF(89 条)— surface 本身就是现代词条,强制自映射

  • 功能词/高频副词:rather always sometimes latter during yes his us inner outer ourselves overseas
  • 复数已词汇化:clothes pants sunglasses upstairs downstairs outskirts crossroads measles annals congratulations grassroots confetti insignia sweepstakes
  • 派生词而 base 为非词:upcoming(upcome) outstanding(outstand) unbiased outdated unpaid unqualified undersigned unwilling housekeeping cheerleading handwriting outlying outgoing pending handheld impending
  • base 为古语/无关词:opera(opus) genus(genu=膝) rent(rend) stove(stave) rebound(rebind) overlay(overlie) laden(lade) collier(colly)
  • 学科 -ics 漏网:bioethics
  • 单/双字母噪声:s(→ko) d(→ski) si(→sus) ka(→kum) cha(→chon) ya(→yon)
  • 专有名词被截断(base 已落进预装词库 = 危害已确认):vegas(vega) wales(wale) jesus(jesu) kansas(kansa) massachusetts netherlands philippines emirates 等 32 个

全表见 build_dict.rs §2.5。

5.3 KEEP — 交接单点名「勿动」的 5 条,已加断言保护

people→person · could→can · its→it · better→good · longer→long 全部保持原样。 两道保护:生成器 §4c 哨兵断言(不符即 panic)+ 单测 v26_correct_mappings_left_untouched。 同轮判定为正确因而未动的还有 women→woman media→medium criteria→criteriondata→datum alumni→alumnus bacteria→bacterium algae→alga fireworks→firework 等。

5.4 PARK(7 条)— 本轮不动,理由不是遗漏

映射为什么不动
bit→bite · ground→grind · clad→clothe · inbred→inbreed · capita→caput两读都成立("a bit" vs "bit the apple";"on the ground" vs "ground the beans")。定这类需要 POS 上下文,而 Layer 1 是无上下文查表——改成任一边都会在另一边制造等量的错。属架构限制,不是漏扫
bored→bor目标 bore 自身映射到 bear(bore = bear 的过去式)。改成 bored→bore 会让 normalize(normalize("bored")) = "bear" ≠ "bore",破坏幂等性不变式
routed→routrout(溃败)与 route 两读都成立;只修了无歧义的 routing

6. 预装库 lampio_dict.db 怎么处理(读这段再动手

本次采用:只重烤 lemma_* 三张表,vocabulary 表一行未动。 入口:tools/vocabulary_builder_v3/bin/rebake_lemma_tables.dart(本次新增)。

bash
cd tools/vocabulary_builder_v3
dart run bin/rebake_lemma_tables.dart --dry-run   # 先看行数
dart run bin/rebake_lemma_tables.dart

为什么不重跑整条 pipeline

generate_db.dartoutput/applied_vocabulary.jsonl,而 output/ 目录不存在 (gitignored 中间产物,早已丢失)。Step 4 的 LLM 翻译 resume 缓存 (output/translated_vocabulary.jsonl)同样没了 —— 整库重跑 = 为 ~12k 词重新付费翻译 (config 注释估 $5-15)+ 数小时 + 12,291 条全部可能变动(等于一次完整 reseed)。 资产修正不需要重算 vocabulary,为它付这个代价不成比例。

这个选择的已知代价(不是 bug,是知情取舍)

  1. 预装库 vocabulary 表里由归一结果产生的 headword 仍在:alway(A1) rath(A2) lat(A2) ye(A1) sometime(A1) opus(A1) vega(C1) …… 共 91 个。修正后不再有 任何 surface 归一到它们 → 成为不可达死行(查不到、也不会再进任何人的生词本)。
  2. 反过来,always / rather / during 等正确词形在预装库里没有词条 → 用户查这些词会走 Supabase 缓冲池兜底(联网、首查稍慢),而不是本地命中。 其中 us / his / yes / as 本来就在 pipeline 停用词表里,本就不该有词条。
  3. 两者都在下次整库 reseed 时自然收敛(届时 wordlist 用新资产归一, alway 死行消失、always 拿到正经词条)。已记进 backlog。

VOCABULARY_SEED_VERSIONdb/helpers.rs故意不 bump:它描述的是 vocabulary 表的 payload,而本次 payload 一字未动;bump 会让所有存量用户白跑一次 18,898 行 upsert。lemma 表不走 seed 路径(运行时从 bundled db 直读),不受该常量影响。


7. RB 侧已完成清单

  • [x] src-tauri/examples/build_dict.rs:§2.4 corrections +89 / §2.5 force_as_base +89
  • [x] 生成器新增 §4b 自检闸:断言修正确实落地(§2.6 二次清理跑在修正之后, 完全可能把刚写好的映射又删掉;14 万条资产没法靠肉眼比对产物)
  • [x] 生成器新增 §4c:「勿动」哨兵断言
  • [x] 重跑 cargo run --example build_dict --release → 两个 JSON 更新
  • [x] rebake_lemma_tables.dart 重烤预装库 lemma_* 三张表(vocabulary 未动,脚本内有守卫)
  • [x] 校验:db 内两张表与 JSON 逐条相等(140,281 / 101,709)
  • [x] 单测 +3(v26_*),cargo test --lib lemmatizer 17 passed
  • [x] scripts/sync-rvh-vocabulary.sh 行数硬断言 140370/101646 → 140281/101709 (不改会让 RVH 同步直接失败
  • [x] 删除 bin/patch_lemmatizer_assets.pydb_generator.dart 的 bake 逻辑提为 可复用 bakeLemmaTables(..., dropExisting:),两个调用方共用一份 DDL
  • [x] 计数更新:docs/database-tables-overview.md · docs/vocabulary-domain-knowledge.md

8. RVH 侧要做的(走 /preinstalled-db-update

  1. 🔴 必须 bump _preinstalledVocabVersion 25 → 26lib/shared/data/database/app_database.dart:211)——不 bump 的话这次修复对存量 RVH 用户完全无效,他们会永远停在 rather→rath

    注意这条与 RB 侧的结论相反,别把 RB 的推理直接搬过来。差异在读取路径:

    • RB:commands/lemmatizer.rsbundled resource 直读 lemma 表 → 新构建自带新数据, VOCABULARY_SEED_VERSION 描述的只是 vocabulary payload(本次未变)→ 不 bump
    • RVH:lemmatizer_flutter_loader.dart 读的是 documents 目录里的拷贝preinstalled_vocab.db。刷新那份拷贝的唯一动作是 _checkAndImportPreinstalledVocabulary 里的 writeAsBytes,而该函数开头 if (currentVersion >= _preinstalledVocabVersion) return;早退跳过拷贝
    • 自愈路径 _ensureLemmaDbReady 救不了:它只探测 lemma_surface_to_base表存不存在,存量安装的旧拷贝表是在的、只是内容陈旧 → 判定「无需重拷」。 即「表在但数据旧」是这套自愈的盲区

    bump 的副作用仅是启动时重跑一次 18,898 行 vocabulary UPSERT(payload 未变,纯耗时)。 顺手在 app_database.dart 的版本注释日志里补一条 v26。

  2. 从 RB 整包接收新 lampio_dict.db:在 RB 仓scripts/sync-rvh-vocabulary.sh (方向 RB→RVH,红线 #10 byte-equal)。RVH 侧不要自己改任何 lemma 数据。 本次 RB 会话已执行:RVH 侧 assets/databases/lampio_dict.db 已更新为 2a38e5c21cd6803d187e92052c368d88c0987aae36d99dbca925100163947af3cmp 逐字节复验通过), 在 RVH 工作树里是一个未提交的改动,等 RVH 会话连同代码改动一起提交。

  3. 更新 ~/reading_vocab_helper/CLAUDE.md 红线 #5e 记录的 SHA256 —— 两个都要改 (见 §3 表格),并把「base_forms.json 未变」那句话删掉,它自 v26 起不成立。

  4. RVH 若有 lemma 表行数断言 / 归一回归 golden,同步更新为 140,281 / 101,709。

  5. 跑 RVH 侧归一回归,重点验证 §5.1/§5.2 的代表词与 §5.3 的 5 条哨兵。 跨端 byte-equal 的验证方式仍是 docs/cross-end/phase0_inputs.txt + rb.csv/rvh.csv 那一组 —— ⚠️ 2026-08-18 RVH 执行时勘误:那两个 .csv 已陈旧四个月 (2026-04-26 产物,早于「词典优先」架构重构,layer 取值仍是旧架构的 suffix/irregular/exception,内容留着 Bug B 过度剥离)。两端一起陈旧, 故 rb.csvrvh.csv 彼此仍逐字节相同 —— 本条按字面执行会通过, 但证明的是「四个月前两端一致」。不是 v26 造成的,也不是 RVH 回归。 详见 26-rvh-phase0-evidence-stale-report.md —— 该问题已于 2026-08-24 按方案 A 处置 (两个 csv 已用两端当前实现重新生成、仍 byte-equal;README 数据资产行补了 layer 词表 作新鲜度指纹)。即本条今天照做是有效的,但仍建议先扫一眼 layer 词表再信它。 本句「178 个改动 surface 与输入词零交集」经 RVH 复核成立(交集为空集); 实际验证改用「两端当场重跑再 diff」,三组输入(lemma-regression 99 / phase0_inputs 131 / 改动的 178 个 surface)全部 byte-identical。

  6. 存量数据清理(§9)——RVH 端同样有错词入库,方案两端一致。

跨端 PK 影响:两端资产 byte-equal,是「两端一致地错」变成「两端一致地对」, 不产生同步分歧。但存量行的 word 值是旧 PK,见下节。


9. 存量数据清理方案(交接单点名的那条)

9.1 影响范围

用户库里 word 字段等于旧错误 lemma 的行。完整清单已生成为数据文件:

docs/cross-end/24-affected-lemmas.tsv(131 个 old_lemma,含 new_lemma 与触发的 surface)

其中 91 个同时是预装词库 headword —— 即带音标、带 CEFR、看起来完全正经, 这正是危害所在。实测样本:

alway | A1 | /ˈɔːl.weɪ/ | {"gloss":"Alternative form of always.",
                            "examples":["And lo I am with you allwaye even untyll the ende off the worlde."],
                            "tags":["alt-of","archaic"]}

涉及表(两端同构):本地 learning_entries / known_words / word_page_links / word_cloze_contexts / word_personalized_examples,以及云端对应的 user_* 表。

9.1b 🔑 裁定(2026-08-18):不做本地迁移,但本地不是问题所在

存量用户全是内部内测用户、会重装 app ⇒ 不做数据迁移,也不落 v34 迁移。 下面 §9.2/§9.3 的两条路线均已作废,保留仅为记录当时的权衡。

⚠️ 但「重装」并不能清掉这批脏数据,原因不在本地

  1. 重装清掉的是本地 SQLite。rath / alway 这些行同时存在于 Supabaseuser_learning_entries / user_known_words / user_word_page_links / user_word_cloze_contexts)。重装后重新登录 → sync pull 把它们原样拉回来。
  2. 而且拉得进来:本轮只重烤 lemma 表、没动 vocabulary,所以 rath 仍是预装库 headword,pull 的父行/FK 存在性检查照样放行。用户看到的生词本与重装前一模一样。
  3. 顺带一提,桌面端「重装」本身也未必清本地:macOS 上换掉 .app 不会删 ~/Library/Application Support/<identifier>/ 下的 lampio.db,要真清得手动删数据目录。

要真正清干净,唯一必要的动作是一次性清理云端(内部账号数量少,成本很低): 对 §9.1 白名单里的 word,在 Supabase 上把对应 user_* 行处理掉。 两种做法二选一(都属生产写操作,需显式确认后执行):

做法说明
软删(推荐)UPDATE user_* SET deleted_at = now(), updated_at = now() WHERE word IN (<白名单>)。墓碑会传播到任何还没重装的设备,把本地那份也一并标删 —— 一次动作两端都干净
硬删DELETE FROM user_* WHERE word IN (<白名单>)。只在确认所有设备都会重装时才等价。否则没重装的那台设备本地还留着行,下次 push 会把它重新上传回云端(本地行没有墓碑,脏检查照样命中)

硬删那一栏正是本项目反复踩的那类坑的镜像:墓碑不是给云端看的,是给对端设备看的。

9.2 两条路线(已作废,见 §9.1b)

迁移(改写成正确 lemma)删除(软删错条目)
用户体验保留 SRS 进度,rath 变成 rather丢一条生词记录
前置条件目标词必须已在本地 vocabulary——learning_entries.wordFK → vocabulary(word) ON DELETE RESTRICT。路径 A 下 rather 在预装库里不存在 → 迁移会被 FK 直接拒绝,除非先 backfill
歧义131 个里有 1 个(latlatest/latter)无法仅凭 word 反推用户读的是哪个;需 word_cloze_contexts.surface 佐证,而该表只在有 cloze 记录时才有
冲突UNIQUE(user_id, word):目标行可能已存在,需 merge SRS 进度

9.3 建议:软删,不迁移(已作废,见 §9.1b —— 改为不落本地迁移,只清云端)

理由是前置条件不成立而非省事——路径 A 下目标词不在本地 vocabulary, 迁移必然撞 FK;且这些条目对应的词条本身是古语/残根,保留学习进度没有意义 (用户从来没想学 rath)。

以下实施要点不再执行(存量用户会重装,不落 v34)。保留是因为其中第 2/3 条 是通用纪律,下次真要写清理迁移时照样适用:

  1. 编号接 migrations.rs 当前最大值 v33v34CREATE TABLE IF NOT EXISTS / 幂等加法式(红线 #11 append-only,不动冻结的 schema.sql)。
  2. 🔒 必须软删(写 deleted_at),不能硬 DELETE(红线 #6/#6a)。 硬删会物理抹掉墓碑 → 该行在云端永生 → 下一轮 pull 直接复活, 清理等于没做。同时要 bump updated_at 让 push 的脏检查命中。
  3. 🔒 父行删除要在同一事务里显式软删整棵子树,不能靠 FK CASCADE (rusqlite 通道从未开 PRAGMA foreign_keys=ON,CASCADE 在 Rust 侧一次都没真正触发过)。 learning_entriesword_page_links / word_cloze_contexts 的清理照 notes.rs::soft_delete_note_tree 的模板写。
  4. 判据用白名单(读 §9.1 的 TSV),不要用「预装库里没有的词就删」—— 那会误杀用户从缓冲池 backfill 的正常生词。
  5. 云端 user_* 表不单独写脚本:本地墓碑经正常 push 传播过去即可(两端各自迁移各自的库)。
  6. 回归测试:造一条 word='rath'learning_entries + 其 word_page_links 子行, 跑迁移后断言两者 deleted_at 均非空且 updated_at > synced_at(脏检查能命中)。

10. 本轮未做的(明确的边界,不是遗漏)

  • §5.4 的 7 条 POS 歧义映射 —— 需要上下文,Layer 1 架构上做不到。真要治得引入 POS 标注层,属独立设计问题。
  • 预装库 vocabulary 表的 91 个死 headword + 缺失的正确词条 —— 见 §6 代价, 留给下次整库 reseed。
  • 频率排名 > 30000 的低频错映射 —— 撒网时按危害排序截断了。同类残根 (fantasyingsoothestbasser 等)仍在表里,但那些 surface 本身 就不是现代英语词,用户读不到。