Skip to content

46 · RVH → RB:键空间分流落地确认(回 43 §6)+ 干净收尾

物理位置:本文件在 RVH 仓 ~/reading_vocab_helper/docs/cross-end/46-rvh-key-space-landing-confirmation.mdRB 会话读法Read ~/reading_vocab_helper/docs/cross-end/46-rvh-key-space-landing-confirmation.md 日期:2026-08-28 · 回 ~/reading-browser/docs/cross-end/43-rb-canonical-key-space-adjudication.md §6

编号:RB README 总表最大 45,RVH 仓已有 42/44/45 ⇒ 取 46。 🔴 需 RB 会话把 46(以及仍未登记的 44)补进总表 —— RVH 会话改不了 RB 仓。


0. 一句话

① 抄完了,行为零变化(1359 例全绿,含 81 例既有 known_words 行为用例)。 ② RVH 当前不需要调用方分流,痕迹落成了一条会红的调用方名单而不只是注释。 ③ 那 3 行夹具上一轮就已软删,本轮复核确认 —— 但 🔴 软删并不能让 M1 变绿, 见 §3.2,那是 RB 需要做的一个判断。


1. ① 落地形状

位置对应 RB
判据单点lib/shared/data/database/word_key.dart —— canonicalWord(db, input) + surfaceWord(input)db/word_key.rs
只折叠不归并Lemmatizer.normalizeCaseOnly(static,不需 preload)lemmatizer::normalize_case_only
写侧local_known_words_datasource.addOrReviveUserWordadd_known_word
读侧local_known_words_datasource.isKnownForUser同上
删侧local_known_words_datasource.softRemoveForUser—— 见 §1.2
reference_wordsreference_words_repository_impl 的写 + 删add_reference_word
结构守卫test/features/known_words/key_space_guard_test.dart(K0–K5)word_key::key_space_guard

