Skip to content

专有名词治理 —— 实施计划

物理仓库位置:~/reading-browser(RB 桌面端)。 创建:2026-08-25 · 立项自 docs/plans/backlog.md(该条已缩为指针,内容全量迁到本文件)。 状态(2026-08-31 更新):L-A ✅ · L-B 🔴 推翻并入 L-D(§3)· L-D ✅ 两半均已交付 · L-C ⏳ 未开工。 L-A 的 7 个改造点 + v34 索引 + 结构守卫已交付;§4.3 四条实机验收 2026-08-30/31 已跑完 (含 §4.2 的难度档分布对照,见 §4.3.1 与 §4.5 ③)。 L-D 两半都在 2026-08-31 落地:① 过滤器退役reference_words 黑名单,裁定 §2.4、 实机验收 §4.5「①④ 结案」)· ② 产品出口(A 类拆成「止损折叠(成本 0)+ 按需补背景」、 B 类收窄为只在查词弹窗提示、不动页面高亮;九词矩阵实机 9/9,见 §6 与 §4.5 的 A/B 类验收段)。 仍未做:只剩 L-C(预装库补标 / 收词闸门)。

⚠️ 别把「L-D 已落地」读成「校准 B2 可解封」:B 类的硬过滤那一半被实测推翻(§0.7 第 4 条), 真正落地的是非破坏性提示快速分级候选池一个词都没少 —— is_pure_proper_noun 全仓唯一调用方是 dictionary.rs 的查词路径(2026-08-31 实测 1 处)。 闸门 2 的钥匙是 L-C,不是 L-D;docs/plans/backlog.md 那条已同步改。

⚠️ 历史注记:步骤 7 曾误记为「实机验收 ✅」,2026-08-27 与用户核实后更正 —— 那些数字是对真库跑 SQL 查出来的,不是在 app 里看到的。这个区分后来被证明是实的(§4.3.1)。

🔒 另一条独立的不归档理由(2026-08-27 补,与验收无关):本文件是 L-C / L-D 的 唯一真相源——docs/plans/backlog.md 明写这句,且 src-tauri/src/db/vocab_scope.rs · src/lib/calibration.ts · supabase/functions/lookup-or-fetch-word/index.ts 三处 代码注释docs/cross-end/30-rb-integrated-reseed-handoff.md 都按当前路径指过来。 即便验收补完,只要 L-D 未落地,归档就是把仍被代码引用的真相源移出活跃视野 (正是 S28 警告的那个形状)。归档要连同这 9 处指针一起改路径,别单独 git mv


0. 病根与四类症状(2026-08-25 全量实测,非推断)

0.1 病根:vocabulary 一张表同时承担三个角色,判据从未分开表达

角色专名该不该在里面现状
可查 lookup✅ 该(且该给得更好,见 L-D)✅ 在,但给的是词典释义
可学 learnable(发现/推荐/triage/复习)❌ 不该❌ 混在一起
计难度 gradable(CEFR 直方图 / p75)❌ 不该❌ 混在一起

三个角色共用同一个判据——「在 vocabulary 表里且有 CEFR」。凡是需要区分的地方, 只能靠一张 193 词的静态整词黑名单(reference_words)打补丁。 所有症状都是这一件事的不同投影,逐个打补丁只会越补越散。

0.2 症状 1 — 难度评估被专名系统性抬高(影响最广、一直在发生)

三条难度路径完全不过滤,连 reference_words 都没接:

路径位置特别之处
页面难度徽标 + reading_logreading.rs::analyze_text_difficultyp75 规则,C1/C2 专名直接抬高全页档位
RSS 文章难度reading.rs::analyze_text_cefr_light只取 100 个唯一词,而新闻标题正是专名密度最高处
推荐文章覆盖率reading.rs::analyze_article_bodiesdict运行词级计数:Manchester 出现 20 次就往超纲桶记 20 次

黑名单只接了 3 个查询(get_learning_words / _with_cefr / get_discoverable_words)。

🔴 2026-08-31(L-D)订正:是 6 处,不是 3 处。 上面这句只数了 vocabulary/query.rs, 漏了 reading/analysis.rs::analyze_article_bodies_innerlearning 集, 以及 content-script 的整条腿 —— highlight.jsdiscovery.js 各自拿 get_reference_word_set 的结果再 Set.delete 一遍(defense-in-depth)。 这个漏数不是无害的:只按这句去改 Rust 侧,fox 照旧不高亮,而且不报错 —— 拆黑名单那轮正是先被这一点绊住的。教训与 §4.3.1 同型:SQL 层的清点看不见 JS 层。

0.3 症状 2 — 黑名单 95% 是安慰剂,且有误杀

seed reference_words 193 词
  ├─ 真正存在于 vocabulary   45   ← 唯一实际生效的部分
  └─ 不在 vocabulary(空转)  148  ← 77% 是安慰剂

预装库 word_tags proper_noun 871 词 → 其中 835 不在黑名单里,照常高亮/推荐/计难度
有效覆盖率 ≈ 45/871 ≈ 5%

误杀:fox(B2 动物,CEFR-J 权威分级)被当 Fox News 永久排除,且 source='system' 用户删不掉、也没有管理 UI。同类 who / amazon。 死代码:7 个 reference_words 命令有 6 个零调用方

0.4 症状 3 — 快速分级 / 校准的候选池被名字占满

get_triage_candidates 无任何专名过滤,按「档位 + 字母序」推进。实测 C2 首屏 20 词里 12 个是人名abba · abby · abigail · ajax · alba · alec · alison · alma · amos · angelina · angus · austin。 校准探针(triage.rs::calibration_probe_inner)排了 proper_noun 标签——全仓唯一接线处—— 但标签召回不足(C1 118/1261、C2 127/483),所以 CALIBRATION_MAX_LEVEL='B2' 封顶才是真正兜底。

0.5 症状 4 — 露骨词与网络垃圾在学习候选池里

milf[C2] · hentai[C2] · shemale[C2] · facesitting[C2] · blowjob[C1] ·
bestiality[C1] · voyeur[C1] · porn[B2] · nude[B1]
trackback[C1] · spyware[C1] · pmid[C2] · dell · intel · rais · batterie

全部 cefr_inferred=1 —— 来自 Google Web Trillion 词频 top-20k(网络爬取语料, 天然含色情与站点模板文字)。它们同样进发现高亮、triage 单卡流、难度直方图。 同一病根的第二类症状:它们该「可查」,不该「可学」。 ⚠️ 这条把解开校准 B2 上限从精度问题升级成「必须先清池子」的硬前置。

0.6 根因链:为什么光靠标签救不了

kaikki_parser.dart::_irrelevantPos 把 pos=='name' 整组丢弃
   ↓  专名证据在建库时就被删掉
只剩垃圾义幸存(且例句是 LLM 编的):
   paul=古意大利银币 · manchester=家用布草 · claire=养蚝池 · todd=公狐
   peter=阴茎 · virginia=阴道 · york=呕吐 · sharon=工人阶级女性(贬)
   ↓  于是「是不是专名」只能靠 LLM 事后补标 word_tags,召回不足
   ↓  而 CEFR 来自 Google 的**词形级**词频(被名字用法抬高),与**词义级**的 CEFR 打架

实证:全库 pos_definitions 里带 "given name"/"surname" 的只剩 10 个词——证据确实被删干净了。 ⚠️ 附带后果:双击 Virginia / Peter 会拿到粗俗释义 + LLM 生成的粗俗例句,可复现的产品事故面。

