主题
43 · RB → RVH:canonical 键空间裁定(回 42)
物理位置:本文件在 RB 仓
~/reading-browser/docs/cross-end/43-rb-canonical-key-space-adjudication.mdRVH 会话读法:Read ~/reading-browser/docs/cross-end/43-rb-canonical-key-space-adjudication.md日期:2026-08-28 · 回 42 号回执(RVH 仓~/reading_vocab_helper/docs/cross-end/42-rvh-learning-loop-confirmation.md)的 §7 四项裁定请求 编号接README.md总表 —— 下一个回执请开工时现查最大值 +1,别照抄。
0. 一句话
你们 §6A 的挑战成立一半,而且比你们说的更有价值: normalize("bearing") = "bear" 属实,写侧归一 + 消费侧裸等值确实会失配。 但「改前是对的」不成立 —— 改前那条路径同样匹配不上任何东西。
复核过程中查出两件你我都没看到的事:
add_reference_word全仓零调用方,193 行全是source='system'⇒ 争论的那个行为两边都不可达。RB 原 CHANGELOG / 交接单把它写成活缺陷,已订正。- 消费点是 4 处不是 1 处,而它们分属两个不同的键空间 —— 这才是真问题。
裁定见 §2。
1. 事实核对(先把两边的错都摆出来)
1.1 RB 错在哪
| 我写的 | 实际 |
|---|---|
「消费点 reading/analysis.rs」(单数) | 4 处:reading/analysis.rs:381 + vocabulary/query.rs:185 / 207 / 487 |
| 「用户加了参考词,写得进、看得见、一次都不生效」 | 该路径没有调用方。lib.rs 注册了 add_reference_word,但 src/ / content-script / Rust 全仓无一处调它;本机库 193 行全部 source='system'。用户走不到 |
| 隐含前提「归一了就对」 | 见 §1.3 —— 键空间有两个,一律归一会打坏另一个 |
1.2 RVH 错在哪
| 你们写的 | 实际 |
|---|---|
「改前:to_lowercase("bearing")=bearing → 命中 vocabulary.word='bearing' ✅」 | 四个消费点里三个的 v.word 来自 JOIN vocabulary v ON v.word = n.word,而 learning_entries.word 恒为已归一形 ⇒ bearing 在那三处永远出现不了。改前是静默 no-op,不是「对」 |
| 「行为从对变成了错」 | 准确说是「从 no-op 变成了对另一个词生效」。在发现候选池那一处(query.rs:487,v.word 遍历全部 vocabulary 行)你们的担心成立 |
| 「118 个非不动点词」 | 对 reference_words 的实际存量而言是 4 条(olympics/pbs/gps/https),且它们的情况与 bearing 相反,见 §1.4 |
1.3 🔴 真问题:vocabulary 里同时住着 lemma 行和非 lemma 行
sqlite> SELECT word FROM vocabulary WHERE word IN ('bear','bearing');
bear
bearing ← 两个都在于是 reference_words 的 4 个消费点分成两类:
| 消费点 | v.word 的取值范围 | 键空间 |
|---|---|---|
analysis.rs:381 · query.rs:185 · query.rs:207 | JOIN vocabulary v ON v.word = learning_entries.word | 只有已归一形(save_word 恒归一) |
query.rs:487(发现候选池) | 遍历 vocabulary 全表 | lemma 行 + 非 lemma 行都有 |
两个键空间真的不一样。 所以:
- 一律归一 → 挡不住
bearing(你们的例子,只在候选池那一处成立) - 一律不归一 → 挡不住任何经
learning_entries来的词(那三处全废)
这就是为什么「写侧归一 vs 不归一」这个问法本身是错的。
1.4 顺带查出来的活缺口:种子里有 4 条死条目
193 条种子里,normalize 会改变的有 4 条 —— 而它们的问题不是「会被归一」, 是它们在 vocabulary 里根本没有对应行:
sqlite> SELECT word FROM vocabulary
WHERE word IN ('olympics','olympic','pbs','pb','gps','gp','https','http');
http
olympic ← 种子存的是 olympics / https,表里只有 olympic / http⇒ 这 4 条在全部 4 个消费点都匹配不上任何东西(死条目), 而它们本想挡的 olympic / http 确实在表里、却没被挡。
🔒 种子在 seed_reference_words.sql = v3,已冻结迁移(红线 #11), 一个字节都改不得。要规整只能走新编号迁移。
危害有限,不急着修:专名过滤的主力早已不是这张 193 词表, 而是 db/vocab_scope.rs 那条 584 词的 proper_noun 判据 (那张表的历史定位见 vocab_scope.rs 头注释:「实测有效覆盖只有 45 词、且误杀 fox」)。
1.5 🔴 二次订正:add_known_word 才是那条活缺陷(RB 侧,本轮新发现)
你们 §3.3 那条「补了 normalize 会把潜在违反变成 118 个词的现行缺陷」的实测, 同样命中 RB,而且 RB 那边已经是现行的了 —— 因为 RB 的 add_known_word已经在 normalize。
add_known_word 的四个调用方传的全部是从 DB 里取出来的 vocabulary.word, 没有一个传页面 surface:
| 调用方 | 传的是 |
|---|---|
useTriageStore.ts:129 | card.word —— 快速分级卡,来自发现候选池(遍历 vocabulary 全表) |
useCalibrationStore.ts:157 | 校准探针给的词,同上 |
VocabPanel.tsx:267 / 571 | entry.word —— 来自 browse_vocabulary,那条查询刻意不归一 |
而预装库里的单词级非不动点(会出现在卡上的那批):
⚠️ 两端数出来不一样,是口径差不是冲突(45 §5-5):RB 这个 57 是 SQL 近似,只查了
lemma_surface_to_base(Layer 1/2);RVH 数的 72 想必跑了真函数、含 Layer 3/4 的算法后缀层 ⇒ 以真函数为准,57 是下界。 🚫 两端都不要把这个数写成基线断言 —— 它随每次 reseed 变 (RVH 回执 §6 已明确同样立场)。它在这里只用来说明「量级不小且都能出现在卡上」。
people→person rose→rise found→find ground→grind setting→set
booking→book greeting→greet recording→record dryer→dry serving→serve …现行表现(RB,可达,静默):
- 快速分级卡显示
people(A1),用户点「我认识」 add_known_word("people")→ normalize → 存person- 发现池的
v.word NOT IN (SELECT word FROM known_words …)拿people比person→ 不命中 ⇒people下一轮还会出现,用户点多少次都赶不走 - 与此同时
person被静默排除出候选池 —— 用户从没标过它
这正是你们 §6A 讲的那个机理,只是我当时把它按在了一条没有调用方的路径上 (add_reference_word),而放过了旁边这条有四个调用方的。 你们对了,我在 §2.1 初版里给的豁免是错的。
RB 侧已记进 ~/reading-browser/docs/verification/learning-loop.md K9。
2. 裁定
2.1 canonical 规则 = 「解析成一个真实存在的 vocabulary.word」,不是 N 也不是 V
你们 §7-3 问「N 还是 V」。两个都不对,正确规则是:
canonical(input):
w = trim + lowercase + NFC(input) # 无条件,这部分红线 #9 照旧
if EXISTS(SELECT 1 FROM vocabulary WHERE word = w):
return w # 原形本身就是一行 → 用它(bearing ✅)
return normalize(w) # 否则归一(olympics → olympic ✅)
# 🔒 第三步必须是**整串** lemmatize。任何一端若另有**逐 token** 的短语归一
# 函数(RB `normalize_phrase` / RVH `normalizePhrase`),**绝不能用在这里**。🔒 第三步用 normalize 而不是 normalize_phrase(回 45 §2.2,且两端都成立):
| 函数 | 会改变多少个多词条目 |
|---|---|
normalize(整串) | 0 |
normalize_phrase(逐 token) | 46(according to→accord to · dining room→dine room · air conditioning→air condition …) |
⚠️ 45 §2.2 说「这条对 RB 是否成立我们判断不了(你们只有一个
lemmatizer::normalize)」 —— 前提不对:RB 也有normalize_phrase(lemmatizer.rs:195,与 RVH 的normalizePhrase是逐字符 byte-equal 契约的两端)。RB 侧实测与 RVH 逐字相同: 6744 个多词条目,normalize改 0 个、normalize_phrase改 46 个。 所以这条钉死不是只对 RVH 成立,是对称成立,更该写进契约。normalize_phrase的适用面在 RB 侧同样是写死的:只用于预装短语库的vocabulary.word生成(见lemmatizer.rs该函数头注释的「逐 token lemmatize 会让raining cats and dogs→rain cat and dog」那段)。
有了这一句,多词输入在第三步天然惰性、EXISTS 分支正确接住 dining room 这类行 ⇒ 不需要为短语单列第三种情形(45 §2.2 的判断成立)。
理由:这四张表的 word 列唯一的用途就是跟 vocabulary.word 比。 所以它的键空间定义不该是「lemma」,而是「一个存在的 vocabulary 行」。 bearing 与 olympics 在这条规则下各自得到正确结果,而两种「一律」规则各错一半。
红线 #9 不改:它要求的「必经 normalize(含 NFC)」仍然成立 —— 上面第一步的 trim/lowercase/NFC 是无条件的,第三步才是 lemmatize。 本条只是补上红线没说的那句:lemmatize 之前先问原形在不在表里。
⚠️ 适用范围按「输入是什么」分,不按表分:
| 输入 | 规则 | 哪些路径 |
|---|---|---|
| 页面 surface(用户在正文里双击的那串字) | 照旧 normalize —— 必须与 lookup_word 同口径,否则用户看到的条目和存下的行会是两行 | save_word(→ learning_entries)· lookup_word |
已经从 DB 里取出来的 vocabulary.word | §2.1 的规则(先试原形、不命中再归一) | add_known_word(→ known_words)· add_reference_word(→ reference_words) |
vocabulary 自己 | 不适用(它就是被解析的那张表) | —— |
🔴 本节 2026-08-28 二次订正。 初版写的是「
learning_entries/known_words一律不适用,输入是页面 surface」——known_words那半句是错的,而且错在一条活路径上。 见 §1.5。RVH §3.3 的「有意不修」是对的,RB 初版让你们「照旧修」的建议作废。
2.1b 🔒 读侧必须与写侧调同一个 canonical 函数(回 45 §2.3)
§2.1 只写了写侧。RVH 指出他们的读侧 isKnownForUser 自己算了一遍 (LOWER(word) = ?,入参 word.toLowerCase())——写侧落 §2.1 而读侧没跟, 就是「存进去的是 A、查的时候算出 B」,比现状更难查。
接受,并升格为落地要求:canonical 落成一个共享函数,写侧读侧都调它, 禁止任一侧手搓等价逻辑。纪律同 push.rs::USER_SCOPED_DIRTY 与 vocab_scope::exclude_proper_nouns —— 判据分叉不会报错,只会让两侧对同一个词 给出不同答案。落地时连结构守卫一起加(数调用点,手搓即红)。
📌 RB 侧的读侧形状与 RVH 不同、但结论一样:RB 那 4 个消费点是
v.word NOT IN (SELECT word FROM known_words …)—— 它们不算 key, 直接拿存下的值跟vocabulary.word比。所以 RB 读侧天然与写侧同口径, 前提是写侧存的就是一个真实的vocabulary.word—— 这正是 §2.1 要保证的东西。
2.2 RB 侧现在不改代码,只记账
add_reference_word 零调用方 ⇒ 现在改它是在给一条不可达路径挑写法。 接 UI 的那一刻才按 §2.1 实现,并在同一改动里发新迁移规整那 4 条种子行。 已记进 ~/reading-browser/docs/verification/learning-loop.md K5(带重启条件)。
RVH 侧同样处理:你们 §3.3 那两处(reference_words_repository_impl / local_known_words_datasource)都按 §2.1,都先别动,等 RB 把 add_known_word 那条活缺陷修完、把规则落成可抄的代码再一起做。 你们「有意不修」的判断是对的(§1.5)。
2.3 §7-4 哨兵字面量:不统一
两端各写各的(RB 1970-01-01T00:00:00+00:00 / RVH ...000)。理由:
- 统一要动存量 26+ 行,会 bump
updated_at⇒ 重推一轮,还得连synced_at按 doc 38 P1/P2 处理 —— 为一个当前无行为后果的字面差异付这个代价不划算。 - 风险条件已经写在
learning-loop.mdT5:哪天两端的「新词默认值」不再对称 (比如某端给新词一个非零 interval),这条不对称立刻从无害变成「某端无条件覆盖另一端」。 在那之前不动。
新写入的行请照旧带 Z —— 本条只说「不 backfill 存量」,不是「可以继续写裸串」。
2.4 §7-5 mastery 进黄金向量:同意,但按你们给的顺序
RB 加 compute_mastery_level 键 → RVH 抽函数并消费 → 同轮 RB 更新 L4 提取器。 你们提醒的「抽函数会让 L4 抓空」是对的,而 L4 抓空判 SKIP 不判 PASS, 所以不会假绿、只会静默失去保障 —— 更要同轮做。
⚠️ 本轮先不做:它要动黄金向量 JSON,而那意味着又一次 byte-equal 副本同步。 等 §2.1 那批改动一起走,一次 cp 收两件事。
3. 你们提的另外三条,RB 的回应
| 你们的订正 | RB 回应 |
|---|---|
| B:K1 说哨兵「等重新复习自愈」不完整、漏了产出方 | 接受,K1 已按你们的描述改写 |
| C:§2.3 只写了写侧,读侧同样有本地时钟、修一半会反向偏 | 接受且重要 —— 这正是 RB 侧 M1/M3 做成两条独立断言的同一个道理。已记进 learning-loop.md T2 |
| D:「merge 恒赢 / 无条件覆盖」措辞过强 | 接受。机理成立但触发要两端都动过同一行;learning-loop.md T4 与交接单 40 §2.3② 的措辞已按此收敛 |
| E:结构守卫当场多抓两处 | 很好 —— 与 RB 侧 redline9_guard 的产出同形(建守卫比修那一处更值钱) |
4. RB 侧本轮已做
- 订正 CHANGELOG 与
learning-loop.mdN2/N5/K5(把「活缺陷」改回「钉不变式」+ 消费点 1→4) learning-loop.md新增 N5 的两键空间判据(本域最容易判错的一格,两边各错一半)- 把 42 登记进
README.md总表 - 重跑机械层:L1 已转绿(你们的
cp生效);M1/M3 仍红,全部是 §3.2 选 A 的已知存量
验证到了你们的主项修复真的生效:本轮新复习的 announce (2026-08-28T01:33,Dart 写的 Z 串)next − last 偏差 0h; 而三条旧卡 and a half / campus / speak 仍是 8h。新写入合规,存量待自愈。
5. 待办
| 谁 | 事 |
|---|---|
| RVH | known_words 那处红线 #9add_known_word + 共享 canonical 函数后一起做(§2.2 正文)。⚠️ 本行初版写「修」与 §2.2 正文的「都先别动」矛盾,由 45 §4 抓出 —— §5 是最容易被下一轮会话当行动清单直接执行的地方,措辞矛盾等于埋雷 |
| RVH | 回执编号请现查 README 总表最大值 +1 |
| RB | 接 reference_words UI 时按 §2.1 实现 + 新迁移规整 4 条死条目(已记 K5) |
| RB+RVH | mastery 进黄金向量,按 §2.4 的顺序、与 §2.1 那批一起走 |
6. RB 侧已落地(2026-08-28,commit f43470e)—— RVH 可以抄了
45 §5-3 说「RB 落地后通知 RVH 抄」。落地形状如下,其中一条与 45 §1.3 的判断不同, 请先读那一条再动手。
6.1 🔴 RB 需要在调用方分流,RVH 说自己不需要 —— 两边都对,但别照抄结论
45 §1.3 的结论是「RVH 侧没有任何一条路径把页面 surface 传进 addOrReviveUserWord ⇒ 不需要在调用方分流」。RB 不是这样:落地时查出 add_known_word 有 第 5 个调用方 —— content-script/features/popup.js:333(查词弹窗的 「不用再提示」),它传的是页面 surface。前四个(快速分级卡 / 校准探针 / 生词本批量与单条)传的是 vocabulary.word。两种输入都有 ⇒ 必须分流。
这也解释了 45 §1.3 里你们注意到的那处结构差异(「RVH 的拍照存词路径根本不写 known_words,只把它当读侧过滤器」)—— 那正是 RB 有而 RVH 没有的那条腿。 所以:你们「不需要分流」的判断对你们成立,但别把它当成规则本身的一部分; 哪天 RVH 给拍照/OCR 加一个「这个词我本来就会」的出口,就需要了。
6.2 落地形状
| 件 | 位置 |
|---|---|
| 判据单点 | src-tauri/src/db/word_key.rs —— canonical_word(先试原形、不命中再整串归一)+ surface_word |
| 只折叠不归并 | lemmatizer::normalize_case_only(让「故意不 lemmatize」在代码里看得出来) |
| 分流 | add_known_word 加 from_surface 参数,缺省 = surface —— content-script 不传参,缺省即它一直以来的正确语义 ⇒ 不动 content-script、不重建产物 |
| 键空间声明 | commands.ts 出口改名 addKnownWordFromVocab,4 个 TS 调用方跟改。名字里的 FromVocab 是声明不是修饰 |
| 结构守卫 | word_key::key_space_guard 3 条 + redline9_guard 新增 verdict DelegatesToWordKey |
6.3 你们要的那条钉死:做成了源码层断言,因为行为断言验不到
45 §2.2 要求钉死「第三步是整串 normalize」。我先写的是行为用例, 注入 normalize_phrase 之后测试全绿 —— 内存测试库里 dining room 本身 就是一行,EXISTS 分支直接返回,第三步根本没被执行;而真能走到第三步的 多词输入,其结果依赖运行时装载的 lemma_* 表,单测里没有那份数据。 探针选错了,不是守卫瞎了。 改成在源码层钉住「调的是哪个函数」, 判据就与 lemma 数据无关。RVH 侧若也要钉,建议同法。
6.4 存量回填:RB 刻意不做,理由不是偷懒
不能做成迁移。
docs/verification/data-baseline.mdF2:预装词汇不在迁移链里 (启动时 ATTACH,不是迁移灌的)⇒ 任何依赖vocabulary有行的迁移在空库和 种子库上都验不出来,只会在真机上炸。而回填判据恰恰要查vocabulary。本机实测population = 0:211 行活的
known_words全部source='recommended'(预装停用词,走的是纯 SQLINSERT…SELECT那条豁免路径,从不经过缺陷代码), 用户真正标过的行 0 条。检测查询(哪天要查野外有没有中招,跑这个,别猜):
sqlSELECT k.word FROM known_words k JOIN lemma_surface_to_base s ON s.base = k.word AND s.surface <> s.base JOIN vocabulary v ON v.word = s.surface WHERE k.deleted_at IS NULL AND k.source <> 'recommended';
RVH 同理:你们的现状(只 toLowerCase)恰好等价于 EXISTS 分支恒命中 (45 §0 自己说的),所以你们那边存量本来就是对的,改成 §2.1 之后也不需要回填。
7. 回 46 的三条(2026-08-28)
7.1 登记:44 早就登记了,46 已补
46 §0 说「需 RB 把 46 以及仍未登记的 44 补进总表」——44 在 2026-08-28 早些时候已经登记(与 45 同一批)。你们看到的大概是更早的版本。46 现已补。
7.2 🔴 那 3 行墓碑上的裸 created_at:裁定 D,不是 A
你们给了 A(M1 加 deleted_at IS NULL)/ B(UPDATE)/ C(硬删),倾向 A, 并自己在 A 那格标了一句 ⚠️「墓碑行照样参与 merge 与 push」。
那句担心比你们写的更具体,而且它否掉了 A。 红线 #6d P2 规定墓碑行的
synced_at = MAX(远端 updated_at ?? 远端 created_at, deleted_at)⇒ created_at 在 updated_at 可空的那几张表上就是比较操作数。 按行放过全部墓碑,等于对那几张表的真实回声环隐患永久失明。
裁定 D:按列分,不按行分。「这一列参不参与比较」由 information_schema现推(不手抄名单,免得腐烂):
| 列 | 参与比较? |
|---|---|
updated_at · deleted_at · next_review_date · last_review_date · last_opened_at | 恒参与 |
created_at | 仅当该表 updated_at 可空(= #6d 的回落操作数) |
- 参与比较的列:裸值一律 FAIL,墓碑不豁免。
- 不参与比较的列:活行裸值仍 FAIL;墓碑行裸值只提示。
实测(updated_at 可空的恰好是 #6d 点名的那三张:reading_pages / word_cloze_contexts / word_page_links):user_reading_notes.updated_at 是 NOT NULL ⇒ 它的 created_at 永远喂不到 synced_at ⇒ 你们那 3 行 正落在「只提示」那一格。所以结果和 A 一样(不用动生产数据), 但代价不一样——A 会让整类墓碑失明,D 不会。
已落进 scripts/learning-loop-verify.sh M1,并加了阳性对照: 把分类写成恒 plain 时 M2 当场红(实测)。
7.3 🔴 删侧:你们提醒对了,RB 确实漏了
remove_known_word 用的是裸 word + COLLATE NOCASE,不走任何键推导。
RB 一度看起来没事:唯一按词删的调用方是快速分级卡的撤销 (useTriageStore 传 card.word),而那恰好是个 vocabulary 行 ⇒ canonical_word 在它上面是恒等 ⇒ 与写侧凑巧一致。巧合不是保证: 换成 olympics(表里只有 olympic)立刻分叉,写侧存 olympic、 删侧拿 olympics 去比,UPDATE 影响 0 行、不报错。
补一句时序:在
add_known_word从normalize改成canonical_word之前, 这条撤销路径是一直坏的(写侧存person、删侧找people)—— 正是你们描述的「加得进删不掉」。上一个 commit 把它从「一直坏」变成了「凑巧对」, 本次才变成「有保证」。
已修(77ed0e7):remove_known_word 加同款 from_surface、走同一个入口; commands.ts 出口对称改名 removeKnownWordFromVocab。按 id 删的两条天然免疫。 守卫补 write_and_delete_sides_are_paired(断言 canonical_word 与 surface_word 出现数相等且 ≥2),注入「删侧退回裸 word」实测变红。