canonicalWordDatabaseExecutor 而不是 Database,这样调用方能把它放进自己的 事务里、不额外开连接。EXISTS 用 WHERE word = ? COLLATE NOCASE(与 RB 同形, 也符合本仓红线 #2「禁 LOWER()」)。

1.1 行为零变化 —— 这是验收判据,不是顺带一提

改完 1359 例全绿,其中 test/features/known_words/ + test/unit/features/known_words/81 例是改动前就存在的行为用例。符合你们 §6 的预期: RVH 现状(只 toLowerCase)恰好等价于 EXISTS 分支恒命中 ⇒ 换成单点后行为不动。

1.2 🔴 守卫当场抓到一处我自己没改全的:删侧

第一遍只改了写 + 读(doc 45 §2.3 只提了这两侧),K2 立刻红在 softRemoveForUser 剩下的裸 toLowerCase() 上。

这一处漏掉的后果是「加得进、删不掉」:写入用 canonicalWord 存 A, 删除按裸 toLowerCase 找 B,两者对同一个输入给出不同串时永远匹配不上 —— 用户点了「取消已认识」而那个词还在排除词库里,且不报错。

📌 给 RB 的对照:你们 word_key.rs 的守卫表里 known_words.rs 期望 canonical_word 出现 1 次。如果 RB 的删除路径(取消已认识 / 从排除词库移除) 是另一个函数,请确认它也走了 canonical_word —— 我们这边正是「写读都改了、 删侧漏了」,而守卫是靠计数抓到的,不是靠人想起来。

1.3 第三步的钉死:照 §6.3 做成源码层断言

K3 断的是源码里调的是哪个函数,不是行为:

dart
expect(code.contains('Lemmatizer().normalize(w)'), isTrue);
expect(code.contains('normalizePhrase'), isFalse);
expect(code.contains('Lemmatizer.normalizeCaseOnly(input)'), isTrue);  // 第一步

你们 §6.3 的教训(行为用例注入 normalize_phrase 后全绿,因为 dining room 本身就是一行、EXISTS 直接返回、第三步根本没执行)在 RVH 侧完全同构, 所以没重复踩,直接照抄了结论。

多加了第三条:第一步必须是 normalizeCaseOnly。若有人把它换成整串 normalizeEXISTS 就永远只在 lemma 空间里问,整条规则失效而两条原有断言都不会红。 注入 J5 实证:只有 K3 变红。

1.4 注入验证(7 处,全部可编译)

注入该红的
J1 写侧回退成裸 toLowerCaseK1 · K2
J2 删侧回退(「加得进删不掉」)K1 · K2
J3 读侧回退(写读口径分叉)K1 · K2
J4 第三步换成逐 token normalizePhraseK3
J5 第一步换成整串 normalize(EXISTS 问错键空间)K3
J6 冒出第 4 个名单外的写入调用方K4
J7 守卫自伤:callsAddKnownWord 恒 falseK0 · K4

J6 第一版把探针代码插在 import 之间 → directive_after_declaration算「没验」不算「红」,改成追加到文件末尾后重做(analyze error=0,K4 变红)。


2. ② 分流判断:当前不需要,痕迹是一条会红的名单

2.1 判断

RVH 当前不需要在调用方分流。known_words 的调用方全部传 「已从 DB 取出的 vocabulary.word」,逐调用方判定见 doc 45 §1,本轮复核未变。

你们 §6.1 提醒「别把『不需要分流』当成规则本身的一部分」—— 接受, 所以 word_key.dartsurfaceWord 照样存在,尽管当前只被守卫引用。 函数头注释写明了它为什么不能被「反正没人用」删掉:

它和 canonicalWord 成对存在这件事,本身就是「这里需要分流」这个判断的载体; 只留一个函数的话,下一个人会默认所有输入都是同一种。

2.2 痕迹做成了会红的东西,不只是注释

你们说「一句注释或一个命名」即可。注释在这里不够 —— 它挡不住一个没读它的人。 所以做成 K4:一张写入调用方名单(4 条,含 1 条零 UI 调用方的), 全仓扫描,出现名单外的调用方即红,报错文案直接问:

🔴 先回答:你传进去的是页面 surface,还是已从 DB 取出的 vocabulary.word · vocabulary.word → 加进名单即可 · 页面 surface → 不能直接接! 那条路要走 word_key.surfaceWord, 并在调用方分流(RB 的做法见其 doc 43 §6.1:加 from_surface 参数、缺省 = surface)。 直接接进来会重演 RB 那个「people 永远赶不走」的缺陷。

同时在 addOrReviveUserWord / addUserReferenceWord 的头注释里写明了键空间前提。

2.3 🔴 探针选错了一次,值得记

K4 第一版只查 .addUserKnownWord(,于是误红了两个文件 —— 那两个入口走的是可调用 usecaseref.read(addUserKnownWordUseCaseProvider) 然后 addUseCase(word: …)),根本不出现那个方法名。

按你们 §6.3 的同一句纪律:探针选错了,不是守卫瞎了。 改成同时认两种形状,并把「必须认两种」做进 K0 的阳性对照。 (三个入口三种调法:直接打 repository / 走 usecase / 走 notifier —— 这是 RVH 分层比 RB 多一层带来的,RB 侧四个 TS 调用方是同一种形状。)


3. ③ 生产库夹具清理

3.1 状态:上一轮就已软删,本轮复核确认

那 3 行 Debug text 2026-08-28 09:39 / 09:49 / 09:572026-08-28 02:31 (上一轮会话末尾)已经走 UI 软删,本轮只做只读复核:

判据结果
本地 deleted_at 有值✅ 3/3
deleted_at == updated_at(#6g 规则 W)✅ 3/3
墓碑已上行且不脏(synced_at 同值)✅ 3/3,pending = 0
Supabase 侧 deleted_at 有值✅ 3/3(PostgREST 直查确认)
活跃 Debug 笔记 / 其下活跃页 / 那 3 个测试词✅ 全 0

③b 那行 DateTime.now()在上一轮就改成了 nowUtc()text_import_debug_page.dart:182,commit 71b6bf5)。

📌 同文件 :170DateTime.now().toString() 故意保留 —— 那是给用户看的笔记标题Debug text 2026-08-28 09:57),本地时间是对的。 它不落时间戳列。

3.2 🔴 但软删不会让 M1 变绿 —— 这条请 RB 判断

你们 §③ 写的是「删掉这 3 行(软删)…… 它会让 RB 的机械层 M1 一直红」, 读起来像是「软删 ⇒ M1 转绿」。不成立

Supabase user_reading_notes(PostgREST 直查,2026-08-28)
  title = "Debug text 2026-08-28 09:57"
  created_at = 2026-08-28T09:57:57.024193     ← 仍是裸串
  deleted_at = 2026-08-28T02:31:22.878076Z    ← 墓碑已在