0.7 已排除的错误解法(别再试)

  1. 用词频阈值压C1/C2 + frequency_rank<2000 有 241 词,抽样全是 abortion · accelerate · accountability · adjacent 这类正经学术词,精度不可用。 (与 backlog §待发现词 Top-N v2 的「别用调权重去压」是同一结论)

  2. 直接 word_tags LIKE '%proper_noun%' 硬过滤:会误伤 194 个带 adj 词性的 国籍/文化词(american · english · chinese · italian · latin · christmas · jewish)。

  3. 整词黑名单fox · polish · turkey · march · bill 证明同形词不能按 type 拉黑, 而 March/Polish 又证明按 type 也管不住(polish 的首义项本来就是「波兰的」)。 📌 2026-08-31 L-D 已按此结论退役 reference_words(§2.4)。

  4. 🔴「非句首 + 首字母大写 → 该处不高亮」(token 级硬抑制)—— 2026-08-31 实测推翻, 这原本是 §6 给 B 类定的做法。对 18 篇推荐文章正文(通识向:New Yorker / 科学新闻 / NASA / 诗歌 / Lifehacker,非 ML 偏样本)跑同一套 WORD_PATTERN + 句首判定:

    指标
    会被抑制的高亮实例中位 12.1% / 篇(均值 14.6%,合计 1170/8581;18 篇里 13 篇 > 9%)
    其中判据已认定为纯专名的仅 113(9.7%) —— 那些词本来就不在高亮集里,抑制是多余的
    判据够不到、只有本规则能挡的1057 次 / 451 个词形

    那 451 个词形里占多数的是卡在 Title Case 里的普通学习词language internationalconference information source machine space flow text generation modelcontinuous deep history library intercept association。真专名(zhang wangyang trump apple latin)是少数。最刺眼的单篇是 NASA 那条 56.8% —— 标题「Deep Space Network's Deep Space Station 23」让 deep/space/station/network 全被抑制,正在读太空报道的人失去 space 的高亮

    这是在 token 级重演 fox 那个错误,而且代价用同一把尺子量出来比黑名单还大 (黑名单误杀 17 个词形,这条误杀 ~300 个词形 / 每篇每 8 个高亮就毁掉 1 个)。 §6 原文自己写着「不做硬过滤fox 的教训)」,但「该处不高亮」就是硬过滤的另一个名字—— 只是把粒度从 type 换成 token,没换性质。B 类只保留非破坏性的那一半(查词时提示),见 §6。

    🔑 复现:脚本形态见本条表格的口径(WORD_PATTERN + 「往前跳过空白与引号, 遇 .!?;:\n 或串首即句首」),语料 = recommended_articles.body_text。 数字随语料走,结论方向不随 —— 重跑时看的是「判据够不到那一列里普通词占多数」这件事, 不是 12.1% 这个数。

  5. 🔴「主动推送池只用权威分级词(cefr_inferred=0)」—— 2026-08-31 实测推翻(这是 同一天在 L-C 会话里提出、当场被自己的数据否掉的方案,记在这里免得下次再提一遍)。 动机是对的:库里 12,172 个单词分两半来路 —— 7,002 权威分级(Oxford 5000 4,643 + CEFR-J 2,495,逐词分级)与 5,170 词频反推(Google Web Trillion top-20k 按词频推档), 而专名/网站模板/露骨词全部落在后一半。看起来「只用权威那半」就能一刀清干净。

    否掉它的是「收紧会丢掉什么」那一列(各档均匀抽 30 词):

    权威占比收紧会丢掉的词长这样
    A269%aquarium calorie cellphone clarinet crane footwear kitty meadow plaid poultry slipper squirrel toddler washable
    B152%amber audible bistro brunette cavity clipboard cork deduct downtime foam hardcover intern lateral maiden palette playback purifier shipment spherical teak vanilla
    B230%ambient classy connectivity dispatch enzyme fetal garnet jacuzzi nifty overdrive physiology proactive reboot retention screenshots slick streamline termination turf verification wrench

    这些恰恰是 Oxford 5000 / CEFR-J 覆盖不到、而现代阅读高频的词。 而同一批抽样里 各档的噪声率是 B2 ~10% · C1 ~30% · C2 ~73%(噪声 = 人名/品牌/截断词/露骨词)—— ⇒ 为清 B2 那 10% 噪声要砍掉它 80% 的池子(4,303 → 877),净损失。 A1–B2 的判据不该动;噪声随档位单调爆炸这件事说明对症的是收词阶段(L-C 的闸门), 不是运行时判据。

    🔑 C2 是另一回事,别被本条一并否掉:C2 档 519 个词权威分级 = 0 个, 它不是「脏」,是这一档在本库里没有可信来源 —— 这正是 CALIBRATION_MAX_LEVEL='B2' 的真实含义。「C2 要不要继续参与主动推送」是独立决策,见 §6 L-C 第 4 条。


1. 本轮范围

:L-A(运行时判据统一)+ L-B(缓冲池闸门)不做:L-C(pipeline / reseed)· L-D(产品出口)· reference_words 退役 · 大小写 token 级判据。

🔴 2026-08-25 实施中推翻:L-B 不成立,已并入 L-D(详见 §3)。 分层判断错在这里 —— 我把 L-B 当成「独立于 L-D、可单独落地」的一层, 而它要能工作,必须知道用户在页面上看到的 token 是不是大写开头, 那正是 L-D 的前半部分。L-B 依赖 L-D,不是平行关系。

⚠️ L-A 治不了什么,先说清楚:判据依赖 proper_noun 标签,而症状 3 里最刺眼的 virginia · manchester · peter · todd · claire · york 恰恰都没被标(8 个典型样本漏 6 个), 症状 4 的露骨词也全部无标。所以本轮结束后

  • ✅ 发现/推荐/难度/triage 的专名噪声大幅下降(有效覆盖 45 → 584)
  • CEFR 高亮不变:用户自己存进生词本的词照旧高亮,包括专名(§2.3.1)
  • ❌ 校准 B2 封顶仍不能解开
  • ❌ 双击 Virginia 的粗俗释义仍在

那两件事的钥匙在 L-C。别把本轮完成误当作它们的完成。


2. L-A 设计:运行时判据统一

2.1 判据

sql
-- 「排除纯专名」:tagged proper_noun 且不存在 noun/proper noun/name 以外的词性
-- (CASE 守卫的理由见 §2.2 末;两列不对称的理由见 §2.1.1)
NOT (CASE WHEN json_valid({a}.word_tags) = 1
          AND ({a}.pos_definitions IS NULL OR json_valid({a}.pos_definitions) = 1)
     THEN (EXISTS (SELECT 1 FROM json_each({a}.word_tags) WHERE value = 'proper_noun')
           AND NOT EXISTS (SELECT 1 FROM json_each({a}.pos_definitions)
                           WHERE key NOT IN ('noun', 'proper noun', 'name')))
     ELSE 0 END)

📌 以上是最终形态(含步骤 1 的 CASE 守卫与步骤 3 的 NULL 修正)。真相源始终是 db/vocab_scope.rs 本身 —— 本块只为讲清判据,改判据时以代码为准、回头同步这里。

实测:命中 584 词(A1 6 / A2 59 / B1 112 / B2 208 / C1 94 / C2 105),误杀 0。 584 个全部 cefr_inferred=1 —— 凡 Oxford / CEFR-J 权威分级过的词 (fox polish turkey march bill)天然不在其中,这是判据自带的安全网,不是巧合。

为什么是「无其它词性」而不是「只有 noun」(两者在当前预装库上等价,都是 584):

POS判定理由
john{noun}过滤 ✓
american{noun, adj}保留 ✓adj = 已词汇化,合法学习词
japan{noun, verb}保留(保守)✓见下方「刻意的假阴性」
kyiv(L-B 回填){proper noun}过滤 ✓只有这个写法能覆盖到
paul(L-C 补标后){noun, name}过滤 ✓向前兼容 L-C

写成「只有 noun」会让 L-B 的回填行和 L-C 的补标行双双漏网——判据必须一次写对, 否则 L-B/L-C 落地时要回头改这里,而那时已经有 7 个调用点在依赖它。

刻意接受的假阴性(93 词)ebay(verb) skype(verb) walmart(verb) stockholm(verb) 这类因 Wiktionary 收了 "to google/to xerox" 而带了 verb 词性的专名会漏网。 不收紧的原因:同一批里混着 congress karate sushi quarterback pope dean mason真正的常用词,收紧必然误杀它们。宁可漏过,不可误杀fox 的教训)。

2.1.1 步骤 3 修正:pos_definitions IS NULL 的空档

triage.rs::proper_nouns_are_excluded先于本方案存在的回归测试)造的 paul 行 只有 word_tags、没有 pos_definitions,而步骤 1 的守卫要求两列都是合法 JSON 才进 THEN 分支 → 该行被放行 → 老测试红。

老测试是对的,判据错了。pos_definitions 为 NULL 意味着一条词性证据都没有, 此时「不存在允许集以外的词性」是空真,该行正是最该滤掉的那种。守卫因此对两列 不对称word_tags 必须合法 JSON(否则谈不上有没有标签),pos_definitionsIS NULL OR json_valid(...)

预装库实测 pos_definitions 无一为 NULL,所以这条不改变当前行为 —— 但它是 L-B 回填行可能出现的形状,且老测试编码的是意图。一条先于本方案存在的测试 挡下了一个会在 L-B 落地时才发作的洞

2.1.2 calibration 的替换是双向的,不只是收紧

calibration_probe_inner 原本手写 word_tags NOT LIKE '%proper_noun%'。换成共享谓词后:

  • 收紧 —— 旧写法是裸字符串匹配,盖不住 L-B 回填的 {proper noun} 形状;
  • 放宽 —— 旧写法把 194 个带 adj 词性的国籍/文化词(american / chinese / english)也一并排除了,而它们是合法常用词,本就该能当探针。校准问的是 「这一档的典型词你会不会」,把 american 从 B1 探针池剔掉没有道理。

新增反向断言 nationality_adjectives_stay_in_the_probe_pool 钉住放宽方向。

2.2 单一真相源

新建 src-tauri/src/db/vocab_scope.rs(沿 db/word_list.rs 先例:跨 command 模块共享的 SQL 助手):

rust
/// 生成「排除纯专名」的 SQL 谓词。`alias` = vocabulary 表在该查询里的别名(无别名传 "vocabulary")。
/// 调用方用 `AND {}` 拼接。**禁止在调用点手搓等价 SQL** —— 结构守卫测试会红。
pub fn exclude_proper_nouns(alias: &str) -> String

纪律照 push.rs::USER_SCOPED_DIRTY:所有消费点只准调这个函数,不准复制谓词文本。 理由与 push 那条完全同构——判据一旦分叉,分叉的那一支不会报错,只会静默给出不同答案。

