Skip to content

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:58local_known_words_datasource.dart:99 addOrReviveUserWord

#入口传的是什么键空间判定
1quick_triage_providers.dart:86 markKnown(VocabularyEntity word)word.word,来自 getTriageCandidates遍历 vocabulary 全表,CEFR 档过滤)DB 取出的 vocabulary.word§2.1(与 RB useTriageStore.ts:129 完全同构)
2word_detail_notifier.dart:174 markAsKnown()WordDetailParams.word,来自 6 个导航点,见 §1.2全部是 DB 取出值或已归一 lemma§2.1
3word_library_provider.dart:266 markAsKnown(String word)唯一调用方 word_lookup_page.dart:203_handleExcludeWord(vocab.word, …)vocabVocabularyEntityDB 取出的 vocabulary.word§2.1
4known_words_list_notifier.dart:74 addUserKnownWord————🔒 零 UI 调用方(与你们的 add_reference_word 同形)。known_words_page 里唯一的 TextEditingController搜索框不是添加框

另有两处直接写 known_words 的路径,都不经 addOrReviveUserWord

路径输入判定
local_known_words_datasource.dart:294 batchMarkKnownByCefrLevelsSELECT 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.wordVocabularyEntityDB
word_lookup/word_lookup_page.dart:125item.word.word(查词结果)DB
core_vocabulary_page.dart:162vocab.wordDB
notebook_page.dart:361entry.wordlearning_entries.word,恒已归一)DB
known_words_page.dart:426word.wordknown_words 行)DB
🔍 filtered_results_page.dart:236result.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):

函数会改变多少个多词条目
normalize0
normalizePhrase46dining 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)。只做了两件:

  1. 更新红线 #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 变红
  2. 本回执。


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/httpsRVH 侧同一份 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.dartsetUpLemmatizerForTest() 加载资产 + ffi 只读打开 assets/databases/lampio_dict.db。探针是一次性的,跑完即删(未进仓)。