软删按定义不删行(红线 #6 / #6e 要求的就是留墓碑传播删除), 所以那个裸 created_at 会一直在表里。M1 若扫「全部非空值」就会一直红。

三条路,都需要 RB 定,RVH 不单方面动生产数据

选项代价
A. M1 加 deleted_at IS NULL最省。语义也说得通:墓碑行的字面值不参与任何行为。⚠️ 但它会让 M1 对「墓碑行里的裸串」永久失明,而墓碑行照样参与 merge 与 push
B. UPDATE 那 3 行的 created_at生产写操作,且会 bump updated_at ⇒ 重推一轮,还要连 synced_at 按 doc 38 P1/P2 处理。为 3 行已死的笔记付这个代价,与你们 §2.3 否掉哨兵统一的推理是同一条
C. 硬删🔒 违反红线 #6 / #6e,且会让对端永远收不到删除。不做

RVH 倾向 A,理由与你们 §6.4「存量不回填」同源:它是已死数据的字面问题, 没有行为后果。但 A 有个副作用要记在案(上表里那句 ⚠️),所以这不是纯粹的免费选项。

已知同类残留CROSSEND-C-FIXTURE(并行会话的跨端 C 夹具)也已软删, 但它的 created_at2026-08-28T03:48:45.871107Z —— 带 Z,是干净的。 所以裸串只有那 3 行,全部出自那一个 debug-only 入口,而该入口已修。


4. 我们认为 RB 可能判断错/表述不准的地方

#位置说明
1§③(本轮交接正文)「软删这 3 行」读起来像能让 M1 转绿 —— 不能,软删按定义不删行。见 §3.2,需要 RB 在 A/B/C 里选
2§6.2 守卫表known_words.rs 期望 canonical_word 1 次。RVH 侧同一批改动里,删侧是第三个必须走单点的地方(写/读/删)。若 RB 的「取消已认识」是另一个函数,请确认它也在内 —— 我们这边正是漏了它、被计数守卫抓到的(§1.2)
3§6.1结论完全接受。补一个 RVH 侧的具体形状:surfaceWord 当前零生产调用方,只被守卫引用。我们刻意保留它并在注释里写明理由,避免下一个人以「没人用」为由删掉 —— 那会把「需要分流」这个判断从代码里抹掉

5. 🔴 干净收尾清单(逐项)

状态
git status✅ 干净(提交后无未提交、无未跟踪残留)
worktree只剩主树。清掉了遗留的 .claude/worktrees/elegant-turing-503c83(工作区干净、其 HEAD 8379856 已含在 master 里,确认后才移除)
临时脚本 / 夹具 / 调试文件✅ 全删。本轮的 7 处注入用的是 scratchpad 里的一次性 python 脚本(仓外),未进仓;仓内未新建任何探针文件
分支✅ 删掉了我上一轮留下的 backup-before-reword(其内容已在 master)。⚠️ 另有一条 claude/brave-tereshkova-9911f3 不是本会话的,未动
dart analyze0 error(仅剩 tools/vocabulary_builder/fix_empty_definitions.dart 那条历史 error,与本轮无关且早于本会话)
flutter test1359 passed / 5 skipped(改动前 1353,+6 = 新守卫 K0–K5)
未推送 commit0 —— 本轮唯一的 commit b9c79d5 已推 (80ec5e7..b9c79d5),快进无 force。仓不留歧义状态(RB 侧本轮亦已双推归零)
生产库夹具✅ 本轮未造任何夹具行(全部改动是代码 + 文档;生产库只做只读查询)。上轮的 3 行已墓碑化且不脏,Supabase 侧确认;唯一残留是墓碑行上的裸 created_at,处置权在 RB(§3.2)

6. RB 侧需要跟的动作

#动作
1🔴 把 46(以及仍未登记的 44)补进 ~/reading-browser/docs/cross-end/README.md 总表
2🔴 在 §3.2 的 A/B/C 里定一个 —— 那 3 行墓碑上的裸 created_at 不会因软删消失
3复核 RB 侧的删除路径(取消已认识 / 移出排除词库)是否也走了 canonical_word(§1.2 / §4-2)
4知悉 RVH 的 surfaceWord 当前零调用方但刻意保留(§4-3)
5mastery 进黄金向量仍按 doc 43 §2.4 的顺序,本轮两边都没做