📌 这条纪律的权威出处不在本文件(本文件会归档,纪律不会): 定义在 db/vocab_scope.rs::exclude_proper_nouns 的文档注释里,由 all_seven_consumption_points_use_the_shared_predicate + nobody_hand_rolls_the_old_like_pattern 两条结构守卫机械执行。 刻意提升到 CLAUDE.md §4 红线表:与 #5i(跨用户数据泄漏)不同量级 —— 这条判据分叉的最坏后果是「某处对某个词的可学判断不一致」,够不上红线。 代码注释 + 会红的测试,对这个量级是更合适的归属。

为什么不落成物化列ALTER TABLE vocabulary ADD COLUMN learnable): 物化 = 派生状态的第二本账,reseed / backfill 任一路径忘记重算就静默过期, 而过期的表现是「过滤时好时坏」,没有报错。谓词每次从真值算,永不过期。 代价是每次查询多一次 JSON 扫描 —— 实测这些查询都是「一次性载入」或「用户主动触发」, 不在热路径上,可接受。

JSON1 可用性 —— ✅ 2026-08-25 实证通过cargo test --lib vocab_scope 3/3)。 libsqlite3-sys 0.28 静态编译 SQLite 3.45.0,JSON 函数自 3.38 起内建。 原本只是推断,现由 filters_exactly_the_pure_proper_nouns 在真实构建里钉住 —— 整个 §2.2 建立在它上面,所以它是步骤 1 的唯一阻塞项,已解除。

⚠️ 谓词必须用 CASE 包起来(步骤 1 实现时发现,非原设计)json_each('not json')抛错("malformed JSON")而不是返回空集 —— 任何一行的 word_tags / pos_definitions 存了非法 JSON,整条查询直接失败,表现是「发现模式 一个词都不出」而不是「少滤了一个词」。SQLite 不保证 AND 两侧的求值顺序, 但明确保证 CASE 惰性求值,所以 json_valid 守卫只能写在 CASE WHEN 里。 (json_valid(NULL) 返回 NULL 而非 0 → 落 ELSE 0 → 保留,正是要的行为。)

2.3 七个改造点

⚠️ 2026-08-25 步骤 2 修正:原列 9 个,砍到 7 个。get_learning_words / get_learning_words_with_cefr 从改造点移到「明确不改」—— 理由见 §2.3.1,那是我在写计划时把「可学」这个角色划大了。

#位置角色改法
1vocabulary/query.rs:473 get_discoverable_words可学AND {pred}
2triage.rs:100 get_triage_candidates可学AND {pred}
3triage.rs:162 get_triage_pending_count可学AND {pred}(与 #2 口径必须一致,否则计数与实际能推的候选对不上)
4triage.rs:215 calibration_probe_inner可学替换现有的 word_tags NOT LIKE '%proper_noun%' 为统一谓词
5reading.rs:96 analyze_text_difficulty计难度AND {pred};被排除的词自然落进 dist.unknown,而 unknown 不进 known_total → 不影响 p75。符合既有「OOV 不入分母」口径
6reading.rs:1554 analyze_text_cefr_light计难度AND {pred}
7reading.rs:1827 analyze_article_bodiesdict 载入计难度AND {pred}只改 dict,同函数 1806 行的 learning 集不动——那是用户已保存的词,存了就该算)

2.3.1 为什么 get_learning_words* 不在其中

这两个是 CEFR 高亮的数据源:把用户自己存进生词本的词在页面上标色。

「可学」这个角色指的是我们主动推给用户的候选池(发现模式 / 快速分级 / 校准探针)—— 那里过滤是在替用户省注意力。而 learning_entries 里的词是用户双击后主动保存的, 是他的显式选择,不是我们的提议。用户存了 Texas(也许他就是想记住这个地名), 我们却不再高亮它 = 替他否决了他的决定,且无任何提示 —— 正是本仓最忌的静默失效。

更要命的是它会造出最令人困惑的组合:同一个词在生词本列表里在、在复习卡里出现、 在 get_cefr_distribution 统计里算数(这三处都明确不改),唯独页面上不高亮

📌 遗留问题(归 L-D):这两处现有的 reference_words 过滤其实是同一个毛病 (用户存了 japan 也不会被高亮,japan 在 193 词 seed 里)。但那是已发布行为, 且 reference_words 的存废本身是 L-D 的题目 —— 不在本轮扩大它的作用域。 把一个可疑行为顺手放大 13 倍,正是小毛病变成承重墙的方式。

明确不改(属「可查」或「已保存词统计」角色,改了是错的):

位置为什么不改
dictionary.rs::lookup_word可查 —— 双击专名必须查得到(怎么查得更好归 L-D)
vocabulary/query.rs::browse_vocabulary词库浏览器 = 可查
vocabulary/query.rs::get_vocabulary_stats「词库总量」是资产规模,不是候选池
reading.rs::get_cefr_distribution / get_srs_efficiency / get_most_looked_up_words统计的是用户已保存的词,用户存了就是存了
srs.rs 全部同上,复习的是已保存词
vocabulary/discovery.rs::get_discovery_word_meta入参词表已由 #3 过滤过,重复过滤无意义
crud.rs::save_word用户主动保存专名是他的自由,不该拦
vocabulary/query.rs::get_learning_words / _with_cefrCEFR 高亮 = 用户已保存词的回显,不是候选池。详见 §2.3.1

2.4 reference_words 怎么办

本轮(L-A)不动。它现有的过滤保留(与新谓词并存,是 OR 关系的两张网)。 退役 / 误杀修复(fox/who/amazon)留给 L-D。

✅ 2026-08-31(L-D)裁定:过滤器退役 · 数据留存

三条路各自的后果先摆出来(判据是 §2.3.1 那个「生词本里在、复习卡里出现、统计里算数, 唯独页面上不高亮」的困惑组合会怎样):

方案那个困惑组合代价
过滤器退役 · 数据留存选定彻底消失7~8 个真专名重回候选池
保留表 + 去 system 来源缩小到用户自建词6/7 命令零调用方 ⇒ 用户根本加不了词 ⇒ 机制永远空转;还丢掉 193 行 chinese_meaning
保留 + 补管理 UI保留,但可解释为一个有效覆盖率 5%、且已被 vocab_scope 结构性取代的机制建 UI

决定性数据(对本机真库实测,非推断)

193  ├─ 145  不在 vocabulary          → 纯空转
     ├─  20  已被 vocab_scope 覆盖     → 冗余
     └─  28  黑名单独有 ──┬─ 17 误杀(fox·dna 权威分级 + 15 个带 adj 的国籍词)
                          ├─  4 缩写(asap·http·wifi·nato)
                          └─  7~8 真专名(japan·amazon·google·microsoft·shanghai·stockholm·chile)

最有说服力的一条是同一词类被劈成两半american/chinese/french/spanish/german/ italian/japanese/korean/british 不在名单里、正常高亮;russian/ukrainian/israeli/ iranian/saudi/afghan/palestinian/syrian 在名单里、永不高亮。而 §2.1 的判据文档 明写「带 adj = 已词汇化,是合法学习词,保留 american」—— 黑名单正在否决一条已裁定的判据, 分界线只是 2026-04 那份 seed 恰好抄了哪些地缘新闻国家,没有任何语言学理由。

落地:删掉全部 6 处过滤(Rust 4 + content-script 2,见 §0.2 的订正);表与 193 行 原样保留chinese_meaning 是 L-D「A 类纯专名」中文兜底的数据源,§6 明写别删); 7 个命令下线(6 个零调用方,get_reference_word_set 随消费点消失)+ 随之全空的 db/word_list.rs无迁移、无数据变更 —— 回滚 = git revert

留下的 7~8 个真专名不是遗漏:它们正是 §2.1「刻意接受的假阴性」那一类(japan/stockholm/ google 带 verb 词性),对症修法是 L-C 的预装库补标,不是手工名单。

🔒 新不变量已进稳定归属(不留在本 plan 里):src-tauri/CLAUDE.md §5 + db/vocab_scope.rs 模块头注释;守卫 the_reference_words_blacklist_stays_retired (覆盖 Rust + JS 七个文件,带阳性对照 blacklist_detection_is_not_blind)。


3. L-B 设计:缓冲池闸门 —— 🔴 已推翻(2026-08-25 实测)

3.1 结论

当前数据源下做不到,两个方向都验过。已部署又撤回,线上恢复原行为。 真正的修法归 L-D(§6),本节保留为「此路不通」的证据,别再走一遍。

3.2 方向 ①:看本函数拿到的 POS —— 永远等不到 proper noun

Wiktionary 页面标题区分大小写,而入参已被 RB 的 lemmatizer::normalize 小写化, 于是查到的是小写页,而小写页是另一个词条

查询返回 POS小写页释义
KyivProper noun乌克兰首都
kyivNoun「chicken Kiev 的异体」——鸡肉料理
TexasProper noun美国得州
texasNoun「蒸汽船的顶层舱面」
parisNoun「pari 的复数」

