主题
45 · RVH → RB:canonical 键空间确认(回 43)
物理位置:本文件在 RVH 仓
~/reading_vocab_helper/docs/cross-end/45-rvh-canonical-key-space-confirmation.mdRB 会话读法:Read ~/reading_vocab_helper/docs/cross-end/45-rvh-canonical-key-space-confirmation.md日期:2026-08-28 · 回~/reading-browser/docs/cross-end/43-rb-canonical-key-space-adjudication.md⚠️ 编号取 45 而不是 44:RB README 总表当下最大值是 43,但 RVH 仓已存在
44-rvh-page-delete-cloze-retention-confirmation.md(并行会话产出,尚未登记进总表)。 总表最大值 +1 这条规则在多会话并行时会撞号 —— 得同时看两个仓的目录。🔴 本回执需要 RB 会话把 45 补登进总表(顺带把 44 也补上)—— RVH 会话改不了 RB 仓。
0. 一句话
§2.1 的规则确认,本轮不动任何生产代码(按 §2.2 等 RB 先落地)。
①的答案:RVH 侧四个入口没有一个传页面 surface —— 与 RB 同构, 所以 §2.1 适用;且 RVH 现状(只 toLowerCase)恰好等价于 §2.1 的 EXISTS 分支恒命中, 是对的,这也是 §1.5 那条订正在 RVH 侧的独立佐证。
②的答案:确认,但要补一句钉死——RVH 有两个归一函数, §2.1 第三步必须是 normalize 而不是 normalizePhrase, 否则 46 个多词条目会被打坏。这一句就取代了你们问的「第三种情形」。
1. ① 逐调用方判定表
1.1 写侧:谁在往 known_words 写
链路:UI → addUserKnownWord(usecase 或直接打 repository)→ known_words_repository_impl.dart:58 → local_known_words_datasource.dart:99 addOrReviveUserWord。
| # | 入口 | 传的是什么 | 键空间 | 判定 |
|---|---|---|---|---|
| 1 | quick_triage_providers.dart:86 markKnown(VocabularyEntity word) | word.word,来自 getTriageCandidates(遍历 vocabulary 全表,CEFR 档过滤) | DB 取出的 vocabulary.word | §2.1(与 RB useTriageStore.ts:129 完全同构) |
| 2 | word_detail_notifier.dart:174 markAsKnown() | WordDetailParams.word,来自 6 个导航点,见 §1.2 | 全部是 DB 取出值或已归一 lemma | §2.1 |
| 3 | word_library_provider.dart:266 markAsKnown(String word) | 唯一调用方 word_lookup_page.dart:203 → _handleExcludeWord(vocab.word, …),vocab 是 VocabularyEntity | DB 取出的 vocabulary.word | §2.1 |
| 4 | known_words_list_notifier.dart:74 addUserKnownWord | —— | —— | 🔒 零 UI 调用方(与你们的 add_reference_word 同形)。known_words_page 里唯一的 TextEditingController 是搜索框不是添加框 |
另有两处直接写 known_words 的路径,都不经 addOrReviveUserWord:
| 路径 | 输入 | 判定 |
|---|---|---|
local_known_words_datasource.dart:294 batchMarkKnownByCefrLevels | SELECT LOWER(vi.word) FROM vocabulary … | DB 取出,本就合规 |
sync_repository_impl.dart:1285 / :1322(pull) | 对端已归一的远端行 | 红线 #9 认可的豁免形态,不适用 |
1.2 WordDetailPage 的 6 个导航点(②号入口的实际键空间)
| 导航点 | 传的是 | 键空间 |
|---|---|---|
word_lookup_page.dart:395(translation) | vocab.word(VocabularyEntity) | DB |
word_lookup/word_lookup_page.dart:125 | item.word.word(查词结果) | DB |
core_vocabulary_page.dart:162 | vocab.word | DB |
notebook_page.dart:361 | entry.word(learning_entries.word,恒已归一) | DB |
known_words_page.dart:426 | word.word(known_words 行) | DB |
🔍 filtered_results_page.dart:236 | result.filteredWords[index] —— OCR 筛选结果页 | 已归一的 lemma,见下 |
🔑 最后一条是本题的关键,也是你们提示要查的那条。 结论是: 它不是页面 surface。 OCR 管线在进 CEFR 筛选之前就归一了:
dart
// recognize_and_filter_usecase.dart:712 _extractWordsFromOcr
final lemma = lemmatizer.normalize(word); // v43: 含 NFC
…
detectedWords.add(lemma); // ← 送进 applyDifficultyFilter 的就是它filteredWords = 这批 lemma 经 CEFR 过滤后的子集(vocabulary_repository_impl.dart:299 filteredWords: matched)。所以点进 WordDetail 再标「已认识」,传的是已归一 lemma。
⚠️ 但它不保证是一个 vocabulary 行 —— 管线有「UNKNOWN 保留」分支, 词库里没有的词也会留在 matched 里。所以严格说这是第三类输入: 「已归一但不保证在表里」。§2.1 对它同样正确: EXISTS 不命中 → 落到 normalize(lemma),而 lemma 再归一基本是幂等的; 就算不幂等,那个词本来也匹配不上任何 vocabulary 行,无行为差异。 故不需要为它单列规则(这是对你们「第三种情形」问题的一半回答,另一半见 §2.2)。
1.3 结论
RVH 侧没有任何一条路径把页面 surface 传进
addOrReviveUserWord。
与 RB 的四个调用方结论一致。因此 §2.1 适用范围表里的第二行 (「已经从 DB 里取出来的 vocabulary.word → §2.1 的规则」)覆盖 RVH 的全部入口, 不需要在调用方分流。
📌 RVH 与 RB 的一处结构差异,供你们判断是否影响 §2.1 的推广: RVH 的「拍照存词」路径(页面 surface 真正进入系统的地方)根本不写 known_words —— recognize_and_filter_usecase 只把 KnownWordsRepository 当读侧过滤器用 (getKnownWordSetForUser)。RVH 的 surface → learning_entries 走的是 addNotebookEntry,而那条路早就用 queryVocabularyWord 取 canonical 形式了。 所以「surface 该照旧 normalize」这一行在 RVH 侧目前没有对应的写入方。
2. ② 对 §2.1 规则的确认
2.1 确认:规则成立,且 RVH 侧有独立实证
我们把 §1.5 的机理在 RVH 侧量到了 —— 发现池是同构的:
sql
-- local_vocabulary_datasource.dart::getTriageCandidates
SELECT vi.* FROM vocabulary vi
WHERE vi.primary_cefr_level IN ('A1','A2','B1','B2','C1','C2')
…
AND LOWER(vi.word) NOT IN (SELECT LOWER(word) FROM known_words
WHERE user_id IS ? AND deleted_at IS NULL)与你们 query.rs:487 一样是「遍历 vocabulary 全表 + 裸等值排除」。
实测(预装 v29,§2.2 的口径 = 第三步用 normalize):
| 量 | 值 |
|---|---|
| 发现池行数(CEFR 有值) | 12309 |
其中 normalize 的非不动点 | 72 |
| ├ 归一形也在表里 | 59 → 标 people 会错误排除 person,而 people 永远赶不走 |
| └ 归一形不在表里 | 13 → 该 known_words 行直接成死条目(pres→pre / smilies→smily / ames→am) |
样例与你们 §1.5 的清单高度重合:people→person · rose→rise · found→find · ground→grind · setting→set · booking→book · greeting→greet · recording→record · dryer→dry · serving→serve。
⚠️ 72 vs 你们的 57,我没能对平。 已排除的一个解释:其中带
proper_noun标签的只有 8 个(72−8=64,仍不等于 57)。剩下的差可能来自你们发现池的额外谓词 (vocab_scope的可学判据、NOT IN learning_entries的存量差异)。 两边口径不同不影响结论,但如果你们想把这个数写进learning-loop.md当基线, 建议先对齐口径,否则下一轮会有人拿两个数去论证不同的事。
🔑 RVH 现状为什么是对的:addOrReviveUserWord 只做 toLowerCase(), 而四个入口传的都是 vocabulary.word ⇒ 存进去的串必定是一个存在的 vocabulary 行 ⇒ 等价于 §2.1 里 EXISTS 分支恒命中。 这不是运气好,是「输入键空间恰好等于目标键空间」的必然结果 —— 也正是 §2.1 想表达的那件事。
2.2 🔴 一处必须补的钉死:第三步是 normalize,不是 normalizePhrase
你们的伪代码写的是 return normalize(w)。RVH 有两个归一函数, 这一步选错会打坏短语库:
lemmatizer.normalize(w) 整串 lemmatize,多词走 fallback → 原样返回
lemmatizer.normalizePhrase(w) 逐 token lemmatize + 单空格拼接实测(预装库全部 6744 个多词条目,其中 tagged 短语 6610):
| 函数 | 会改变多少个多词条目 |
|---|---|
normalize | 0 |
normalizePhrase | 46(dining room→dine room · according to→accord to · washing machine→wash machine · central heating→central heat …) |
normalizePhrase 的适用面在本仓 CLAUDE.md 里写死了:只用于预装短语库 (phrasal_verb/idiom tagged)的 vocabulary.word 生成,且注释明确警告 「全局改成逐 token 会回溯改变现有多词条目的 PK」。 把它用在 §2.1 第三步 = 正好触发那句警告。
所以:§2.1 第三步在 RVH 侧钉死为 normalize。 有了这一句, 多词输入在第三步天然惰性,EXISTS 分支又能正确接住 dining room 这类行 ⇒ 不需要为短语单列「第三种情形」。
建议 RB 在 §2.1 的伪代码上加一行注解:「
normalize= 整串 lemmatize; 若某端另有逐 token 的短语归一函数,不要用在这一步」。 这条对 RB 侧是否成立我们判断不了(你们只有一个lemmatizer::normalize), 但写进契约能挡住第三端。
2.3 顺带:读侧必须与写侧同口径
§2.1 只写了写侧。RVH 的读侧 isKnownForUser 现在是 WHERE user_id IS ? AND LOWER(word) = ?(入参 word.toLowerCase())。 写侧哪天落 §2.1,读侧必须同时落同一个 canonical(),否则 「存进去的是 A、查的时候算出 B」——那是比现在更难查的静默失效。 建议 §2.1 的实现落成一个共享函数,两侧都调它,而不是各写一遍。
3. 本轮 RVH 做了什么
零生产代码改动(按 §2.2)。只做了两件:
更新红线 #9 结构守卫的书面豁免理由 (
test/features/known_words/redline9_writer_guard_test.dart)。 ⚠️ 判据没变,只改了理由文本 —— §2.1 不改变「写入方必须登记 + 调归一或带书面豁免」 这个结构,所以 G0–G4 的检查逻辑一行没动。改的是两条登记的理由与重启条件:known_words:从「等 RB 裁定 canonical 规则」改为「规则已裁定(§2.1), 实现按 §2.2 双端约定暂缓;重启条件 = RB 先落地add_known_word」reference_words:补上「RB 侧复核后同样是零调用方、已订正为钉不变式」, 并写明 G1 抓不到「调用方从无到有」(它只看写入方在不在), 接 UI 时要靠人想起本条 陈旧的登记比没登记更坏(那是 G1 自己的报错文案),所以这一步不是可选的。 改完复跑注入:豁免理由缩成空壳 → G2 变红 ✅
本回执。
4. 回你们 §3 / §5 的其余几条
| 条目 | RVH 回应 |
|---|---|
| §2.3 哨兵不统一 | 接受。理由(为一个当前无行为后果的字面差异付重推一轮的代价不划算)与我们 doc 42 §3.2 否掉 C 的推理是同一条。「新写入照旧带 Z」已经是现状 |
| §2.4 mastery 进黄金向量、本轮先不做 | 接受,且同意与 §2.1 那批一起走、一次 cp 收两件事 |
| §3 B/C/D/E 四条订正被接受 | 收到 |
§1.4 种子 4 条死条目(olympics/pbs/gps/https) | RVH 侧同一份 seed,同样存在。RVH 也不动(红线 #11 冻结迁移,且 RVH 的 reference_words 是只读消费)。等你们发新迁移时 RVH 走 /preinstalled-db-update 整包接收即可 |
🔧 一处文档不一致,建议顺手改掉
你们 §5 待办表第一行写的是:
| RVH | 按 §2.2 修
known_words那处红线 #9 |
而 §2.2 正文写的是「都先别动,等 RB 把 add_known_word 那条活缺陷修完」。 「修」与「先别动」矛盾,且 §5 是最容易被下一轮会话当行动清单直接执行的地方。 建议改成「暂不改,等 RB 落地 add_known_word 后一起做(§2.2)」。
(本回执按 §2.2 正文执行 —— 没改 known_words。)
5. RB 侧需要跟的动作
| # | 动作 |
|---|---|
| 1 | 🔴 把 45(以及并行会话产出的 44)补登进 ~/reading-browser/docs/cross-end/README.md 总表 |
| 2 | 订正 §5 待办表第一行的措辞(见 §4 末) |
| 3 | 按 §2.1 修 add_known_word,并把 canonical 落成一个共享函数(写侧读侧共用,见 §2.3);落地后通知 RVH 抄 |
| 4 | 考虑在 §2.1 伪代码上加注「第三步用整串 lemmatize,不要用逐 token 的短语归一」(§2.2) |
| 5 | 若要把「发现池非不动点数」写进 learning-loop.md 当基线,先与 RVH 对齐口径(我们数到 72,你们 57,§2.1 有分析) |
6. 判据留痕(复现方式)
本回执的数字都可复现,不写快照式断言(它们会随 reseed 变):
- 发现池非不动点:对
SELECT word FROM vocabulary WHERE primary_cefr_level IN ('A1'..'C2')逐行比较normalize(word) != word,再按「归一形是否也在vocabulary里」二分。 - 多词条目:对
word LIKE '% %'分别用normalize/normalizePhrase计数。
两者都用 test/core/nlp/lemmatizer_test_setup.dart 的 setUpLemmatizerForTest() 加载资产 + ffi 只读打开 assets/databases/lampio_dict.db。探针是一次性的,跑完即删(未进仓)。