Skip to content

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" 属实,写侧归一 + 消费侧裸等值确实会失配。 但「改前是对的」不成立 —— 改前那条路径同样匹配不上任何东西。

复核过程中查出两件你我都没看到的事

  1. add_reference_word 全仓零调用方,193 行全是 source='system' ⇒ 争论的那个行为两边都不可达。RB 原 CHANGELOG / 交接单把它写成活缺陷,已订正
  2. 消费点是 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:487v.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:207JOIN 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:129card.word —— 快速分级卡,来自发现候选池(遍历 vocabulary 全表)
useCalibrationStore.ts:157校准探针给的词,同上
VocabPanel.tsx:267 / 571entry.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,可达,静默):

  1. 快速分级卡显示 people(A1),用户点「我认识」
  2. add_known_word("people") → normalize → 存 person
  3. 发现池的 v.word NOT IN (SELECT word FROM known_words …)peopleperson不命中people 下一轮还会出现,用户点多少次都赶不走
  4. 与此同时 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)46according to→accord to · dining room→dine room · air conditioning→air condition …)

⚠️ 45 §2.2 说「这条对 RB 是否成立我们判断不了(你们只有一个 lemmatizer::normalize)」 —— 前提不对:RB 也有 normalize_phraselemmatizer.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 dogsrain cat and dog」那段)。

有了这一句,多词输入在第三步天然惰性、EXISTS 分支正确接住 dining room 这类行 ⇒ 不需要为短语单列第三种情形(45 §2.2 的判断成立)。

理由:这四张表的 word唯一的用途就是跟 vocabulary.word 比。 所以它的键空间定义不该是「lemma」,而是「一个存在的 vocabulary 行」。 bearingolympics 在这条规则下各自得到正确结果,而两种「一律」规则各错一半。

红线 #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_DIRTYvocab_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.md T5:哪天两端的「新词默认值」不再对称 (比如某端给新词一个非零 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.md N2/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按 §2.2 修 known_words 那处红线 #9暂不改,等 RB 落地 add_known_word + 共享 canonical 函数后一起做(§2.2 正文)。⚠️ 本行初版写「修」与 §2.2 正文的「都先别动」矛盾,由 45 §4 抓出 —— §5 是最容易被下一轮会话当行动清单直接执行的地方,措辞矛盾等于埋雷
RVH回执编号请现查 README 总表最大值 +1
RBreference_words UI 时按 §2.1 实现 + 新迁移规整 4 条死条目(已记 K5)
RB+RVHmastery 进黄金向量,按 §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_wordfrom_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.md F2:预装词汇不在迁移链里 (启动时 ATTACH,不是迁移灌的)⇒ 任何依赖 vocabulary 有行的迁移在空库和 种子库上都验不出来,只会在真机上炸。而回填判据恰恰要查 vocabulary

  • 本机实测population = 0:211 行活的 known_words 全部 source='recommended' (预装停用词,走的是纯 SQL INSERT…SELECT 那条豁免路径,从不经过缺陷代码), 用户真正标过的行 0 条

  • 检测查询(哪天要查野外有没有中招,跑这个,别猜):

    sql
    SELECT 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_atupdated_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_atNOT 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 一度看起来没事:唯一按词删的调用方是快速分级卡的撤销 (useTriageStorecard.word),而那恰好是个 vocabulary 行 ⇒ canonical_word 在它上面是恒等 ⇒ 与写侧凑巧一致。巧合不是保证: 换成 olympics(表里只有 olympic)立刻分叉,写侧存 olympic、 删侧拿 olympics 去比,UPDATE 影响 0 行、不报错

补一句时序:在 add_known_wordnormalize 改成 canonical_word 之前, 这条撤销路径是一直坏的(写侧存 person、删侧找 people)—— 正是你们描述的「加得进删不掉」。上一个 commit 把它从「一直坏」变成了「凑巧对」, 本次才变成「有保证」。

已修(77ed0e7):remove_known_word 加同款 from_surface、走同一个入口; commands.ts 出口对称改名 removeKnownWordFromVocab。按 id 删的两条天然免疫。 守卫补 write_and_delete_sides_are_paired(断言 canonical_wordsurface_word 出现数相等且 ≥2),注入「删侧退回裸 word」实测变红。