⚠️ 这与预装库里 paul=古意大利银币 / manchester=家用布草 是同一个病(§0.6), 区别只在于:预装库那批是冻结资产里的历史遗留,而这条是线上 backfill 路径此刻正在做的事 —— 用户双击 Kyiv,缓冲池就存一条「chicken Kiev 的异体」,还跨用户共享。 (这条本身是 L-D 的一个新论据:专名的正确出口不是词典释义。)

3.3 方向 ②:改查大写页 —— 会在所有职业类姓氏上误触发

             小写页 POS            大写页 POS
kyiv         Noun                  Proper noun      ← 想要的信号确实在大写页
baker        Noun                  Noun,Proper noun ← 但 baker 是彻头彻尾的普通词
hunter       Noun                  Proper noun      ← hunter 同理
smith        Noun,Verb             Proper noun      ← 侥幸被动词义救下
carpenter    Noun,Verb             Proper noun      ← 同上
cook         Noun,Verb             Proper noun      ← 同上

英语职业类姓氏铺天盖地(baker/hunter/smith/carpenter/cook/taylor/walker/miller/fisher…)。 smith 这类靠动词义兜底逃过,但 baker / hunter 没有动词义 → 会被判成纯专名 → 被 RB 端同一判据滤出学习循环。普通词被当专名,方向相反的灾难,且全程无报错。

3.4 为什么单测全绿却没抓住

looksLikePureProperNoun 的 7 条用例全过,但喂进去的是我假设的 Wiktionary 返回形状 ({'proper noun': []})。真实数据从不长这样。 教训:外部数据源的返回形状必须先抓一次真实响应,不能靠单测里自己编的 fixture 立论。

3.5 线上状态

已 deploy → 发现空转 → 摘掉闸门 → 重新 deploy。当前线上与加闸门之前行为完全一致 (净差异只有 4 处 | null 类型放宽,纯注解无运行时影响)。 坑位注释留在 index.tsword_tags: null 处,写明两个方向为何都不通。

4. 测试与验收

4.1 单测(db/vocab_scope.rs 内联)

  1. JSON1 可用性 + 判据正确性filters_exactly_the_pure_proper_nouns,一条测钉三件事): 内存表塞 7 行标本 —— john{noun}+tag / kyiv{proper noun}+tag / american{noun,adj}+tag / japan{noun,verb}+tag / fox{noun}无tag / run(两列全 NULL) / broken(非法 JSON), 断言过滤掉且仅过滤掉 john + kyiv。 → fox 那行是误杀反向断言run 那行钉住「NULL 不该被过滤」。

  2. 非法 JSON 不致命malformed_json_rows_are_kept_not_fatal):钉住 §2.2 的 CASE 守卫。

  3. 裸表名与别名等价works_without_a_table_alias):难度三条路径里有查询不写别名。

  4. 结构守卫两条(照 push.rs::every_push_query_uses_the_shared_predicate):

    • all_seven_consumption_points_use_the_shared_predicate —— include_str! 三个消费文件, 断言逐文件调用数(query.rs 1 / triage.rs 3 / reading.rs 3,合计 7)。逐文件而非只看 总数,失败信息才能直接指出哪个文件漂了。这个数字要人肉维护:增删消费点时连同 §2.3 的表一起改 —— 数字对不上就是要人停下来想一想,不是让人直接改大。
    • nobody_hand_rolls_the_old_like_pattern —— 非注释行里不得出现 LIKE '%proper_noun%' (即本轮之前 calibration_probe_inner 的写法,它同时有「漏 L-B 的 {proper noun} 形状」 与「误杀 194 个国籍词」两个静默毛病)。注释行按原样跳过 —— triage.rs 的文档注释里 引用了这个旧写法来讲它错在哪,那是要留着的。 ⚠️ 只针对这一个已知反模式,不声称穷举;通用保障是上面那条计数守卫 + code review。

    反向验证已做(2026-08-25):往 reading.rs 的 dict 查询里临时注入 word_tags NOT LIKE '%proper_noun%',守卫如期变红并打印出该行,随后原样还原。 带注释过滤的守卫最容易写成「永远不会红」,不实测一次不能算数。

4.2 难度路径的前后对照(⚠️ 不可跳过)

584 词里 A1/A2 有 65 个,从 graded_total 分母移除的净效应方向不确定—— 去掉 C1/C2 专名会压低超纲率,去掉 A1/A2 专名会抬高它。必须量过再定档,不能假设是下修。

✅ 2026-08-25 步骤 4 结论:方向是下修,且在每个用户档位上都成立。

