主题
跨端 cloze 语境池验证台账(2026-08)
物理位置:
docs/verification/cross-end-cloze-pool-2026-08.md(仓根相对;2026-08-28 合仓后只有一个仓) 这是两端会话共用的接力棒:两个会话读写同一个文件(2026-08-28 合仓后它就在本仓,无需绝对路径),不要另起第二份。 RVH 侧前置回执:docs/cross-end/35-rvh-ocr-cloze-context-confirmation.md、docs/cross-end/39-rvh-tombstone-synced-at-confirmation.md(T4-1 已把 RVH 侧那半归并进本仓,不再有第二个docs/cross-end/) 起始:2026-08-27 · 已登记为跨端编号 41(docs/cross-end/README.md全表)——编号是两仓共用的一条序列,RVH 会话按该号引用本文件即可。
§0 目的与待验清单
为什么有这份文件
docs/cross-end/23-rb-ocr-cloze-context-decision.md §7 与 docs/cross-end/16-rb-cloze-context-sync-handoff.md §6 各留了一组「跨端实测」, 一直没跑过真·双端(两端同时在场、真机、真 sync)。RVH 侧 doc 35 / 39 落地并做完 单端真机验证之后,欠的就只剩这几条。
三项待验
| # | 待验什么 | 出处 | 需要谁在场 | 状态 |
|---|---|---|---|---|
| A | 池收敛 + 淘汰序:RB 攒满 5 → RVH 拍页新增 1 → 各 sync 两轮 → 双端收敛到同一份 5 条,且被淘汰的是真正最旧那条 | doc 23 §7.1 | RB + RVH | 🔄 进行中 —— 第 1 棒 ✅(§2 1-c)· 第 2 棒 ✅(RVH 入池 + 淘汰序判据全中,见 §2 2-e)· 第 3 棒 ✅:A3 收敛 ✅ · A4 淘汰序 5/5 ✅ · ⚠️ C3 由另一条证据结案、A4 未鉴别它(§2 3-b/3-c/3-f) |
| B | C1 静默占位:注入一条 sentence 不含 surface 的行 → sync → RB 侧语境切换器 n/N 的 N 少 1、而池内活跃行是 5 | doc 23 §7.2 | 用户在 Supabase 控制台手工 INSERT + RB 观察 | ✅ 已复现(2026-08-28),见 §2「B 轨」 |
| C | RVH 删扫描页 → cloze 行仍在 | doc 23 §7.3 | RVH 单端 | 🚫 本轮不排期(见下) |
doc 16 §6 的第二条(「双设备各攒 5 条后互相 pull,确认 reconcile_cloze_pool 收敛回 ≤5 而非 10」) 已由 RVH 于 2026-08-27 验完,本台账不重复。A 是它的加强版:不只问「是不是 5 条」, 还问「被淘汰的是不是真正最旧那条」——前者只验数量守恒,后者才验排序键语义。
B 为什么只能手工注入:两端采集侧现在都拦得住这种行(RB insert_cloze_context 的守卫 + RVH OcrClozeGate),所以造不出来——只能绕过采集侧,从 Supabase 控制台直接 INSERT, 再让 RB pull 下来(🔒 pull 侧无条件插入、不校验质量,这正是 B 要复现的那件事)。
C 为什么本轮不跑:RVH 的 reading_pages 删除是硬删 + 依赖 FK CASCADE (reading_page_datasource.dart:131 / :580,两条 UI 均可达)⇒ 页删除不上行, 且 CASCADE 会物理抹掉 word_page_links 的墓碑。现在跑 C,等于把这个坏行为记成 「既有行为」——doc 23 §3.1 第 1 条说「可以接受」是在软删前提下说的(红线 #6a)。 RVH 侧已立项先修再验。
⚠️ A 的原判据对 C3 没有鉴别力 —— 本台账补一步
doc 23 §7.1 写「这一步同时验 C3:若时区错,被淘汰的会恒定是 RB 那条」。 按它字面跑,验不出 C3:
- 场景 = RB 5 条(真实时刻 t1 < … < t5)+ RVH 新增 1 条(t6,最新)。
- 时钟正确 ⇒ 淘汰最旧 = RB 的 t1。
- RVH
created_at有 +8h 偏置 ⇒ 那条读作 t6+8h,仍然是池里最新的 ⇒ 淘汰的还是 RB 的 t1。 - 两个假设给出同一个观测。A1–A3 只验收敛,不验 C3。
C3 只在「RVH 那条不再是池里最新」时才露头。故补第四步:
A4(鉴别步):A3 收敛到 5 条后(应为 RVH 1 条 + RB 4 条),RB 逐条再加新语境, 每加一条淘汰当前最旧那条。加满 4 条后池子应是「RVH 那条 + 4 条新 RB」; 再加第 5 条时,被淘汰的必须是 RVH 那条(它此刻才是最旧的)。
- 淘汰 RVH 那条 ⇒ 排序键语义正确,C3 不成立。
- RVH 那条活着、而某条真实更新的 RB 行被淘汰 ⇒ C3 偏置坐实。
B 的实验设计(本台账细化;夹具 = silence,不能用 cave)
doc 23 §7.2 只说了「注入一条不含 surface 的行 → 看 N 少 1」。落到可执行,有四个必须先定死的点。
为什么另起一个词:B 的动作是故意往池子里塞一条「占坑但不可见」的行。 cave 是 A 的夹具且 relay 正在半路上(起始态已写进 §2 1-c、RVH 要照着它验), 在它上面做 B 等于中途改动对照组。两个词互不干扰——reconcile_cloze_pool 按 word 分组。
为什么 B 可以和 A 并行:B 不需要 RVH(RB + Supabase 控制台就够)。 A 卡在 RVH 那棒,B 不卡。
四个设计点:
| # | 定死什么 | 为什么 |
|---|---|---|
| ① | 先取 baseline N = 5,再注入 | 「N 少 1」是个差值判据。没有对照组,事后看到 N=4 无法排除「本来就是 4」。这一步不能省 |
| ② | 坏句必须只因「不含 surface」而落选 | 展示侧 usableSentence 还有 8..220 字符的长度闸。若坏句太短,N 少 1 的原因就变成长度,验错了东西。坏句要正常长度、语法正常、且完全不出现 silen* 任何形式(定位用 surface + word_forms + canonical_surface,留一个 silences 就命中了) |
| ③ | 坏行 created_at 取当前时刻(最新) | 注入后池子 6 条,reconcile_cloze_pool 砍最旧 ⇒ 砍掉的是最旧那条好行,坏行留下。若给坏行一个老时间戳,它自己会被砍掉,实验直接落空 |
| ④ | 坏行 source_url 与其余 5 条取同值 | 减少混淆变量。source_url IS NULL 的行会落进 buildReviewPositions 的 orphan 桶排尾(doc 23 §3 已单独确认过),那是另一件事,不要和 C1 混在一次实验里 |
期望观测:word_cloze_contexts 里 word='silence' 活跃行 = 5, 而复习卡语境切换器 n/N 的 N = 4 ⇒ 那一条占着 5 个坑位之一却在 UI 里不可见 = 静默占位复现。
收尾:观测完把坏行软删(红线 #6,不要在控制台 hard DELETE —— 硬删会让本地那条永远收不到墓碑,变成一条真正的孤儿)。
🔒 本文件的生命周期(别让它变成第七份专题清单)
docs/verification/README.md 定的是常驻清单体裁:判据表 + 假绿风险,且禁止数值快照。 本文件反着来——它是一次性接力棒(文件名带日期即为此),价值恰恰在原始查询输出。两条纪律:
- 它不是专题清单,不进 README §现有专题表。
- relay 收尾时当场拆散:结论 →
CHANGELOG.md;未修项 →docs/plans/backlog.md; 新学到的假绿风险 →docs/verification/sync-consistency.mdS13 那一行。 拆完本文件停止更新(保留作证据,不再维护)。
§1 环境
RB(桌面端)
| 项 | 值 |
|---|---|
| 版本 | 0.1.0-dev.10(真相源 src-tauri/tauri.conf.json) |
| 第 1 棒时的 HEAD | 2a0686e |
| 账号 | <测试账号 A> · user_id = <test-user-id-A> |
| 本地库 | ~/Library/Application Support/com.lampio.dev/lampio.db(dev app,pnpm tauri dev) |
| 快照 / 粘贴文档落盘处 | ⚠️ dev 模式不在 App Support 下:db/helpers.rs::library_dir 的 cfg!(debug_assertions) 分支把 library 根指到项目里的 temp/library/{user_id}/…。去 ~/Library/Application Support/com.lampio.dev/library 找会看到一个空目录 |
| 采集闸门 | vocabulary/crud.rs::insert_cloze_context:句长 8..220 chars、须含空白、按 (user, word, sentence COLLATE NOCASE) 幂等(软删则复活);封顶 CLOZE_POOL_CAP = 5,满池按 created_at ASC, id ASC 软删最旧 |
| 采集触发 | 首次查词 → 手动点存词(save_word);已存词再次查词 → add_word_page_link 自动静默入池(弹窗显示「✓ 语境 +1」,无需再点) |
created_at 形状 | chrono::Utc::now().to_rfc3339() ⇒ +00:00 结尾 |
RVH(移动端)
| 项 | 值 |
|---|---|
| 版本 | 1.0.0+1(pubspec.yaml)· schema _schemaVersion = 57 |
| 第 2 棒时的 HEAD | 3d51449(v63,工作区干净) |
| 设备 | Xiaomi 24094RAD4C(beryl)· Android 16 / SDK 36 · serial 6DL76DBEQSYXA6BI · debug 构建(flutter run) |
| 账号 | <测试账号 A> · user_id = <test-user-id-A> ✅ 与 RB 同一个(authState 快照实读,见 §2 2-a) |
| 本地库 | /data/user/0/com.lampio.app/app_flutter/lampio.db |
| 勾选的 CEFR 档 | user_settings.selected_cefr_levels 两行分别为 A1,A2,B1 / A1,A2,B1,B2,C1,C2 —— B1 都在,前置条件②满足 |
已从 RB 侧可观测到的事实(读 rvh/ 源码 + 本地已同步下来的 RVH 行):
| 项 | 值 |
|---|---|
| OCR 采集闸门 | OcrClozeGate:minSentenceChars = 25(比 RB 的 8 严)、maxSentenceChars = 220(与 RB 同值)、enableMainColumnRule = false |
| 语境来源 | 词位所在的那一行 OCR 文本(line_text),不做整页切句 |
| 采集范围 | 只覆盖本批次成功入库的词(savedWords = successfulEntries.keys) |
| 候选词过滤 | 按用户当前勾选的 CEFR 档过滤(filter_confirmation_notifier.dart:61 注释)⇒ 目标词的档位没勾上,它根本不出现在确认列表里。⚠️ 不止这一条:第 2 棒实测还有两个静默吞词的缺陷(句末带标点的词拿不到词位;多词性词首屏被整串等值匹配吞掉)—— 见 §2 2-f |
source_url | 恒 NULL(doc 23 §3 裁定:不造合成 URL) |
created_at 形状 | Z 结尾 |
Supabase
project jdtbyteiwnciqnfppztz · 表 user_word_cloze_contexts。
本次的被验对象
词 = cave(B1)。RB 侧起始态:活跃语境 5 条 + 墓碑 2 条,本地与云端逐行一致。 逐行数据见 §2 1-c / 1-d。预案词 silence 未采用(见 §2 1-f)。
⚠️ 同一张表里两种 UTC 形状并存
排序键 created_at 是 text 列、按字典序比。RB 写 +00:00、RVH 写 Z—— 同一微秒时 Z 恒大(Z=0x5A > +=0x2B)。这不影响 A(两端时刻相差远大于微秒), 但 A4 判读时要记住:同刻并列时 RVH 那条会被判为「更新」。
§2 逐棒记录
第 1 棒 · RB · 2026-08-27
做了什么:① 建本台账;② RB 会话先按预案词 silence 备了一套载体(见 1-f,最终未采用); ③ 用户在真实文章上双击 cave 攒语境,一直攒到溢出(7 句 → 封顶 5,淘汰 2); ④ 确认已 sync,Supabase 与本地逐行一致。
1-a 选词 cave(用户实际选的;与预案 silence 的取舍)
| 判据 | cave 实测 | 判定 |
|---|---|---|
| 在预装库、有 CEFR 档 | primary_cefr_level = B1、frequency_rank = 654 | ✅ 比预案的 A2 更稳 —— RVH 候选列表按用户勾选的 CEFR 档过滤,B1 几乎必在档内 |
| 不是停用词 / 不是熟词 | default_stopwords = 0、known_words = 0 | ✅ |
| 归一稳定 | lemma_base_forms 里 cave 是 base;caves/caved/caving → cave | ✅ 红线 #9:RVH 拍到任一屈折形都落同一个 PK |
| 已进生词本 | learning_entries 1 行,next_review_date = 1970-01-01(即到期) | ✅ 语境池挂在词上才有意义;且它现在就是到期卡,B 要看的 n/N 随时可观测 |
| RVH 容易拍到 | ⚠️ 不如 silence:cave 是题材词(考古 / 地理),随手翻一本小说不一定有 | ⚠️ 可接受,但下一棒得挑内容:拍这篇文章本身的屏幕/打印页,或任何讲洞穴、史前的页 |
| relay 期间不易被日常阅读误采 | ✅ 题材词的另一面 | ✅ 这条反而比 silence 强 —— 池子被意外污染是本次最主要的假绿源 |
原始输出:
sql: SELECT primary_cefr_level, frequency_rank, default_stopwords, known_words,
learning_entries … WHERE word='cave'
cefr freq stopword known in_notebook
B1 654 0 0 1
sqlite3 src-tauri/assets/lampio_dict.db
"SELECT * FROM lemma_surface_to_base WHERE surface IN ('cave','caves','caved','caving');"
surface|base
caved|cave
caves|cave
caving|cave
"SELECT * FROM lemma_base_forms WHERE base IN ('cave','caves');"
base
cave1-b 载体:真实网页(比预案的粘贴文档更好)
来源 = https://www.snexplores.org/article/oldest-human-fire-use (reading_pages id 5fce68dc-5893-4b28-9c1f-88209be07961,用户本来就在读的一篇)。
比预案的粘贴文档好在:source_url 是真 https 地址而非 rb-cache:// 本地串。 跨端来看,这一侧是「真 URL」、RVH 那侧是「恒 NULL」(doc 23 §3), 两种取值同时在场,比两侧都是人造值更接近真实分布。
⚠️ 五句里有三句含专有名词 “Wonderwerk Cave”。挖空定位不受影响—— cloze.ts::findAllIntervals 用的是 new RegExp(`\b${surf}\b`, 'gi'), 大小写不敏感,surface='cave' 照样命中句中的 Cave(故不构成 C1 静默占位)。 副作用只在体验:这三句挖空后露出 “Wonderwerk ____”,语境线索强到接近送分。
1-c 攒语境 —— 原始输出(本地 word_cloze_contexts)
用户在该页对 cave 连续双击 7 次(第 1 次点存词,其余走 add_word_page_link 自动入池)。 word='cave' 全部 7 行,按 created_at ASC, id ASC(= 淘汰规则用的那个序):
# created_at len deleted_at sentence
1 2026-08-27T13:29:45.847412+00:00 76 2026-08-27T13:30:34.395846+00:00 A cave in Africa seems to host the oldest known traces of humans using fire.
2 2026-08-27T13:29:48.048237+00:00 78 2026-08-27T13:31:11.167807+00:00 The new evidence comes from an earlier, deeper level of sediment in that cave.
3 2026-08-27T13:30:15.723085+00:00 37 (活) The site is known as Wonderwerk Cave.
4 2026-08-27T13:30:19.559786+00:00 88 (活) So people in the Wonderwerk Cave probably carried in flames from wildfires, Chazan says.
5 2026-08-27T13:30:24.191913+00:00 104 (活) Archaeologists already knew that Wonderwerk Cave was probably home to groups of H. erectus at this time.
6 2026-08-27T13:30:34.395846+00:00 67 (活) Ancient people didn't seem to always have fires in Wonderwerk Cave.
7 2026-08-27T13:31:11.167807+00:00 96 (活) Barn owls also likely sheltered in the cave while these ancient humans lived there, Chazan says.
活跃行 = 5(#3–#7),墓碑 = 2(#1、#2)这不是「攒够 5 条」,是「攒到溢出」——比原计划多验出一件事。 两次淘汰的配对:
| 第几次插入 | 新行 created_at | 同一刻被软删的行 | 它当时是不是最旧 |
|---|---|---|---|
| 第 6 次 | 13:30:34.395846 | #1(created_at 13:29:45.847412) | ✅ 是当时的最旧 |
| 第 7 次 | 13:31:11.167807 | #2(created_at 13:29:48.048237) | ✅ 是当时的最旧 |
新行的 created_at 与被淘汰行的 deleted_at 逐位相同——因为 insert_cloze_context 把同一个 now 同时喂给封顶那条 UPDATE 和随后的 INSERT。所以这两行不只是「时间接近」, 是同一次调用的两半,配对关系没有解释空间。
⇒ A 的本端半边(RB 内部封顶 + 淘汰最旧)成立,且是自然发生的,不是造出来的。 剩下的是跨端半边:RVH 写进来的那条,会不会被同一套序公平对待。
顺带落一条红线 #6d 的野外证据:两条墓碑本地 updated_at == deleted_at == synced_at(W 规则 + P3 都满足),且 13:48 那轮 pending_push = 0 —— 墓碑写完即不脏,没有回声环。这正是 cross-end/38 要守的东西。
1-d sync 后的 Supabase —— 原始输出
rb_supabase_query(user_word_cloze_contexts, word=eq.cave, order=created_at.asc):
created_at deleted_at server_updated_at id
2026-08-27T13:29:45.847412+00:00 2026-08-27T13:30:34.395846+00:00 2026-08-27T13:31:29.869262+00:00 ac5b84b6-…
2026-08-27T13:29:48.048237+00:00 2026-08-27T13:31:11.167807+00:00 2026-08-27T13:31:29.869124+00:00 91fe906c-…
2026-08-27T13:30:15.723085+00:00 null 2026-08-27T13:30:30.980282+00:00 e97cd131-…
2026-08-27T13:30:19.559786+00:00 null 2026-08-27T13:30:30.980209+00:00 8451ea3a-…
2026-08-27T13:30:24.191913+00:00 null 2026-08-27T13:30:30.978751+00:00 43274a0b-…
2026-08-27T13:30:34.395846+00:00 null 2026-08-27T13:31:29.869026+00:00 7ea5563e-…
2026-08-27T13:31:11.167807+00:00 null 2026-08-27T13:31:29.868656+00:00 6ac40b06-…
同步状态:last_sync_at = 2026-08-27T13:48:37.669296+00:00 · pending_push = 0 · lastSyncError = null7 行 id 与本地逐一对应,5 活 2 墓碑,无多无少。 墓碑也上行了 (红线 #6b:deleted_at 传本地真值而不是 null),所以 RVH 下一轮 pull 会拿到这两块墓碑。
判定:✅ 第 1 棒达成。 RB 侧起始态 = cave 池 5 条活跃 + 2 条墓碑, 本地与云端一致,可以交棒给 RVH。
1-e C3 反证(不属 A,先记账)
A4 是为「万一 C3 成立」准备的。第 1 棒已拿到两条独立的现成证据, 指向 RVH 当前构建的 created_at 是真 UTC、不带 +8h 偏置:
证据 1 —— OCR 页的页名 vs created_at(reading_pages):
name Scan 2026-08-27 01:39 #1
created_at 2026-08-26T17:39:49.137389Z页名用本机本地时间命名(01:39,UTC+8),created_at 写 17:39Z —— 差 8 小时且方向正确。 若 RVH 用裸 DateTime.now() 贴 Z,这里会写成 2026-08-27T01:39…Z。
证据 2 —— cloze 行的 created_at vs 服务端 server_updated_at:
word created_at server_updated_at
starvation 2026-08-26T17:39:50.772199Z 2026-08-26T17:41:50.458773+00:00server_updated_at 由 Supabase trigger 用 clock_timestamp() 强制写入(红线 #5d), 是服务端时钟。采集到上行相差 +2 分钟,符合真实 sync 延迟。 若有 +8h 偏置,created_at 会晚于 server_updated_at 约 8 小时。
⚠️ 这两条不能替代 A4:它们证的是「写入侧时钟对」,A4 证的是「淘汰规则真按这个键排」; 且只覆盖了 OCR 这一条采集路径。
1-f 未采用的预案载体(留着,别当噪声)
RB 会话先按预案词 silence 造了一篇 5 句粘贴文档 (reading_pages id fc67395e-46c8-4ab4-aa91-7d457f83e502,标题 Five reading contexts (cross-end relay 2026-08-27),source_ref 为 rb-cache://…)。 用户改用真实文章 + cave 后它未被使用,silence 的 cloze 行数仍是 0。
留着无害;后续若嫌它干扰「最近打开」列表,删掉即可——但要走软删(红线 #6a), 且它是 relay 期间造的,删除动作本身会进同步矩阵。
1-g 🔴 顺带发现:复活路径绕过封顶(2026-08-28 已实测)
vocabulary/crud.rs::insert_cloze_context 的结构是:
rust
if let Some((existing_id, deleted_at)) = existing {
if deleted_at.is_some() {
// 复活:SET deleted_at = NULL, updated_at = ?2
}
return; // ← 早于下面那条封顶 UPDATE
}
// 满池 → 软删最旧;再 INSERT复活在封顶之前 return。 于是「池子已满 5 条 + 重新双击一句曾被淘汰的句子」 ⇒ 活跃行变成 6,超过 CLOZE_POOL_CAP。它会在下一次插入 (count - CAP + 1 会一次砍掉 2 条)或下一轮 reconcile_cloze_pool 时自愈, 所以不是持久损坏,但:
- 对本次 relay 是直接危害:期间若发生,会被读成「收敛失败」,属假红。 ⇒ 🔒 relay 期间不要重复双击 #1 / #2 那两句。
- 复活不刷新
created_at(只改updated_at),所以复活出来的行带着原始的老时间戳, 立刻又是池里最旧的那条 —— A4 挑新句子时同样要避开这两句。
实测(2026-08-28,在 B 轨的 silence 池上做,不碰 A 的 cave 夹具): 池内活跃行 5 时重新双击那句已被淘汰的 The old librarian insisted that silence… ⇒ 活跃行 = 6,CLOZE_POOL_CAP = 5 被突破。复活行的三个时间戳:
created_at 2026-08-27T20:53:19.961475+00:00 ← 没刷新,仍是原始时间
updated_at 2026-08-27T21:06:29.803835+00:00 ← 复活这一刻
deleted_at null
synced_at 2026-08-27T20:57:18.093449+00:00 ← 仍是被淘汰那次写的两条推论,第 2 条随即被同一轮观测证实:
- 复活行是脏的(
updated_at > synced_at)⇒ 下一轮 push 会把「它活过来了」传出去。 这是对的,复活必须上行。 created_at没刷新 ⇒ 它回到池里就立刻又是最旧的那条。
续测:60 秒后的下一轮 sync 把它又淘汰了。
sync/mod.rs 里 push(:140)在 pull(:202)之前 ⇒ 同一轮里先把复活推上去、 再按 server_updated_at > last_sync_at 拉回来 ⇒ touched_words 含 silence ⇒ 触发 reconcile_cloze_pool ⇒ 它作为最旧的那条被 Step 2 砍掉:
last_sync_at 2026-08-27T21:06:15.220625+00:00 -> 2026-08-27T21:07:15.206059+00:00
墓碑 created 20:53:19.961475 updated/deleted 21:07:19.967617 synced 21:06:29.803835
The old librarian insisted that silence was no…
活 ×5(4 好 + B 轨那条坏行)deleted_at == updated_at(红线 #6d 的 W 规则 ✅), 且 deleted_at > synced_at ⇒ 这次再淘汰同样是脏的、会照常上行。
净效果:满池时重新双击一句被淘汰过的句子,用户什么也得不到—— 而且他会收到一个「成功」的反馈。 popup.js::checkContextIsNew 走 get_cloze_sense,那条查询带 deleted_at IS NULL ⇒ 对墓碑行返回 None ⇒ 判为「新语境」⇒ 弹窗亮出「✓ 语境 +1 · 撤销」。然后 60 秒内它悄悄没了。 假的正反馈,比单纯的封顶越界更值得修。
⚠️ 自愈依赖 sync:未登录 / 离线时没有那一轮 pull,6 条会一直挂着, 直到该词下一次插入(那时封顶按 count - CAP + 1 一次砍 2 条)。 所以「60 秒自愈」不是普遍结论,只对在线态成立。
📤 已按 §0 生命周期拆出:要动手的那部分(含三条修法与权衡)登记在 docs/plans/backlog.md 顶部那条 「cloze 语境池:满池时复活一条被淘汰的句子会谎报『✓ 语境 +1』」。 本节只留证据,不再维护修法。
第 3 棒 · RB · 2026-08-28(A3 ✅ / C3 已结论 / A4 待跑)
3-a A3 收敛核对 ✅
RVH 第 2 棒推完后,RB 侧 autoSync 已自行 pull 到位,未做任何手工动作。
本地 word_cloze_contexts / word='cave'(created_at ASC, id ASC = 淘汰规则用的那个序):
st id8 created_at deleted_at synced_at src_null
墓碑 ac5b84b6 2026-08-27T13:29:45.847412+00:00 2026-08-27T13:30:34.395846+00:00 2026-08-27T13:30:34.395846+00:00 0
墓碑 91fe906c 2026-08-27T13:29:48.048237+00:00 2026-08-27T13:31:11.167807+00:00 2026-08-27T13:31:11.167807+00:00 0
墓碑 e97cd131 2026-08-27T13:30:15.723085+00:00 2026-08-27T21:45:27.767276Z 2026-08-27T21:45:27.767276Z 0
活 8451ea3a 2026-08-27T13:30:19.559786+00:00 null 2026-08-27T13:30:22.904559+00:00 0
活 43274a0b 2026-08-27T13:30:24.191913+00:00 null 2026-08-27T13:30:29.416957+00:00 0
活 7ea5563e 2026-08-27T13:30:34.395846+00:00 null 2026-08-27T13:30:37.688648+00:00 0
活 6ac40b06 2026-08-27T13:31:11.167807+00:00 null 2026-08-27T13:31:16.110480+00:00 0
活 03994316 2026-08-27T21:45:27.767276Z null 2026-08-27T21:45:27.767276Z 1 ← RVH 那条同期 sync 在跑的证据(防假绿对照):last_sync_at = 2026-08-27T22:35:16.581799+00:00、 pending_push = 0、lastSyncError = null。
判定:活跃 5 条逐 id 与云端相同 · e97cd131 已成墓碑 · RVH 那条 source_url IS NULL 已落地 ⇒ A3 ✅。
顺带第三次拿到红线 #6d 的远端墓碑证据:e97cd131 的 deleted_at == synced_at == 2026-08-27T21:45:27.767276Z(P1 只由远端行的列拼出、P2 非 NULL),不脏。
镜像证据:RVH 那条的 created_at 与 e97cd131 的 deleted_at 逐位相同 (21:45:27.767276Z)⇒ RVH 的「入池 + 封顶淘汰」也是同一次调用的两半, 与 §2 1-c 记的 RB 侧形状一致。这条此前只在 RB 侧观测过。
3-b 🔴 RVH 交接单 §4 那条时间窗推导不成立(核完,订正如下)
RVH 给出的窗口是 T < 2026-08-28T05:45:27Z(北京 13:45)。这个界晚了整整 8 小时, 错因是把 8 小时偏置重复施加了一次:
交接单原文:「有偏置:真实是 13:45:27Z,而存成了 2026-08-28T05:45:27Z」
前半句对,后半句错。存的是多少不是假设、是观测 —— 云端和本地都白纸黑字写着 2026-08-27T21:45:27.767276Z,两个假设下这个字符串完全相同。 两个假设的分歧只在「它对应哪个真实时刻」:无偏置 = 21:45:27Z,有偏置 = 13:45:27Z(stored − 8h)。 交接单把「真实 13:45:27Z」又 +8h 得到 05:45:27Z 当成了 stored,于是界整体右移 8 小时。
正确的窗口(排序比的是 stored 字符串,真相是 real):
- 排序说「RVH 那条更旧」⟺
T > stored(21:45:27Z) - 事实说「RVH 那条更旧」⟺
T > R - 无偏置时
R = stored⇒ 两者恒等 ⇒ 无偏置假设永远不产生分歧(本就如此) - 有偏置时
R = 13:45:27Z < stored⇒ 分歧区间 =13:45:27Z < T < 21:45:27Z
⇒ 鉴别窗口 = 2026-08-27T13:45:27Z … 21:45:27Z(北京 08-27 21:45 … 08-28 05:45)。 核对时刻 2026-08-27T22:35:21Z(北京 08-28 06:35)⇒ 窗口已在 50 分钟前关闭。
换个说法更好记:A4 类鉴别只在「现在还没走到那条行自称的时刻」之前有效。 真实的现在一旦越过它自称的 created_at,那个谎就不再能被排序证伪。
⚠️ 这个错的方向是危险的:照它跑,A4 第 5 步会看到 RVH 那条被淘汰, 于是记下「C3 不成立、A4 鉴别通过」——而 A4 其实什么也没鉴别。 正是本台账反复在防的那种假绿。RVH 把它标出来请 RB 复核,是对的。
3-c C3 已由另一条证据直接结论:不成立(而且是对 A4 要测的那一行)
不需要 A4 了 —— 云端那行自带答案:
id 03994316-3f38-418f-812b-fd00512e730a
created_at 2026-08-27T21:45:27.767276Z ← RVH 客户端写的
server_updated_at 2026-08-27T21:46:34.209246+00:00 ← Supabase trigger 用 clock_timestamp() 写的
差 +66.4 秒(≈ 一个 autoSync 间隔)server_updated_at 是服务端时钟(红线 #5d)。若 RVH 有 +8h 偏置, created_at 会晚于 server_updated_at 约 8 小时;实测是早 66 秒, 正好是「收割完,下一轮 sync 推上去」。
⇒ C3 对这一行确定不成立。 这比 §2 1-e 那两条旁证强:那两条测的是别的行, 这一条测的正是 A4 本来要测的那一行。
(自洽性核对:若真有偏置,push 发生在真实 21:46:34Z 而真实创建是 13:45:27Z ⇒ 收割到上行隔了 8 小时 01 分,与「RVH 收割完立刻回报」的事实矛盾。两条路都指向无偏置。)
3-d A4 仍然要跑,但它现在只验一件事
时效性没了,A4 剩下的那半仍然有效且没被验过:淘汰规则是不是真按 created_at 排。 这半没有时间依赖。跑法与预测序列照 §0 / 交接单 §1,唯一改动是判读口径:
🔒 A4 的结论里必须明写「未能鉴别 C3」,C3 的结论挂在 3-c 上, 不要把 A4 第 5 步淘汰了 RVH 那条读成「C3 因此不成立」。
3-e A4 实测:5/5 精确命中 ✅
载体:新建粘贴文档 194e3cb3-27fe-4c4c-b445-974c4b853e53,5 句各含一次 cave, 全部是池里从未出现过的。⚠️ 句子是编的,不是真实读物 —— 取舍理由: 第 1 棒那篇文章含 cave 的 10 句里已有 7 句进过池,只剩 2 句可用,凑不满 5; 跨两个来源分批做等于让操作者在两个 tab 之间记顺序,而顺序正是 A4 的全部判据。 A4 只看 created_at 排序、不看来源,故用合成句换取确定性。
配对方式:不是逐条停下来查,而是事后从时间戳精确还原 —— §2 1-c 已证明被淘汰行的 deleted_at 与触发它的新行的 created_at 逐位相同 (同一个 now 同时喂给封顶 UPDATE 和 INSERT),所以配对没有解释空间。
新行 id8 created_at 同刻被软删 该行 created_at 预测 命中
1ace1ac3 2026-08-27T22:48:52.659955+00:00 8451ea3a 2026-08-27T13:30:19.559786+00:00 8451ea3a ✓
92479dfe 2026-08-27T22:49:00.318789+00:00 43274a0b 2026-08-27T13:30:24.191913+00:00 43274a0b ✓
c9d8ff34 2026-08-27T22:49:06.962596+00:00 7ea5563e 2026-08-27T13:30:34.395846+00:00 7ea5563e ✓
32e1f2ec 2026-08-27T22:49:14.186712+00:00 6ac40b06 2026-08-27T13:31:11.167807+00:00 6ac40b06 ✓
cf2b32ba 2026-08-27T22:49:20.685889+00:00 03994316 ←RVH 2026-08-27T21:45:27.767276Z 03994316 ✓终态两端一致:
本地 total 13 · active 5 · never_synced 0 · dirty_tombstones 0
last_sync_at 2026-08-27T22:50:01.363984+00:00 · pending_push 0 · lastSyncError null
云端 同 13 行、同 5 活,deleted_at 逐 id 相同判定:淘汰规则确实按 created_at ASC, id ASC 排,五步无一例外 ⇒ A4 的排序键语义部分 ✅。
第 5 步跨过了格式边界。 它拿 RVH 的 2026-08-27T21:45:27.767276Z 与 RB 的 2026-08-27T22:48:52.659955+00:00 等比大小,结果正确 —— 因为差异落在小时位上, 远在后缀之前就分出胜负。⚠️ 同微秒并列那种情况(Z=0x5A > +=0x2B)本轮没有测到, §1 末尾那条警告至今仍是理论推断,不是实测结论。
3-f 🔒 A4 没有鉴别 C3 —— 别把这两件事读成一件
第 5 步淘汰了 RVH 那条,这只说明排序键语义正确。它对 C3 零信息量: 按 3-b 订正后的窗口,鉴别窗在 2026-08-27T21:45:27Z 就关闭了, 本次 A4 跑在 22:48–22:49,晚了约一小时 —— 有偏置与无偏置在这个时刻给出同一个观测。
C3 的结论挂在 3-c(created_at 对 server_updated_at 差 +66.4 秒),不挂在这里。 末尾 ⏭ 原本写着「淘汰它 ⇒ C3 不成立」,那句照 3-b 已失效,勿再沿用。
B 轨 · 第 1 棒 · RB + Supabase 控制台 · 2026-08-28
结论先写:✅ C1 静默占位复现,且代价比 doc 23 §7.2 描述的更大—— 那条不可见的行不只占了一个坑位,它还把一条好语境挤成了墓碑。
B-1 夹具
silence 池按 §0「B 的实验设计」攒满 5 条(载体 = §2 1-f 那篇粘贴文档, source_url 全部为 rb-cache://localhost/text/fc67395e-….html)。
B-2 注入前 baseline(useReviewStore 快照,未揭晓、未答卡)
word : silence
cloze_contexts : 5 ← 池里活跃行
positions (= N) : 5 ← UI 里可见的语境
每条 positions[i].cloze.segments 都含 { blank: true, text: "silence" }
repetitions: 0 | last_review: null | reviewedCount: 0⚠️ 这份 baseline 是抢下来的:用户执行 INSERT 时复习卡已经装载在 store 里, 而 positions 只在 setCurrentCard 时派生 ⇒ 这一帧就是注入前的状态。 若当时卡没开着,baseline 就丢了(见 B-5 的复盘)。
B-3 注入
按 §0 设计点 ③ 用当前时刻(最新)写入。落地后本地池:
state created_at sentence
墓碑 2026-08-27T20:53:19.961475+00:00 The old librarian insisted that silence wa…
活 2026-08-27T20:53:45.395677+00:00 After the verdict was read, a heavy silenc…
活 2026-08-27T20:53:48.833971+00:00 Radio astronomers have learned to treat si…
活 2026-08-27T20:53:52.258560+00:00 She walked home through the snow, and the …
活 2026-08-27T20:53:56.228548+00:00 In the negotiation, his silence did more d…
活 2026-08-27T20:57:13.510966+00:00 The room had been completely quiet ever si… ← 注入的坏行
活跃 5(4 好 + 1 坏)· 墓碑 1最旧那条好行被 reconcile_cloze_pool 的 Step 2 砍掉了 (deleted_at = 2026-08-27T20:57:18.093449+00:00 = reconcile 的 now)。 坏行因为 created_at 最新而幸存 —— 设计点 ③ 按预期生效。
B-4 注入后观测(同一个快照里同时拿到两个数)
结束会话 → 按同一笔记本重进(setCurrentCard 重新派生 positions):
word : silence
cloze_contexts : 5 ← 池里活跃行
positions (= N) : 4 ← UI 里可见的语境
在池里但进不了 position list 的:
'The room had been completely quiet ever since the last visitor walked out.'
repetitions: 0 | last_review: None | reviewedCount: 0⇒ 活跃 5、N = 4,差的那一条正是注入的坏行。 静默占位复现。
为什么不需要靠 baseline 做差值:坏行不可能可见(句中没有 silence / silences / silencing / silenced 任何一个,已逐个正则核过), 所以它对 N 的贡献恒为 0;N=4 就直接说明另外 4 条全都可见。 baseline 只是把「本来就是 4」这条退路也堵上,属锦上添花。
B-5 三条比原判据更值钱的东西
- 代价被低估了。 doc 23 §7.2 的说法是「占着坑位却不可见」。实测还多一层: 坏行进池时把最旧那条好语境挤成了墓碑,而墓碑会照常上行 (红线 #6b)⇒ 那条好语境在两端都没了。C1 闸门守的不只是「一个空坑」, 是「一个空坑 + 一条真实语境的永久损失」。
cloze_contexts与positions的落差本身就是可观测量。 不必去读 UI 上的n/N(那还要先揭晓),useReviewStore里两个数组的长度差就是同一件事, 且不需要任何会写库的动作。以后再验这类问题,走这条路。- baseline 差点丢。 设计点 ①(先取 baseline)在执行时被跳过了—— INSERT 与 baseline 是同一轮动作。这次靠「卡已装载、store 里的
positions是旧帧」侥幸留住。下次这类实验,baseline 要落成一次独立的、明确的读取, 不能指望某个组件恰好还挂着旧状态。
B-6 收尾(✅ 已完成 2026-08-28)
- [x] 用软删清掉坏行(控制台 UPDATE,
deleted_at与updated_at取同值) - [x] 顺带实测 §2 1-g(复活绕过封顶)—— 见该节,已由读码升为实测
- [ ] 被挤掉的
The old librarian…那条未找回(见下「残留」)
归零核对(两端原始输出)
Supabase user_word_cloze_contexts / word=eq.silence:
created_at deleted_at updated_at id
2026-08-27T20:53:19.961475+00:00 2026-08-27T21:07:19.967617+00:00 2026-08-27T21:07:19.967617+00:00 8e07a136-… ← 被挤掉的好句
2026-08-27T20:53:45.395677+00:00 null 2026-08-27T20:53:49.207652+00:00 30350d97-…
2026-08-27T20:53:48.833971+00:00 null 2026-08-27T20:53:52.991706+00:00 658a4bc9-…
2026-08-27T20:53:52.258560+00:00 null 2026-08-27T20:53:56.000206+00:00 0ed364e7-…
2026-08-27T20:53:56.228548+00:00 null 2026-08-27T20:53:59.283760+00:00 d8f092b8-…
2026-08-27T20:57:13.510966+00:00 2026-08-27T21:13:41.997381+00:00 2026-08-27T21:13:41.997381+00:00 fd98973e-… ← 坏行,已软删本地 word_cloze_contexts:坏行已成墓碑,且
updated_at == deleted_at == synced_at == 2026-08-27T21:13:41.997381+00:00
last_sync_at = 2026-08-27T21:13:52.205371+00:00 · pending_push = 0⇒ 顺带把红线 #6d 在「远端来源的墓碑」上验了一遍(此前 §2 1-c 那条是本端来源):
- P1(禁本端时钟):
synced_at逐位等于远端那行自己的updated_at/deleted_at, 没有掺进任何本端now。 - P2(取最大且非 NULL):
MAX(sync_ts, deleted_at)两个操作数同值 ⇒ 非 NULL。 - 结果:
deleted_at > synced_at为假 ⇒ 该行不脏 ⇒pending_push = 0, 没有回声环。这正是 cross-end/38 那条契约要守的东西,现在有一次远端方向的实证。
B-7 残留(有意留下,不修)
池子现在是 4 条活跃,不是 5 —— 少的那条是 The old librarian insisted that silence…, 它在 B-3 被坏行挤成了墓碑,这就是 B-5 第 1 条说的「一条真实语境的永久损失」。
不找回它,理由:① 它是本次实验代价的实物证据,留着比补回来更有说明力; ② silence 不是 A 的夹具,池子是 4 还是 5 对后续棒次没有影响。 真要找回,重新双击该句即可复活(当前 4 < 5,不会触发 1-g 的封顶越界)。
B 轨到此结束。
第 2 棒 · RVH · 2026-08-28
结论先写:✅ A 的 RVH 半边成立。 新语境入池后被淘汰的是 e97cd131(The site is known as Wonderwerk Cave.,created_at 13:30:15.723085) —— 正是第 1 棒交接时池里最旧的活行,不是 RVH 自己刚写的那条。 本地与 Supabase 逐 id 一致,且此后 4 轮 sync pushed=0(无回声环)。
但这一棒花了两次拍页才走通,中间挖出两个 RVH 侧的静默缺陷(见 2-f)—— 其中任意一个单独存在,都会让「目标词根本不出现在收割候选里」而 UI 上毫无异常。 第一次拍页正是同时踩中两个,cave 一次都没露面。
2-a 前置三项检查(动手前做完,原始输出)
rvh_state(authState)
AsyncData<AuthUserEntity?>(value: AuthUserEntity(
id: `<test-user-id-A>`, email: `<测试账号 A>`)) ← ① 同一账号 ✅
SELECT selected_cefr_levels FROM user_settings;
A1,A2,B1
A1,A2,B1,B2,C1,C2 ← ② B1 在档 ✅
SELECT le.mastery_level, (known_words 计数), (vocabulary.primary_cefr_level),
(reference_words 计数) ... WHERE word='cave';
mastery_level known cefr refw
level0 0 B1 0 ← 不是熟词/不是参考词,会进候选 ✅交接态复核(RVH 本地 word_cloze_contexts,第 2 棒开始前):
id created_at deleted_at sentence
e97cd131 2026-08-27T13:30:15.723085+00:00 (活) The site is known as Wonderwerk Cave.
8451ea3a 2026-08-27T13:30:19.559786+00:00 (活) So people in the Wonderwerk Cave probably…
43274a0b 2026-08-27T13:30:24.191913+00:00 (活) Archaeologists already knew that Wonderwerk…
7ea5563e 2026-08-27T13:30:34.395846+00:00 (活) Ancient people didn't seem to always have…
6ac40b06 2026-08-27T13:31:11.167807+00:00 (活) Barn owls also likely sheltered in the cave…
(本地只有这 5 行;RB 那 2 条墓碑本地没有)
last_sync_at:`<test-user-id-A>` = 2026-08-27T21:13:42.029522+00:00🔎 两条墓碑在 RVH 本地不存在是正确行为,不是漏 pull。 watermark(21:13)远晚于 它们的
server_updated_at(13:31:29),所以 RVH 确实拉到过它们 ——_pullWordClozeContexts的分支③「本地无 + remote 删 → skip,不落墓碑」 决定不为一条本地从未存在的行建墓碑(FK-safe 惯例)。 记在这里是因为「本地 6 行 vs 云端 8 行」乍看像丢数据,实际是设计。
2-b 载体:渲染页 + 相册导入(不是相机拍摄,理由见下)
用 Chrome headless 把文章内容排成一页 1600×1100 PNG,推进设备相册 (/sdcard/Pictures/rvh_cave_relay_v2_20260828.png),走 app 的 From Gallery 入口 (CaptureSource.gallery,与相机同一条 OCR / 闸门 / 采集链路,只跳过二值化)。
为什么不是相机拍摄:本棒的判据是「跨端池收敛 + 淘汰序」,与光学成像无关; 而渲染页能把行的长度与断句钉死,让「目标词落在整行中间、行 ≥ 25 字符」这条前置条件 可复现,而不是靠运气。代价是这一页不是相机原件 —— 已如实记在这里。
页内只出现一次 cave,落在这一行的中段(70 字符,过 minSentenceChars = 25):
Barn owls left pellets on the cave floor where people kept fires going⚠️ 这句不是文章原文的逐字引用,是用原文事实("Barn owls…"、"People kept fires going on the pellet-strewn floor.")改写的一行。原因是文章里剩下的两句含 cave 的没被用过的句子 (He leads excavations at Wonderwerk Cave. / …Wonderwerk Cave, which was inhabited by…) 里,cave 一个在句末、一个后面紧跟逗号 —— 两种都会被 2-f 的缺陷 1 整词丢掉。 另外 5 句原文虽然 cave 位置合适,但都已在池里,重复采集是幂等 no-op(还会踩 §2 1-g 的复活越界),不能用。
reading page:69bc25db-406d-44a8-a81d-ef3165244792(Scan 2026-08-28 05:45 #1), 挂在既有笔记 Snexplores.org Notes(5fb561ce-…)下,source_ref = NULL。
2-c 收割 —— 原始输出
[VocabFilter] Cloze 版面: 7 行, 内容宽 1259, 行左边缘 [90, 90, 90, 91, 92, 92, 93],
主列 未启用(证据不足), 断词行 false
[VocabFilter] Cloze 闸门: 拒收 [];无词位候选 1 词 [finding]
[VocabFilter] Cloze: 28 词入库,27 词通过闸门,新增 27 条语境27 条新语境共享同一个 created_at(2026-08-27T21:45:27.767276Z)—— 一次收割一个 nowUtc(),与 C3 的 UTC 口径一致(Z 结尾)。
收割后、sync 前的本地 cave 池:
id created_at deleted_at sentence
e97cd131 2026-08-27T13:30:15.723085+00:00 2026-08-27T21:45:27.767276Z The site is known as Wonderwerk Cave. ← 被淘汰
8451ea3a 2026-08-27T13:30:19.559786+00:00 (活) So people in the Wonderwerk Cave…
43274a0b 2026-08-27T13:30:24.191913+00:00 (活) Archaeologists already knew…
7ea5563e 2026-08-27T13:30:34.395846+00:00 (活) Ancient people didn't seem…
6ac40b06 2026-08-27T13:31:11.167807+00:00 (活) Barn owls also likely sheltered…
03994316 2026-08-27T21:45:27.767276Z (活) Barn owls left pelets on the cave floor
where people kept fires going ← RVH 新增
活跃 = 5 · 墓碑 = 1(本地)淘汰行的 deleted_at 与新行的 created_at 逐位相同 —— 同一次 ClozePoolWriter.insertAll 调用里的同一个 now 同时喂给封顶 UPDATE 和 INSERT, 与 RB 侧 insert_cloze_context 的形状一致(§2 1-c 记过同款配对)。配对关系没有解释空间。
pelets是 OCR 把pellets少认了一个 l。不影响本棒:cave本身识别正确, C1 的两条断言(normalize(surface) == word、ClozeBuilder.buildContextCloze可挖空) 都作用在目标词上。如实记下,不修。
2-d sync 两轮 —— 原始输出
autoSync(60s)在 app 根自动跑,未手工触发:
Sync complete: pushed=0, pulled=0, syncStartAt=2026-08-27T21:45:10.475416Z, watermarkAdvancedTo=(no advance) ← 收割前
Sync complete: pushed=81, pulled=29, syncStartAt=2026-08-27T21:46:30.096952Z, watermarkAdvancedTo=2026-08-27T21:46:34.223944+00:00 ← 第 1 轮
Auto-sync (signed in): pushed 81, pulled 29
Sync complete: pushed=0, pulled=0, syncStartAt=2026-08-27T21:47:30.088832Z, watermarkAdvancedTo=(no advance) ← 第 2 轮
Sync complete: pushed=0, pulled=0, syncStartAt=2026-08-27T21:48:30.398462Z
Sync complete: pushed=0, pulled=0, syncStartAt=2026-08-27T21:49:29.588636Z
Sync complete: pushed=0, pulled=0, syncStartAt=2026-08-27T21:50:29.586732Zsync 后的 RVH 本地(注意 synced_at):
id created_at updated_at deleted_at synced_at
e97cd131 2026-08-27T13:30:15.723085+00:00 2026-08-27T21:45:27.767276Z 2026-08-27T21:45:27.767276Z 2026-08-27T21:45:27.767276Z
03994316 2026-08-27T21:45:27.767276Z null null 2026-08-27T21:45:27.767276Z
(其余 4 行 synced_at 保持首次 pull 时的值,未被触碰)⇒ 墓碑行 updated_at == deleted_at == synced_at,写完即不脏 (红线 #6g 规则 W + P3 ✅);新行 synced_at == created_at,同样不脏。
Supabase(user_word_cloze_contexts, word=eq.cave, order=created_at.asc):
created_at deleted_at server_updated_at id
2026-08-27T13:29:45.847412+00:00 2026-08-27T13:30:34.395846Z 2026-08-27T13:31:29.869262+00:00 ac5b84b6-… 墓碑(RB)
2026-08-27T13:29:48.048237+00:00 2026-08-27T13:31:11.167807Z 2026-08-27T13:31:29.869124+00:00 91fe906c-… 墓碑(RB)
2026-08-27T13:30:15.723085+00:00 2026-08-27T21:45:27.767276Z 2026-08-27T21:46:34.209325+00:00 e97cd131-… 墓碑(RVH 淘汰) ★
2026-08-27T13:30:19.559786+00:00 null 2026-08-27T13:30:30.980209+00:00 8451ea3a-… 活
2026-08-27T13:30:24.191913+00:00 null 2026-08-27T13:30:30.978751+00:00 43274a0b-… 活
2026-08-27T13:30:34.395846+00:00 null 2026-08-27T13:31:29.869026+00:00 7ea5563e-… 活
2026-08-27T13:31:11.167807+00:00 null 2026-08-27T13:31:29.868656+00:00 6ac40b06-… 活
2026-08-27T21:45:27.767276Z null 2026-08-27T21:46:34.209246+00:00 03994316-… 活(RVH 新增) ★
活跃 5 · 墓碑 3 · 与 RVH 本地逐 id 一致(本地少的 2 条墓碑见 2-a 的说明)
RVH 新增行 source_url = NULL ✅(doc 23 §3:不造合成 URL)🔑 防假绿对照(照抄 §2 1-g 的做法):4 轮 pushed=0 的同时 last_sync_attempt_at 从 21:47:30 推进到 21:50:29, 且两条 ★ 行的 server_updated_at 逐字节不变 —— 「没重推」不是「压根没同步」。
2-e 判定
| 判据(第 1 棒交接时写死的) | 实测 | 判定 |
|---|---|---|
| 双端收敛到同一份 5 条活跃(不是 6,不是各自 5 条) | 本地 5 / 云端 5,逐 id 相同 | ✅ |
被淘汰的是 e97cd131(created_at 13:30:15.723085,当时最旧的活行) | 正是它 | ✅ |
| 不是 RVH 自己那条 | 03994316 活着 | ✅ |
墓碑上行(deleted_at 传真值而非 null) | 云端 deleted_at = 21:45:27.767276Z | ✅ |
| 无回声环 | 4 轮 pushed=0 + server_updated_at 不变 + attempt 仍在推进 | ✅ |
⚠️ 本棒不构成 C3 的证据(§0 已论证):RVH 那条是池里最新的, 「时钟正确」与「+8h 偏置」给出同一个观测。C3 留给第 3 棒的 A4。
交给 RB 第 3 棒的输入(A4 挑新句时避开这 3 条墓碑:ac5b84b6 / 91fe906c / e97cd131, 理由见 §2 1-g):
RVH 那条:
id 03994316-3f38-418f-812b-fd00512e730a
sentence Barn owls left pelets on the cave floor where people kept fires going
created_at 2026-08-27T21:45:27.767276Z ← 目前池里最新
source_url NULL2-f 🔴 途中挖出的两个 RVH 缺陷(第一次拍页失败的真因,各自都能静默吞词)
第一次拍的页里 cave 落在 He leads excavations at Wonderwerk Cave. 的句末。 收割候选里没有 cave,UI 上没有任何异常。逐层回溯发现是两个独立缺陷叠在一起:
缺陷 1 —— ML Kit 词过滤把「带标点的词」整个丢掉 (lib/features/ocr/data/engines/mlkit_engine.dart:112)
wordPattern = RegExp(r'^[a-zA-Z]{2,}$') 直接作用在 ML Kit element 原文上, 而 ML Kit 把紧贴的标点算进 element(Cave.)⇒ 该词连词位都不生成。 一页 7 行的实证,6 行以句号结尾、1 行没有终止标点:
渲染行末词 OCR 是否收到
old. ❌ Canada. ❌ Cave. ❌ One. ❌ itself. ❌ ago. ❌
sparks ✅ ← 唯一没有终止标点的那行[Pipeline] 📥 OCR 原始单词 (73): …, he, leads, excavations, at, wonderwerk, chazan, was, …
↑ "Cave." 本该在这里对语境采集尤其糟:没有词位 = 该词这一页永远采不到语境 (同一轮日志里 无词位候选 1 词 [finding] 是同一类症状)。
缺陷 2 —— 多词性词在收割站首屏被静默吞掉 (lib/features/vocabulary_filtering/presentation/providers/filter_confirmation_state.dartFilterConfirmationState.initial)
activeLevels.contains(cefrLevel) 是整串等值匹配,而多词性词的 cefrLevel 是逗号串 (cave = "B2,B1")⇒ 首屏 words 里根本没有它们。同文件的 sortedGroupedWords 和 notifier 的 _filterWordsByLevels 都正确地 split(',') —— 三处实现只有 initial 没拆。
一行日志就能看出来:点一下 C2(该档 0 词,逻辑上不该有任何变化):
[VocabFilter] Before - Displayed words: 20
[VocabFilter] Action - Removing level: C2
[VocabFilter] After - Displayed words: 28 ← 凭空多出 8 个多出来的 8 个正是 CEFR 值带逗号的那些:
[Pipeline] 📊 A1,B1: fire 📊 B1,A2: explore 📊 B2,B1: cave
📊 B1,A1: go, share 📊 B2,B1,A1: light 📊 A2,A1: use同屏两个数自相矛盾(B1 芯片计数 7,B1 分组只列 1 个 iron),无任何报错。
本棒对缺陷 2 用的绕法:进收割站后点一下 C2 芯片,
words经_filterWordsByLevels重建,cave就回到 B1 分组并默认勾选。 这是绕法不是修复 —— 记在这里是为了让第 3 棒知道 RVH 那条语境是怎么进去的。
两条都已登记进 RVH 的 rvh/docs/plans/backlog.md 顶部(各一条 🔥 P1,含实测原始输出、根因表、修法与回归断言)。本节只留证据,不再维护修法。
⚠️ 两者都不是本次 relay 引入的,也不影响 A 的判据(cave 一旦进了收割清单, 采集/封顶/同步整条链路的行为与平时完全相同)。但它们解释了为什么 doc 23 §7.1 那句 「RVH 拍一张含 cave 的页」在实操中不是一步就能做到的事。
2-g 本棒没做的事(明确记账,别当已验)
- RB 侧的两轮 sync 与收敛核对:RB 是桌面端,本会话只动 RVH 仓 ⇒ 属第 3 棒。 云端已是终态(2-d),RB 只需 pull。
- C3 鉴别:见 2-e 的 ⚠️。
- doc 23 §7.3(删扫描页 → cloze 行仍在):按交接要求未跑。 RVH
reading_pages仍是硬删 + FK CASCADE,先修再验。 silence池:全程未触碰(B 轨夹具,停在 4 条活跃)。- §2 1-g 的两条
cave墓碑:全程未重新采集,未触发复活越界。
§3 结论
三项待验的终态
| # | 结论 |
|---|---|
| A 池收敛 + 淘汰序 | ✅ 成立。跨端 merge 后双端收敛到同一份 5 条(13 行逐 id 一致);淘汰确实按 created_at ASC, id ASC 排,A4 五步预测无一例外,含跨端那一条 |
| B C1 静默占位 | ✅ 复现,代价比 doc 23 §7.2 描述的更大:坏行不只占一个不可见的坑,它进池时把最旧那条好语境挤成了墓碑、墓碑照常上行 ⇒ 那条真实语境两端都没了 |
| C RVH 删扫描页 → cloze 行仍在 | 🚫 未验。阻塞在 RVH reading_pages 的硬删 + FK CASCADE,现在跑会把坏行为记成「既有行为」。RVH 已立项先修再验 |
C3 created_at 时区偏置 | ✅ 不成立(RVH 当前构建写真 UTC)。结论来自 created_at 对服务端 trigger 写的 server_updated_at 差 +66.4 秒(§2 3-c)—— 不是来自 A4 |
这一轮真正学到的三件事(原判据里都没有)
- doc 23 §7.1 对 C3 没有鉴别力;而第 3 棒交接单里那条本用于订正的窗口 又把 8 小时偏置重复施加了一次、界晚了 8 小时(§0 A4 / §2 3-b)。 两次是同一个形状:把「存的是多少」当成随假设变化的量 —— 它是观测。 可复用的判据:A4 类鉴别只在「现在还没走到那条行自称的时刻」之前有效。
cloze_contexts与positions的长度差本身就是可观测量(§2 B-4)。 验「某条数据在 UI 里不可见」不必揭晓复习卡去读n/N,也不需要任何会写库的动作。- 「同一次调用的两半」是本轮最有用的取证手法(§2 1-c / 3-e): 封顶 UPDATE 与 INSERT 共用一个
now⇒ 被淘汰行的deleted_at与新行的created_at逐位相同 ⇒ 事后可精确还原整条淘汰序列,不必逐步停下来观测。RVH 侧同形(§2 3-a)。
顺带产出(非本轮目标)
- 红线 #6d 三次野外实证:本端来源墓碑(§2 1-c)+ 远端来源墓碑两次(§2 B-6 / 3-a)—— 写完即不脏、
pending_push = 0、无回声环。 - 一条产品缺陷:满池复活绕过封顶 + 谎报「✓ 语境 +1」(§2 1-g),已在 backlog。
- 一条未测到的边:两种 UTC 形状在同微秒并列时的排序(
Z>+), §1 那条警告至今仍是理论推断(§2 3-e)。
🔻 拆散记录(按 §0 生命周期,2026-08-28 执行)
本文件到此停止更新,保留作证据。三处去向:
| 去哪 | 内容 |
|---|---|
CHANGELOG.md | 本轮结论(A ✅ / B ✅ / C 未验 / C3 不成立) |
docs/plans/backlog.md | ① 满池复活谎报「✓ 语境 +1」(已登记)② C 项未验,阻塞在 RVH 硬删 |
docs/verification/sync-consistency.md S13 | 新学到的假绿风险:A4 类鉴别的时间窗 + 「长度差可观测」取证手法 |
⏭ 没有下一棒 —— relay 于 2026-08-28 收尾。 A ✅ / B ✅ / C 🚫 未验(阻塞在 RVH 侧硬删)。 结论见 §3,去向见「拆散记录」。本文件停止更新,保留作证据。
C 项要重开时的入口:RVH 修完
reading_pages软删(reading_page_datasource.dart:131/:580) 之后,按 doc 23 §7.3 单端跑即可,不需要再起一次双端 relay。