主题
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 把一批现代高频词当成古语/残根的屈折形(rather→rath、always→alway、 based→bas),修正落在 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.4corrections/ §2.5force_as_base——实测重跑生成器可字节复现仓库里的资产(两个 SHA 一致), 说明生成器才是真相源。 - 那个 Python 脚本的
REPO路径停在迁仓前(指向不存在的assets/nlp/,实际在src-tauri/assets/nlp/),且它断言「原串恰好出现 1 次」——v18 已应用后必然失败。 即它今天跑任何一次都是直接报错退出。 - 已于本次删除(
git rm)。修正层单一真相源 =build_dict.rs。
3. 资产变更
| 资产 | 旧 SHA256 | 新 SHA256 | 条数 |
|---|---|---|---|
surface_to_base.json | ca75b17972fc239d82affdb13634477d22f0bbc461527d3d2e4c05e731956147 | 98a24cf47ac457a05f1e172211bdd14ed59291732ada3fbfc5fcaf4e52e31d97 | 140,370 → 140,281(−89) |
base_forms.json | 3094c829fee1bcaf430917a9f77377d146cc93e8f6c591cd622b45381f7deadc | a4ef3b1047c7252556721525da97c378d57b1c4686652567b3e37ca02aabcf59 | 101,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→person、women→woman、details→detail 全部命中频率反转但都是对的 (复数在网页语料里就是比单数常见)。判据定为:
修 = base 在现代英语里不是该 surface 的词形(截断残根 / 古语 / 另一个词) 不修 = base 是同一词的规范词典形(
person/woman/criterion/medium/datum) 搁置 = 两读都成立、需 POS 上下文才能判(Layer 1 是无上下文查表,架构上做不到)
第三层 · 完整性清扫(这一层最关键) 按 base 反查该残根下的全部 surface。首轮只修「频率网命中的那几个」时, 实测漏下 64 条同源映射——修了 changed→change 却留着 changing→chang、 citing→cit、leased→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 本身就是现代词条,强制自映射
- 功能词/高频副词:
ratheralwayssometimeslatterduringyeshisusinnerouterourselvesoverseas - 复数已词汇化:
clothespantssunglassesupstairsdownstairsoutskirtscrossroadsmeaslesannalscongratulationsgrassrootsconfettiinsigniasweepstakes - 派生词而 base 为非词:
upcoming(upcome)outstanding(outstand)unbiasedoutdatedunpaidunqualifiedundersignedunwillinghousekeepingcheerleadinghandwritingoutlyingoutgoingpendinghandheldimpending - 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)massachusettsnetherlandsphilippinesemirates等 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→rout | rout(溃败)与 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.dart 读 output/applied_vocabulary.jsonl,而 output/ 目录不存在 (gitignored 中间产物,早已丢失)。Step 4 的 LLM 翻译 resume 缓存 (output/translated_vocabulary.jsonl)同样没了 —— 整库重跑 = 为 ~12k 词重新付费翻译 (config 注释估 $5-15)+ 数小时 + 12,291 条全部可能变动(等于一次完整 reseed)。 资产修正不需要重算 vocabulary,为它付这个代价不成比例。
这个选择的已知代价(不是 bug,是知情取舍)
- 预装库
vocabulary表里由旧归一结果产生的 headword 仍在:alway(A1)rath(A2)lat(A2)ye(A1)sometime(A1)opus(A1)vega(C1) …… 共 91 个。修正后不再有 任何 surface 归一到它们 → 成为不可达死行(查不到、也不会再进任何人的生词本)。 - 反过来,
always/rather/during等正确词形在预装库里没有词条 → 用户查这些词会走 Supabase 缓冲池兜底(联网、首查稍慢),而不是本地命中。 其中us/his/yes/as本来就在 pipeline 停用词表里,本就不该有词条。 - 两者都在下次整库 reseed 时自然收敛(届时 wordlist 用新资产归一,
alway死行消失、always拿到正经词条)。已记进 backlog。
VOCABULARY_SEED_VERSION(db/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 lemmatizer17 passed - [x]
scripts/sync-rvh-vocabulary.sh行数硬断言 140370/101646 → 140281/101709 (不改会让 RVH 同步直接失败) - [x] 删除
bin/patch_lemmatizer_assets.py;db_generator.dart的 bake 逻辑提为 可复用bakeLemmaTables(..., dropExisting:),两个调用方共用一份 DDL - [x] 计数更新:
docs/database-tables-overview.md·docs/vocabulary-domain-knowledge.md
8. RVH 侧要做的(走 /preinstalled-db-update)
🔴 必须 bump
_preinstalledVocabVersion25 → 26 (lib/shared/data/database/app_database.dart:211)——不 bump 的话这次修复对存量 RVH 用户完全无效,他们会永远停在rather→rath。注意这条与 RB 侧的结论相反,别把 RB 的推理直接搬过来。差异在读取路径:
- RB:
commands/lemmatizer.rs从 bundled 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。- RB:
从 RB 整包接收新
lampio_dict.db:在 RB 仓跑scripts/sync-rvh-vocabulary.sh(方向 RB→RVH,红线 #10 byte-equal)。RVH 侧不要自己改任何 lemma 数据。 本次 RB 会话已执行:RVH 侧assets/databases/lampio_dict.db已更新为2a38e5c21cd6803d187e92052c368d88c0987aae36d99dbca925100163947af3(cmp逐字节复验通过), 在 RVH 工作树里是一个未提交的改动,等 RVH 会话连同代码改动一起提交。更新
~/reading_vocab_helper/CLAUDE.md红线 #5e 记录的 SHA256 —— 两个都要改 (见 §3 表格),并把「base_forms.json未变」那句话删掉,它自 v26 起不成立。RVH 若有 lemma 表行数断言 / 归一回归 golden,同步更新为 140,281 / 101,709。
跑 RVH 侧归一回归,重点验证 §5.1/§5.2 的代表词与 §5.3 的 5 条哨兵。
跨端 byte-equal 的验证方式仍是—— ⚠️ 2026-08-18 RVH 执行时勘误:那两个docs/cross-end/phase0_inputs.txt+rb.csv/rvh.csv那一组.csv已陈旧四个月 (2026-04-26 产物,早于「词典优先」架构重构,layer 取值仍是旧架构的suffix/irregular/exception,内容留着 Bug B 过度剥离)。两端一起陈旧, 故rb.csv与rvh.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。存量数据清理(§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 的两条路线均已作废,保留仅为记录当时的权衡。
⚠️ 但「重装」并不能清掉这批脏数据,原因不在本地:
- 重装清掉的是本地 SQLite。
rath/alway这些行同时存在于 Supabase (user_learning_entries/user_known_words/user_word_page_links/user_word_cloze_contexts)。重装后重新登录 → sync pull 把它们原样拉回来。 - 而且拉得进来:本轮只重烤 lemma 表、没动
vocabulary,所以rath仍是预装库 headword,pull 的父行/FK 存在性检查照样放行。用户看到的生词本与重装前一模一样。 - 顺带一提,桌面端「重装」本身也未必清本地: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.word 有 FK → vocabulary(word) ON DELETE RESTRICT。路径 A 下 rather 在预装库里不存在 → 迁移会被 FK 直接拒绝,除非先 backfill | 无 |
| 歧义 | 131 个里有 1 个(lat ← latest/latter)无法仅凭 word 反推用户读的是哪个;需 word_cloze_contexts.surface 佐证,而该表只在有 cloze 记录时才有 | 无 |
| 冲突 | UNIQUE(user_id, word):目标行可能已存在,需 merge SRS 进度 | 无 |
9.3 建议:软删,不迁移(已作废,见 §9.1b —— 改为不落本地迁移,只清云端)
理由是前置条件不成立而非省事——路径 A 下目标词不在本地 vocabulary, 迁移必然撞 FK;且这些条目对应的词条本身是古语/残根,保留学习进度没有意义 (用户从来没想学 rath)。
以下实施要点不再执行(存量用户会重装,不落 v34)。保留是因为其中第 2/3 条 是通用纪律,下次真要写清理迁移时照样适用:
- 编号接
migrations.rs当前最大值v33→ v34;CREATE TABLE IF NOT EXISTS/ 幂等加法式(红线 #11 append-only,不动冻结的schema.sql)。 - 🔒 必须软删(写
deleted_at),不能硬 DELETE(红线 #6/#6a)。 硬删会物理抹掉墓碑 → 该行在云端永生 → 下一轮 pull 直接复活, 清理等于没做。同时要 bumpupdated_at让 push 的脏检查命中。 - 🔒 父行删除要在同一事务里显式软删整棵子树,不能靠 FK CASCADE (rusqlite 通道从未开
PRAGMA foreign_keys=ON,CASCADE 在 Rust 侧一次都没真正触发过)。learning_entries→word_page_links/word_cloze_contexts的清理照notes.rs::soft_delete_note_tree的模板写。 - 判据用白名单(读 §9.1 的 TSV),不要用「预装库里没有的词就删」—— 那会误杀用户从缓冲池 backfill 的正常生词。
- 云端
user_*表不单独写脚本:本地墓碑经正常 push 传播过去即可(两端各自迁移各自的库)。 - 回归测试:造一条
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 的低频错映射 —— 撒网时按危害排序截断了。同类残根 (
fantasying、soothest、basser等)仍在表里,但那些 surface 本身 就不是现代英语词,用户读不到。