analyze_article_bodies 抽出可测内核 analyze_article_bodies_inner(抽法同 triage.rs::calibration_probe_inner —— #[tauri::command]State 在测试里构造不出来), 新增 3 条单测钉住机制:专名连分母都不进 / 无专名文本逐值不变 / B1 读者眼里超纲率下降。

方向为什么是下修(原先担心 B2 用户那档会反向,实测不成立): 按 Zipf 权重(1/frequency_rank,近似真实文本出现频次)算 584 个被移除词的档位分布 ——

词频加权占比:A1 6.9% · A2 18.2% · B1 17.8% · B2 32.0% · C1 11.2% · C2 13.9%

设移除集里「对该用户超纲」的占比为 p,文本当前超纲率为 r,则移除后比率下降 ⟺ p > r

用户档p(移除集中超纲的比例)判定
A274.9%下修
B157.1%下修
B225.1%下修
C113.9%下修

coverageTier 的档位阈值是 2% / 5%,即真实文本的 r 通常在个位数百分比 —— 每一档的 p 都远大于 r,所以各档一致下修,幅度随文本的专名密度放大。 ⚠️ 我最初的担忧(「584 词里 A1/A2 占 65 个,可能反向」)错在拿词种计数比 50%, 而正确的比较对象是 p vs 当前超纲率 r。词种计数与词频加权是两回事。

✅ 实机层已做(2026-08-30,步骤 7):下修恰好 1 档,未超一档 ⇒ 2%/5% 阈值不动。

同一篇人名密集文本、改前/改后各编译一次(把 exclude_proper_nouns 临时改成恒真): 阅读页「文章难度」徽标 B2 → B1,同页待发现词 51 → 45。方向与上面 Zipf 加权推算一致。

⚠️ 但对照的不是推荐文章那条路径。 发现页的推荐卡本机拿不到带 body_text 的缓存文章 (fetch_recommended_articles: got 0 articles),coverageNone ⇒ 档位回落到 difficultyTier(纯 CEFR 减法,L-A 不碰),改前/改后都是 5/5「对你刚好」。 ⇒ 真正验到的是 analyze_text_difficulty(#5),不是 analyze_article_bodies(#7)。 细节与「零漂移 ≠ 改动无效」的判读方法见 §4.3 与 §4.3.1。

4.3 实机验收清单

四条已跑完(2026-08-30,dev app + rb-debug MCP)。 下面每条的「机械层已知」 是跑之前的旁证(对真库跑 SQL 得到),「🔬 实机」是这次在 app 里看到的。 两层不能互相顶替:机械层回答「判据选中了哪些词」,实机层回答「用户在界面上看到什么」—— 本轮全部四类症状(§0.2)当初都是在界面上被发现的。 实测结果证明这一层不是形式:它捞出了三件机械层看不见的事(见 §4.3.1)。

🔒 本轮实机的两条方法学前提(下次重跑照做)

  • 本机 dev 库 ≠ plan 里记的「真库」。 本机:vocabulary 18971 行 · 带 CEFR 12364 · 打了 proper_noun 标签 1058 · 判据命中 770 · gradable 11594。 plan 各处记的 584 / 11707 / 871 是另一份快照(本机这份的标签覆盖更宽,多半是缓冲池回填 攒出来的)。⚠️ 两套数不可互相引用 —— 下面凡带具体数字的实测结论,都只对本机这份成立, 方向性结论才是可迁移的那部分

  • discovery_cefr_levels 默认只有 B1,B2 不先把六档全开,A1/A2/C2 的样本 (march A1 · american/chinese A2 · peter C2)压根不进候选池, 于是「页面上没高亮」会被误读成「被判据滤掉了」。这是本轮最容易踩的假阴性, 第一遍就踩了。

  • 载体是「粘贴文本」入口,不是网址栏。 地址栏靠 onKeyDown 的 Enter 提交, rb-debug MCP 只有 rb_click / rb_type送不出按键事件;粘贴文本走同一条 reader + discovery 管线,且内容可控(一段文本同时盖住 8 个典型样本 + 5 个回归词)。

  • [x] 发现模式:打开一篇维基条目,确认 york / texas / john 之类不再被标为待发现词 - 📊 机械层已知:真库 excluded=584 / gradable=11707,john texas 排除集里。 - ⚠️ york 不在——virginia manchester peter todd claire york 8 个典型样本漏 6 个(标签覆盖率问题,钥匙在 L-C)。所以这条按原文写法必然不通过, 跑之前先把预期改成「texas/john 消失、york 仍在」,否则会误判成 L-A 失败。 - 🔬 实机(2026-08-30):通过(按修正后的预期)。判据是 content webview 里的 .rb-discover span,不是肉眼——「看起来没高亮」和「DOM 里真的没这个 span」是两件事。 - 消失 6 个John Texas Virginia Manchester Todd Claire - 仍在 2 个York(b2) Peter(c2) —— 两者 word_tags IS NULL,判据够不着 - 同页改前/改后(把 exclude_proper_nouns 临时改成恒真、重编译): 待发现词 51 → 45,差的正好是上面那 6 个 - 🔴 本条同时修正了上面那行 ⚠️ 的数:本机 dev 库是 8 漏 2,不是「漏 6」。 virginia/manchester/todd/claire 在这份库里proper_noun 标签的 (标签覆盖 1058 vs plan 那份 871)。方向不变、钥匙仍在 L-C,但「漏 6」这个数 别再引用 —— 它是快照相关量,不是结构常量。

  • [x] 快速分级:跳到 C2 档,确认首屏不再是 abba · abby · abigail · ajax… - 📊 机械层已知(随 L-C 那轮,见 CHANGELOG):只部分达成,20 词里人名 12 → 10abba/abby/abigail/alec/alison/alma/amos 清掉了,ajax/austin/barney/barr/barry/ beck/benedict/benny 顶上来——它们带 verb/adj/prep 词性,被判据的第二半放行, 而那一半正是 fox/march 不被误杀的安全网(刻意接受的假阴性)。 - ⇒ 瓶颈已从标签覆盖率转移到判据形状,继续补标无效,对症的是 §6 的 L-D。 - 🔬 实机(2026-08-30):部分达成,与上面的结论一致。 路径 = 词汇与笔记 → 已认识 → 标记熟词 → 点 C2 段 → 确认跳档。 首卡 acetate、第 2 张 acme、第 3 张 ajax —— 而 ajax 的卡面直接印着prep. Nearby, over there判据第二半放行的机制在界面上肉眼可见, 不必回去读 SQL 才知道它为什么漏。 同库同用户的首 20(改前/改后各跑一次): - 改前:abba abby abigail acetate acme ajax alba alec alison alma alprazolam amos angelina angus annals anon apoptosis assay astrophysics austin → 人名 12 - 改后:acetate acme ajax alba alprazolam annals anon apoptosis assay astrophysics austin ba balm bangbus barbados barney barr barry bate batterie → 人名 6 (+ barbados 地名 ⇒ 专名共 7) ⚠️ 本机是 12 → 6,plan 上面记的是 12 → 10 —— 同样是快照差异,别互相引用。 定性结论一字不改:首屏仍有 6 个人名,继续补标无效,对症的是 L-D。

  • [x] 难度徽标:一篇人名密集的新闻,改前/改后档位对照(= §4.2 末尾那条) - 📊 机械层已知:§4.2 的 Zipf 加权推算给出方向——移除集中「对该用户超纲」的占比 A2/B1/B2/C1 = 74.9/57.1/25.1/13.9%,p 远大于当前超纲率 r,故各档一致下修。 - ⚠️ 推算不是对照。若实机整体漂移超过一档,回看 difficulty.ts::coverageTier 的 2%/5% 阈值——但阈值调整是独立决策,不要在本轮顺手改。 - 🔬 实机(2026-08-30):下修恰好 1 档,未超一档 ⇒ 2%/5% 阈值本轮不动。 同一篇人名密集文本,改前/改后各编译一次:B2「略有挑战」→ B1「略有挑战」。 - 🔴 但这条先炸出一个「机械层对了、界面没跟上」的实例,值得单独记: 发现页那 5 张推荐卡的档位标签根本不走 coverage 路径,因此改前/改后两次实测 都是 5/5「对你刚好」,零漂移。零漂移不是「改动无效」,是这条路径在这台机器上 压根没被走到fetch_recommended_articles 本机拿到 0 篇带 body 的缓存文章 (日志原话 got 0 articles from Supabase),而 analyze_article_bodiescoverage 只在 has_body 时才是 Some,于是 HomePage 回落到 difficultyTier —— 那是纯 CEFR 减法,L-A 完全不碰。 ⇒ 改造点 #7(analyze_article_bodiesdict)本轮实机未被覆盖, 真正验到的是改造点 #5(analyze_text_difficulty → 阅读页「文章难度」徽标)。 下次要验 #7,得先让本地有带 body_text 的缓存推荐文章。

  • [x] 回归(误杀反向验收)fox / march / polish / american / chinese仍然可被发现和高亮 - 📊 机械层已知:误杀 0。这几个词全部落在判据外不是运气——命中集清一色 cefr_inferred=1,凡 Oxford/CEFR-J 权威分级过的词天然进不去,是判据自带的安全网。 - ⚠️ 机械层只证明了「判据没选中它们」,没证明它们在页面上真的还被高亮 (高亮链路另有 7 个接触面)。这正是本条非跑不可的理由。 - 🔬 实机(2026-08-30):4/5 通过,fox 在界面上仍被误杀。 - ✅ 带 .rb-discovermarch/March(a1) · polish/Polish(b2) · American(a2) · Chinese(a2) —— 大小写两种形态都在 - ❌ fox 不带 —— 同一句里 red Park March 全高亮,就它没有 - 归因(逐词跑生产查询的两个子句):fox = passes-predicate + blocked-by-reference_words - 🔑 决定性佐证:把判据改成恒真重编译之后,fox 依然不高亮 ⇒ 这不是 L-A 引入的回归,是 §0.3 那条老误杀(source='system'、用户删不掉), §2.4 明写本轮不动、归 L-D。 - 🔴 所以上面那句「误杀 0」必须加作用域判据层误杀 0 成立; 用户在页面上看到的误杀不是 0。 机械层与实机层在这条上给出了相反的用户结论, 而两边都没算错 —— 这正是 §4.3 那句 ⚠️ 预判的形状。 - ⏳ 高亮腿的第二个接触面没验:已保存词的 CEFR 高亮走 get_learning_words*,那两个查询带同一条 reference_words 子句 (§2.3.1 的 📌 已记),故 fox 存进生词本后同样不会高亮。 本轮没有实测它 —— 要测就得往生产 Supabase 写一个词,没做。

  • L-B:/edge-deploy 后双击一个池外专名(如 Zelensky),确认 Supabase 那行 word_tags = ["proper_noun"] 且本地 vocabulary 同步拿到该值 - ⛔ 作废,不必跑。L-B 实施中被推翻(Wiktionary 小写页是另一个词条,闸门一次都 不会触发),已 deploy → 摘掉 → 重新 deploy,线上行为回到原状(§3)。 这条不可能通过,也不该通过 —— 留着它空勾会让人以为还欠一次部署验证。

4.3.1 实机层捞出的三件机械层看不见的事(2026-08-30)

这一层不是走形式 —— 下面三条没有一条能从 SQL 里推出来

#发现为什么机械层看不见落点
fox 在页面上仍被误杀,尽管判据放行了它机械层只问「判据选中谁」;实际候选池是判据 ∩ 黑名单两张网串联,而黑名单不在判据的作用域里L-D(已在 §6,本条给它加了实机证据)
改造点 #7 本轮实机未被覆盖 —— 推荐卡的档位回落到 difficultyTier,因为本机没有带 body_text 的缓存文章SQL 能证明 dict 少了 770 个词,证明不了那个 dict 有没有被调用到见 §4.3 难度徽标条;下次重跑的前置条件
bangbus(成人网站品牌)出现在 C2 首屏 20 词里 —— §0.5 症状 4 的第一个实机实例它不是专名判据的目标,任何围绕 proper_noun 的查询都不会把它显出来;只有真的翻卡片才撞得见backlog + L-C(收词闸门) → 🔴 落点 2026-08-31 订正为 v29 prune(它不在资产里,是存量库残留),见 §4.5「② 真因与 v29 prune」

🔒 两条对下次重跑的纪律(都是这次踩出来的):

  • 数字全部是快照相关量。 584/871/11707、「8 漏 6」、「12 → 10」——本机分别测到 770/1058/11594、「8 漏 2」、「12 → 6」。引用方向,别引用数字;要数字就当场重测。
  • 零漂移要先排除「路径没被走到」再解释成「改动无效」。 ②就是这个形状, 而它和「改动无效」在界面上长得一模一样

4.4 流程链

/arch-check/build-check(含 cargo test)→ 实机 → /rb-code-review/doc-sync-check

4.5 已知未修 / 有意接受(2026-08-30 实机验收后)

带「什么条件下重新处理」,形同 ADR。⚠️ 这张表本身也会腐烂:下一轮重跑时先核一遍, 一条若其实已被修,留着它比没有它更糟。

#事实为什么不在本轮修重开条件
fox 在页面上仍不被高亮/发现 —— ✅ 2026-08-31 L-D 已修并实机验收,见下方「①④ 结案」已结案
bangbus 在快速分级 C2 首屏 20 词里 —— 🔴 归因错了,2026-08-31 查清并修:它不在资产里(pipeline 早删了),是存量运行库的老种子残留;对症的不是收词闸门而是 prune。见下方「② 真因与 v29 prune」数据层已修;C2 档整体成色是另一件事,归 §6 L-C 第 4 条
改造点 #7 实机未被覆盖 —— ✅ 2026-08-31 已补跑,见下方「③ 补跑结果」已结案

③ 的真因(2026-08-31 查清,与本机配置无关):不是「本机没缓存」这种状态问题, 是赶上了两批推荐之间的空窗fetch_recommended_articlesexpires_at=gt.now() 过滤, 且DELETE FROM recommended_articles 再插 —— 上游返回 0 条就等于把本地整张表清成 0 行 (所以那天本地不是「有旧缓存但缺 body」,是空表)。 2026-08-30 04:0x UTC 跑验收时上游无未过期行;当天 22:00:46 UTC 生成了新一批, 比验收晚 18 小时。现在上游 26 行全未过期(有效期到 09-06)、24 行带 body_text, app 那条查询(limit 20)会拿到 20 条 / 18 条带 body,正文 median 4409 字符 —— 够跑覆盖直方图。 ⚠️ 所以下次重跑前要查的是上游的过期窗口,不是本地配置; 判据一条 SQL:recommended_articlesexpires_at > now()body_text IS NOT NULL 的行数 > 0。 📌 补跑前还被另一件事挡过一次:dev app 的登录态过期会停在登录页 ⇒ HomePage 不挂载 ⇒ 那条 fetch 根本不发。fetch_recommended_articles 自身只用 anon key、不需要登录, 登录只是 UI 路径上的闸 —— 补跑前先确保 app 是登录态。

③ 补跑结果(2026-08-31):路径确认已被走到;档位分布改前/改后完全相同。

本地缓存实测 20 行 / 18 行带 body_text(684–57534 字符),与从上游预测的逐字一致。

  • 路径确实活了(这一点必须先证,否则又会把「饱和」误读成「路径没走到」——正是上一轮那个坑): 用户等级 A1,缓存里 A2 文章 3 篇。若仍是 difficultyTier 回落, A2 − A1 = 1 ⇒ 三篇都该是「对你刚好」。实测两篇带 body 的 A2 文章都显示「略有挑战」, 只有那篇没有 body 的 A2 仍是「对你刚好」⇒ coverageTier 在驱动,回落只发生在无 body 那两篇。
  • 改前/改后档位分布:轻松 0 / 刚好 1 / 略有挑战 19,两侧一字不差(20 张全展开后计数)。 「改前」= 把 exclude_proper_nouns 改成恒真重编译,并用快速分级 C2 首卡 = abba (改后是 acetate)做二值探针确认补丁真的在跑 —— 没有这一步,「两侧相同」无法与 「补丁没生效」区分开。

🔑 结论:#7 的改动在直方图上是真的,但在这批数据上够不到档位阈值。 A1 用户读 A2–C1 新闻,超纲率远高于 coverageTier 的 5% 上界;移除 770 个专名 会让比值下降(方向与 §4.2 的 Zipf 推算一致),但下降后仍远在 5% 以上 ⇒ 档位不动。 ⚠️ 这修正了 §4.2「各档一致下修」的读法:那句说的是比值,不是档位。 档位只在比值贴近 2%/5% 时才会跟着动;用户等级与文章难度差得越远,档位越饱和、 对本改动越不敏感。⇒ 想在档位上看见 #7 的效果,得挑一个等级与文章难度接近的用户, 而不是加大文本的专名密度。

| ④ | 已保存词的 CEFR 高亮腿没实测 —— ✅ 2026-08-31 已修并实机验收(确实存了一个词),见下方「①④ 结案」 | — | 已结案 |

A/B 类产品出口实机验收(2026-08-31,裁定见 §6)

🔧 先补了工具:查词弹窗此前没有任何自动化入口(三条此路不通都实测走过, 见 src-tauri/src/debug/http.rs::dblclick 的头注释)。新增 dev-only 的 /debug/dblclick(按词定位 → 设选区 → 发 dblclick)+ MCP 侧 rb_dblclick这是本轮真正的杠杆 —— §4.3.1 说「机械层与实机层不可互相替代」,而此前最需要 实机层的弹窗恰恰没有工具支撑,只能请人手动点。

探针文本九词矩阵,每条都先断言弹窗里的词换成了目标词(不断言这一点会读到 上一次的残留 = 假绿,第一轮正是这么翻的车):

徽标折叠覆盖的是
John / Texas专有名词判据腿 + 止损
Kyiv / Tuesday这里可能是专名页面腿(OOV / 同形碰撞)
March / Space(句首大写)🔴 反向断言:句首不是信号
fox🔴 反向断言:它不是专名
march(小写)/ delegation控制组

9/9 通过。 交互两条腿同样实测:折叠开关三态正确(折叠 → 展开且文案变 「收起词典义项」→ 收回);「这是什么?」在 Kyiv 上 8 秒后返回 「乌克兰首都,位于该国中部偏北,第聂伯河畔。」 —— 正是 §6 立这条时设想的那句话, 且按钮用后即撤(一次查词只付一次费)。

📌 被工具绊过一次,记下来elementFromPoint(cx,cy) 会命中盖在词上面的 上一个弹窗isInsideRbUi(e.target) 直接 return,双击静默落空而弹窗里 还留着上一个词。表现是「功能没反应」,不是报错。派发目标必须取文本节点的父元素。

①④ 结案(2026-08-31 · L-D 过滤器退役,裁定见 §2.4)

造了一篇受控探针文本(粘贴文本流),一次同时拿到正例、控制组和反向断言 —— 反向断言是这次的关键:没有它,「黑名单退役」和「把闸门整个拆了」在证据上无法区分。

改后 class判定
fox ×7rb-discover rb-discover-b2 → 存入生词本后 rb-highlight rb-highlight-b2✅ ① 与 ④ 各一条腿
Russianrb-discover rb-discover-a2✅ 15 个带 adj 国籍词的代表,原被黑名单挡
Americanrb-discover rb-discover-a2控制组(从来不在名单里,改前改后都亮)
Texas无 span🔴 反向断言vocab_scope 判据仍在工作
Japanrb-discover rb-discover-b2已知代价(带 verb ⇒ §2.1「刻意假阴性」),对症是 L-C

④ 的具体做法:fox 出现在发现面板「值得先看」→ 勾选 → 「添加 1 个单词到生词本」→ learning_entries 落行 → reload 后才转成 rb-highlight-b2。 📌 保存后不 reload 看不到变化rb-discover 停在原位)—— 这是既有行为、与本改动无关, 但下次验这条腿的人会被它绊一下,故记在这里。

⚠️ 副作用(有意接受):fox 现在真的在生词本里,且会随 autoSync 推到生产 Supabase。 删掉它只会留一个墓碑,不如留着 —— 它本来就是个正经 B2 词。探针文本也留作证据 (reading_pages 一行 + 一份快照)。

② 真因与 v29 prune(2026-08-31):bangbus 从来不是「收词闸门没挡住」,是「删除决定没送达存量用户」。

判据来自差集,不是眼判:本机 dev 运行库 18,972 行 vs 资产 18,916 行,多出的 56 行 = 53 个 source='preinstalled' 残留 + 3 个 source='backfilled' 回填行(kyiv 是其一)。 那 53 个与 round30 的 delete_words 载荷集合精确相等(两向差集皆空)。 除此之外两库逐行零差异primary_cefr_level / cefr_source / word_tags / pos_definitions 全等,「资产有而运行库没有」= 0)⇒ upsert 的 DO UPDATE 列清单是够的, 不存在第二处「治理内容进不来」。

机制:seed 合并是 ON CONFLICT DO UPDATE只增改不删。所以「资产删干净了」与 「用户库删干净了」是两件事,而资产 SHA、体积闸、单测全都只看得见前者。 补轮当时给 delete_words_gate1(9 词)补了 v27 prune 且确实生效(运行库里这 9 个 = 0 行), 主载荷那 53 个漏了

落地helpers.rs 加 v29 prune(清单是 round30 载荷的镜像,沿 v18/v23/v27 先例带 NOT EXISTS(learning_entries) 自守),版本锚 rb-18916-v28-prune53-2026-08-31 —— 资产一个字节没动(仍是 v28 那份),bump 的唯一目的是让存量用户跑一次 prune。 守卫三条(各自反向注入实证):G1 prune 清单 ⊇ 载荷的 delete_words + delete_words_gate1 · G2 prune 清单 ∩ 资产 = ∅ · SQL 必须带生词本自守。

真机验收pnpm tauri dev 在跑,改 Rust 即重编译重启,seed 当场跑掉): 18,972 → 18,920kyiv 未动、gate1 仍为 0、孤儿生词本条目 0、C2 池 310 → 290。

🔑 收益的诚实边界:这修的是正确性(已裁定未送达)+ 删掉 8 个露骨词,不是 「让 C2 变干净」。C2 首屏换掉 bangbus/batterie,换进来的是 beck/benedict —— 还是人名; C1/B2 首屏一字不变。C2 那片人名在资产里,只能靠补标 + 判据或关掉 C2(§6 L-C 第 4 条)。

⚠️ 当场撞到的机制风险,已写进 helpers.rs 注释:prune 是「一次性、按版本字符串」的。 那次重启恰好落在一次反向注入的窗口里(清单临时少了 bangbus)⇒ 本机 52/53 被删、 bangbus 永久留下,把清单改回来也没用(版本已记下、seed 不再跑)。 发布后才发现清单漏词 = 必须发一个新版本串。 dev 机的解法是 DELETE FROM settings WHERE key='vocabulary_seed_version' 让下次启动重跑。


5. 风险与回滚

风险缓解
JSON1 在某平台不可用(Windows 构建)§4.1 的单测在 CI 上跑,Windows job 会暴露;真出问题回落物化列方案
难度档位整体漂移§4.2 前后对照;阈值调整是独立决策,不要顺手改
误杀合法学习词判据自带 cefr_inferred=1 安全网 + §4.3 反向验收清单
与并行会话冲突本轮只碰 src-tauri/src/{db,commands} + 一个 Edge Function,与 admin/ 不相交;仍按 §10.3 每完成一个可编译小步就提交

回滚:L-A 是纯查询谓词,git revert 即可,无迁移、无数据变更。 L-B 已写入的 word_tags加法式标注,回滚 Edge Function 后存量标注无害(正是我们要的值)。


6. 后续阶段(不在本轮)

L-C 预装库根治 —— 需 reseed,跨端 byte-equal(红线 #10),走 /vocab-reseed

  1. kaikki_parser 不再静默丢弃 name POS,改为记录:见过 name POS → 打 proper_noun 标签。 最强信号且免费 —— 数据本来就在 dump 里,现在是白扔。
  2. 收词阶段落实 exclude_proper_nounsconfig.yaml:72 声明为 true # Move to reference_words, 但 wordlist_builder.dart::_shouldExclude 从没读过这个字段 —— 死开关。 判据用「有 name POS 且 cefr_inferred=1」,不是「首字母大写」。
  3. 同一道收词闸门顺带清 Google 词频源的露骨/垃圾词(症状 4)。
  4. 清完后才谈解开 CALIBRATION_MAX_LEVEL

🔴 2026-08-31 再核查:第 4 条的形状不对,而且 ①②③ 少了一条「怎么送达存量用户」

(a) 「清完就能解封 C2」是错的 —— C2 档权威分级 = 0 个。 库里 12,172 个单词分两半来路: 7,002 权威分级(Oxford 5000 4,643 + CEFR-J 2,495)与 5,170 词频反推 (Google Web Trillion top-20k),而 C2 那 519 个词全部落在后一半cefr_source 分布: llm 477 / freq 39 / fallback 3,cefr_inferred 无一为 0)。噪声率实测随档位单调爆炸: B2 ~10% · C1 ~30% · C2 ~73%(各档均匀抽 30 词眼判,噪声 = 人名/品牌/截断词/露骨词)。 C2 池按字母序前 20 是 acetate acme ajax alba alprazolam annals anon apoptosis assay astrophysics austin ba balm barbados barney barr barry bate beck benedict —— 清掉几个露骨词 之后剩下的还是这批人名

CALIBRATION_MAX_LEVEL='B2' 的真实含义不是「池子脏」,是这一档在本库里没有可信来源。 解封 C2 需要的是给 C2 引进权威词源(或人工裁定一批 C2 种子词),不是清理。 「C2 要不要继续参与主动推送(快速分级单卡流)」是一个独立的产品决策 —— 它今天一键可达useTriageStore::jumpToLevel + CEFR 分档选择器),不需要翻完前面一万张卡。

(b) 反过来,「收紧运行时判据」这条路已被否(详见 §0.7 第 5 条):A1–B2 的词频反推词里 七八成是 cellphone / clipboard / enzyme / reboot / calorie 这类权威表覆盖不到的现代高频词, 砍掉是净损失。⇒ 症状 4 的对症位置只能是收词阶段(本节 ②③),运行时判据不动。

(c) 缺一条:删除决定必须送达存量用户。 round30 从资产里删了 53 个词,存量用户的运行库 一行没少(seed 合并是 UPSERT,只增改不删)。这个缺陷静默了整整一轮,直到 2026-08-31 拿运行库跟资产对差集才发现(详见 §4.5「② 真因与 v29 prune」)。 🔒 今后任何一轮 pipeline 删词都必须同时在 helpers.rs 加一条 prune 并 bump 版本锚 —— 守卫 helpers.rs::seed_prune_guards::prune_covers_round30_delete_payload 已把这条钉住: 下一轮只改 pipeline 不加 prune,测试立刻红。

⚠️ 2026-08-26 核查:L-C 的成本与路径都要重估,别照上面四条直接开工

① 「整库 reseed」的真实成本比记的更高,但可能根本不需要整库重跑。 实测 pipeline 现状:

资产状态影响
data/kaikki_english.jsonl.gz完好(symlink → ~/Downloads/kaikki.org-dictionary-English.jsonl.gz,457MB,实测可解压)主数据源在位,整库重跑不需要重新下载
data/generated_examples.json (5MB) · differentiation.json (7MB) · etymology.json (5MB)✅ 在仓,确定性缓存这三块 LLM 产物不用重付
中文翻译无缓存文件整库重跑要为 ~12k 词重付 LLM(即 backlog 记的 $5-15)
output/中间产物,可由现有输入重生成

🔴 2026-08-26 更正:本表上一版把 kaikki dump 写成「61 字节截断占位」,是错的。 那 61 字节是 symlink 自身的大小(= 目标路径字符串长度),不是文件大小 —— README §「Kaikki dump 纳管」写明该文件按 gitignore + symlink 纳管、基线 ~457MB。 教训:ls -la 的 size 列对 symlink 没有意义,判据是权限位开头的 lreadlink。 当时跑过的 file 其实如实报出了 457MB gzip 的真实信息,却被误读成「尾部垃圾」反过来 佐证了错误结论 —— 证据在手边被读反,比没有证据更危险。

② 关键判断:本轮要的很可能是「补标」而不是「重收词」。 原文四条里的 ①② 都指向改 wordlist / 改 Kaikki 解析 → 必然整库重跑。但回头看三条未解决项真正需要的是什么:

  • 校准 B2 解封 · 发现/难度里的人名 → 只要 virginia/manchester/peter 这批被打上 proper_noun 标签,L-A 已落地的判据立刻就能滤掉它们。不需要动收词、不需要 Kaikki、 不需要 LLM。
  • 露骨/垃圾词(milf/hentai/trackback不是专名,标 proper_noun 不对,需要另立 判据或直接删行——那是个小批量、可枚举、可人工复核的动作。

先评估「确定性就地补标」这条路:先例是 bin/rebake_lemma_tables.dart——红线 #10 明令的唯一就地改库豁免,靠的是「确定性重写整张表 + 带守卫(vocabulary 行数必须不变 + 4 表白名单)+ 跑完过 scripts/check-vocab-asset.sh 那道闸」(2026-08-29 前那一步是 sync-rvh-vocabulary.sh 的「整包同步给 RVH」,symlink 之后已无同步动作,见红线 #10)。补标工具可照它立规矩, 但守卫要重设(补标改的正是 word_tags 列,"一行未动"那条守卫不适用)。

③ 🔴 一个反直觉的陷阱:把词从预装库里「删掉」会让查词体验变差。 删掉 = 该词变 OOV → 双击时走 backfill_word → 缓冲池 → Wiktionary 小写页 → 拿回「kyiv = chicken Kiev 的异体」这类垃圾(§3.2 已实测)。也就是说删词并不能消除 粗俗释义,只是把它从预装库挪到缓冲池保留词条 + 打标比删除更安全。 露骨词是例外(那些确实该消失,且没有"查得到"的价值)。

📌 2026-08-31 更新:L-D 落地后这句仍然成立,只是理由缩了一半。 弹窗现在会给 OOV 的 大写词标「这里可能是专名」+ 「这是什么?」(页面腿够得到 Kyiv),所以删词后的体验 不再是纯粹的垃圾释义;但判据腿(is_pure_proper_noun)只认库内行, 删掉即失去它,而且缓冲池那条垃圾释义仍会被拿回来展示。⇒ 仍然保留 + 打标

L-D 产品出口 —— 把缺陷变成功能

  • A 类纯专名(Kyiv / Zelensky / TomTom):用户要的是「乌克兰首都」而不是词典释义。 resolve-context 的 background 已具备这个能力(prompt 里 named entities 明确列为处理对象), 缺的只是一个词级入口。竞品(Kindle / 浏览器划词)在专名上给的同样是无用的词典释义。

    2026-08-31 落地:拆成「止损」+「补值」两半,成本截然不同。

    做法每次查词成本
    止损(确定性)判据命中 → 弹窗把误导词典义折叠、标「专有名词」0
    补值(LLM)「这是什么?」按钮,点了才调 resolve_context0(点击才付)

    为什么是折叠不是删除:现查三例 —— John 首义项「A prostitute's client.」、 Texas「The topmost cabin deck on a steamboat.」、TomTom「Alternative form of tom-tom.」 —— 这些确实是词典里的义项,不是错数据。硬删等于替用户否决,正是 fox 那条教训 反对的做法。默认不展示解决「有害」,可展开解决「别替我做主」。

    为什么按需不是自动:自动 = 每次双击专名一次 LLM 调用,而新闻正文专名密度极高、 误触双击也照付;按需之后离线/未登录时这条腿只是不可用,止损那半截照样成立

    刻意复用句级 resolve_context、不新开 Edge Function:新函数要走手工触发/edge-deploy 才生效,功能会卡在「代码合了但线上没有」。代价是它的 anti-hallucination 铁律可能不返回用户点的那个词 ⇒ 实现里优先挑匹配项、挑不到就给整句背景、都没有才说没有。

    两条互补的腿,缺一条漏一半:判据腿(vocab_scope::is_pure_proper_nounDictResult.is_proper_noun)只认库内行,实测 john/texas=1, march/space/fox/tuesday=0;而 Kyiv/Zelensky 根本不在预装库里, 只有页面腿(大写信号)够得到。渲染侧三条路径都接了:正常 entries · OOV not-found 分支 · backfill 回填后(§3.2 实测拿回垃圾释义的那条路)。

  • reference_words 的 193 行带 chinese_meaningiran → 伊朗)—— 作为过滤器该退役,作为专名中文兜底数据有价值,别直接删表。

  • B 类同形碰撞(March / Polish / Bill)只能在 token 级判: word-extract.js::getSelectedWord(与 getWordAtPoint)第一步就 toLowerCase(), 大小写信号在此丢失。保留 surface 后「非句首 + 首字母大写」可作降权信号。 🔴 2026-08-31 收窄:只做「查词时提示」,不做「该处不高亮」。 原文把后者也列进来了,但实测它会抑制中位 12.1% 的高亮实例,其中只有 9.7% 落在 判据已认得的专名上,其余大量误伤 Title Case 里的普通学习词 —— 是在 token 级重演 fox 那个错误(完整数据 + 复现口径见 §0.7 第 4 条)。 ⇒ B 类的可做部分 = 非破坏性的提示:查词弹窗上标一句「这里可能是专名」, 信息给到用户、判断权留给用户,不静默改变页面。 ✅ 2026-08-31 落地getSelectedWord / getWordAtPoint 改为返回 { word, surface }word 那一路语义一个字节没变,仍是喂 lookup_word / save_word 的小写键 —— 让 surface 流进 save_word 会直接破坏红线 #9 的键空间); 判据函数 looksLikeProperNounHere(surface, sentence)。 🔒 存疑那支只提示、不折叠(判据腿确信才折叠)—— 拿启发式去折叠正确释义 就是重演 fox。守卫 word-extract.test.js 6 例,含三组反向断言(句首 / 带前导 引号的句首 / 小写)+「同一 surface 在句首与句中给出不同答案」;首条测试把用途边界 钉在代码里。为此把 vitest 的 include 扩到 content-script 的纯函数。 ⚠️ highlightTextNodematch[0] / match.index 都在手,这条「技术上做得到」—— 所以拦住它的必须是这份实测,不是可行性。 下一个人看到那两个变量会觉得很顺手。

顺带要修(小、独立、不阻塞)

  • reference_words.rs::add_reference_wordto_lowercase() 而非 lemmatizer::normalize() —— 2026-08-27 已改走 word_key::canonical_wordredline9_guard 当场抓到的那处); 2026-08-31 该文件随命令下线整个删除。
  • 7 个 reference_words 命令有 6 个零调用方:补管理 UI,或删 —— (2026-08-31, §2.4 裁定的一部分)。第 7 个 get_reference_word_set 的唯一调用方是 content-script 的 黑名单腿,随之一起消失;db/word_list.rs 因此全空,一并删除。
  • docs/vocabulary-domain-knowledge.md §7.2 描述了一个不存在的收词过滤行为 —— ✅ 2026-08-31 已订正为「exclude_proper_nouns: true 是死开关,专名照常进 vocabulary, 运行时由 vocab_scope.rs 挡在可学/计难度之外」。

7. 实施顺序

  • [x] 步骤 1db/vocab_scope.rs + §4.1 的 1-3 条单测 → cargo test 3/3 绿 (结构守卫是第 4 条,归步骤 5)。JSON1 已实证,§2.2 方案确认可行,无需回落物化列。
  • [x] 步骤 2query.rs::get_discoverable_wordscargo check 绿 → 提交 (原计划此步接 3 处,实施时砍到 1 处,见 §2.3.1)
  • [x] 步骤 3 接 3 个 triage 点(含替换 calibration 的旧 LIKE)→ cargo test 10/10 绿 → 提交 (途中修了判据对 pos_definitions IS NULL 的空档,见 §2.1.1)
  • [x] 步骤 4 接 3 个「计难度」点 + 抽 analyze_article_bodies_inner + 3 条覆盖单测 → cargo test --lib 203/203 全绿 → 提交。方向已量:各档一致下修(§4.2)
  • [x] 步骤 5 结构守卫两条(逐文件计数 1/3/3 + 旧 LIKE 反模式)+ 注入式反向验证 → cargo test --lib 205/205 全绿 → 提交。L-A 的 7 个改造点至此全部完成。
  • [x] 步骤 6 L-B:改 → deploy → 实测闸门空转 → 摘掉 → 重新 deploy → 冒烟 401 ✅ 结论:L-B 推翻,并入 L-D(§3)。留下的净改动 = 4 处类型放宽(deno check 从 6 错变干净)+ 「此路不通」坑位注释。
  • [x] 步骤 7 /arch-check ✅(S28 命中已处置:🔒 纪律权威移到代码 + 守卫测试)→ /build-check ✅(cargo test 206/206 + pnpm build)→ /rb-code-review 捞出 150× 热路径 回归 → v34 部分索引修复真库只读核对 ✅(excluded=584 / gradable=11707, john/texas 滤除、fox/march/american/chinese/polish 保留) → /doc-sync-check ✅ ✅ 实机验收已补(2026-08-30)。此前这里一度写成「实机验收 ✅」,但括号里那些是 对真库跑 SQL 查出来的数,不是在 app 里看到的(2026-08-27 与用户核实后更正为未做)。 本次在 dev app + rb-debug MCP 上真跑了 §4.3 的四条(第五条 L-B 已作废)+ §4.2 的 改前/改后对照 —— 「改前」是把 exclude_proper_nouns 临时改成恒真、重编译测完再 git checkout 还原,两边同一篇文本、同一个账号。结果 = 3 条通过 + 1 条 4/5。 ⚠️ 这两层验收不可互相替代,而且这次被实测证实(§4.3.1 三条): 其一与机械层给出相反的用户结论(判据放行 fox,页面上仍不高亮); 其二是机械层原理上看不到的(SQL 证明不了那个 dict 有没有被调用到)。 📌 本计划仍不归档,但理由换了:不再是验收欠账,而是它是 L-C / L-D 的唯一真相源 (见顶部 🔒)。§4.5 四条已知未修如今只剩 ②(露骨/垃圾词,对症的是 L-C)。
  • [x] 步骤 8 CHANGELOG + database-schema.md §10(v34 行 + 链头 →v35)+ CLAUDE.md §2 (vocab_scope.rs 唯一判据条目)+ backlog 指针
  • [x] 步骤 9(L-D 的过滤器部分,2026-08-31) 决策先行(§2.4 三方案对照 + 真库实测)→ 拆 6 处过滤(Rust 4 + content-script 2)→ 下线 7 命令 + db/word_list.rs → 三处守卫跟改(redline9_guard 6→5 · key_space_guard 去一档 · 新增退役守卫)→ cargo test --lib 239/239 + pnpm build ✅ → 实机验收 ①④(§4.5「①④ 结案」, 含 Texas 反向断言)→ 文档同步 5 份。表 / seed 迁移 v3 / 193 行数据一个字节没动。
  • [x] 步骤 10(L-D 的产品出口部分,2026-08-31) A 类拆两半:判据腿 (vocab_scope::is_pure_proper_nounDictResult.is_proper_noun)+ 页面腿 (looksLikeProperNounHere(surface, sentence))→ 弹窗折叠误导词典义 + 标「专有名词」 (成本 0)+ 「这是什么?」按需调 resolve_context(点了才付);B 类收窄为只提示 (硬过滤被 §0.7 第 4 条实测推翻)。getSelectedWord/getWordAtPoint 改回 { word, surface }word 那一路一个字节没变(surface 流进 save_word 会破红线 #9)。 守卫 word-extract.test.js 6 例(含 3 组反向断言),vitest include 扩到 content-script 纯函数。 🔧 途中补了 dev-only 的 /debug/dblclick + MCP rb_dblclick —— 查词弹窗此前没有任何 自动化入口,这是本轮真正的杠杆。实机九词矩阵 9/9(含 March/Space/fox 三条反向断言), 交互两条腿同测(折叠三态 + Kyiv 拿到「乌克兰首都…」)。落账见 §6、§4.5 的 A/B 类验收段与 CHANGELOG。 ⚠️ 这一步不解校准闸门 2 —— 见顶部状态行的告警。

步骤 2-5 之间没有硬依赖,但步骤 1 必须先做完 —— JSON1 若不可用,整个 §2.2 的方案要换。