# 2026-09-30 ## 生产时间线「部分重复部分没有」的根因与修复 用户报 https://life.shiroko.one/timeline 上仍有重复。抓页面逐条比对:一屏 180 条里有 **53 对** 到达时间、离开时间、停留时长**完全相同**的条目——与 09-04 那种「离开时间不同」的已并入行不同, `ZMERGEDTO`/`ZPARENT` 判据一条都收不到(43 对里隐藏侧 0)。 真正成因:**站点补录的行没有留档 ⇒ 没有 UUID**,导出时被补上 `v5("ZVISIT/")`;这份包 再合并回站点时站点侧没有 UUID 可对齐,被当成「备份新增」又插一条(41/43 的副本 UUID 正好等于 配对行的派生 UUID,实测确认)。 修:rond 里把派生 UUID 抽出来当**对账键**(`DerivedUUID`/`VisitMatchUUID`),差异与合并都用它; 卡片新增第二类判定(成因「你补录的那条被合并回来的一份」),清理时把副本名下的位移/天气**改挂** 到保留项。提交 `8dec973`(+ 格式 `aaf65f6`),已推送;全量验收随后跑。 ## 「本地测试要十几分钟」的根因:我昨天那个收口把页面拖成了 44~70 秒 - 症状:最慢的 4 个用例占全套 55%(309s/274s/226s/115s),全是「渲染若干页面」的布局用例。 - 定位:临时逐页计时探针(`/` 43.6s、`/places` 70.1s)→ 变异对照(关掉判据 0.28s)→ CPU profile (135 秒只用 3 秒 CPU ⇒ 在等,不是算)。孤立的 EXPLAIN 只有 12~38ms,**会骗人**。 - 根因:判据打在 `entity_raw.raw`(TOAST 过的整行 JSON)上,而 /places 是「每个地点 × 5 个子查询」。 - 修:派生列 `visits.is_merged` + 刷新(ingest / recountPlaces)+ 部分索引;/places 70084ms → 206ms, 首页 43633ms → 698ms。新增 `page_render_budget_test.go`(7 个页面卡 10 秒的灾难探测器)。 - 教训:**别拿单条查询的耗时判断整页性能**;「功能对、就是慢」的退化必须有守卫。 ## 生产导出 `data/20260930.rondbackup` 被真机拒收 —— 逐项排查(未定位到形状违规) 按技能里「备份恢复失败」的排查顺序全跑了一遍,**每一层都与手机包/曾被接受的包同形**: 1. 打包层(头号真凶):本地头 bit3=0、长度非 0、条目名与顺序与 09-20 被接受的那份一致 ✓ 2. 模型骨架:表 31 / 索引 47 / CREATE TABLE 逐字符 / `Z_PLIST` sha256 / `Z_MODELCACHE` 16073B / `Z_UUID` / `Z_VERSION` —— 与 09-29v2 手机包**完全一致** ✓ 3. 逐列空值画像:唯一差异 `ZLOCATION.ZISOCOUNTRYCODE` 20 个 NULL。**这不是问题**——09-20 被接受过的包也是 23 个 NULL, 且 09-29 做过**真机对照实验**(同一份包唯一差异这列):`''` 被拒、`NULL` 能导入(见 commit `d5e96ed`)⇒ NULL 是对的, **别再去「补空串美化」**(我这次差点又去改它)。 4. 关系完整性 0 悬空(含 `ZRAWVISIT→ZVISIT`、`ZVISIT.ZLOCATION/ZACTIVITY_`、自引用 `ZMERGEDTO/ZPARENT`)✓ 5. `Z_OPT` 全 ≥1、`Z_ENT` 与参照一致、`ZIDENTIFIER_` 16B 且唯一、值类型分布无新增类别、无非法 UTF-8、 行级时间不变量 0 违例、SQLite 文件头(页大小/WAL 版本/编码)一致 ✓ 6. 计数画像同形(1001 到访 / 64 隐藏 / 436 地点 / 392 有到访 / 交通方式 6 条无重名)✓ 7. 包里**仍有 43 组站内副本重复对**(就是 dedup 卡片要清的),但不影响导入 ✓ **下一步(等用户)**:要报错原文 + 两个对照导入把范围切两半—— `storage/.verify/acceptance-clean.rondbackup`(纯净往返)与 `data/20260920-fixed-A-数据修复.rondbackup`(曾成功)。 若指向「站点数据」,就用这份失败包做行级二分(按 UUID 拆「手机原有的行」/「站内新建的行」)。 ## 拒收排查续:静态判据全过 ⇒ 转真机二分(工具已提交) 用户确认:报错就是「备份恢复失败,你的当前数据未被修改」(QQ 传文件,同一路径传的另一份能导入)⇒ **导出链路与 App 都没问题,问题在这份数据集的数据里**。 静态排查全过(打包位标志 / Core Data 骨架逐字节 / 空值画像 / 关系完整性含自引用 / 类型分布 / Z_OPT·UUID·BLOB / 行级时间不变量 / 位移窗口不变量 / 取值域与数值范围 / SQLite 文件头)。 顺手排掉四个「看着像」的: - `ZISOCOUNTRYCODE` 20 个 NULL:09-20 被接受过的包也是 23 个 NULL,且 09-29 真机对照实验证明「空串被拒、NULL 能进」⇒ 不是它; - 站内新建行的 v5 UUID:手机包自己就有 172 个 v5; - 43 组精确重复到访:**手机包自己就有 39 组** ⇒ 真机容忍; - 位移窗口越界:这份包 0 条(手机包自己倒有 2 条)。 新增 `tools/bisect_export.py`:以被拒包为底生成 V1(去掉站内新建的 22 条到访)、 V2(+3 个站内新建地点)、V3(手机里也有的行按手机原值重置),都已过审计(悬空 0 / Z_MAX 够 / 打包正常)。 判读:哪一版开始能进,问题就在上一版删/改掉的那一类里;三版都进不去 ⇒ 问题在「行的集合」(删过/多过的行)。 ## 二分结果:V1 ✓ / V2 ✓ / V3 ✗ ⇒ 罪在「站内新建的那 22 条到访」(地点无辜) - V1(去掉 22 条)能导入、V2(再多去 3 个地点)也能导入、V3(全保留、但把手机里也有的行按手机原值重置)报错 ⇒ **问题在这 22 条到访本身或其附属记录**里,与地点、与「共享行被改过的值」无关。 - 这 22 条的构成:`Z_PK=[46,200]+998..1017`。其中 **46/200 其实是基线(09-14 包)自己的行** (同一 UUID、同一数据),只是**手机在 09-29 前把它们删掉了**(09-29v2 里没有这两个 UUID)—— 所以「按 UUID 是否在手机包里」分类会把它们误判成站内新建。 - 下一轮对照(都已过审计、位于 `data/bisect/`): `V4` = 保留这 22 条但删掉它们的附属记录(15 位移 / 16 天气 / 2 采样); `V5` = 删 `[46,200,998..1007]`;`V6` = 删 `[1008..1017]`。 - 判读:V4 ✓ ⇒ 罪在附属记录;V5/V6 哪个 ✓ ⇒ 罪在另一半的那 10~12 条里,继续对半拆。 ## 二分第二轮:V4 ✗ / V5 ✓ / V6 ✗ ⇒ 罪在 [46,200,998~1007],再收紧到 [998~1007] - V4(保留 22 条、只删它们的附属记录)**报错** ⇒ 附属记录(15 位移 / 16 天气 / 2 采样)无罪; - V5(删 [46,200,998~1007])**能进**、V6(删 [1008~1017])报错 ⇒ 罪在 V5 删掉的那 12 条里; - **46/200 被排除**:它们就是基线自己的行(同 UUID 同数据),而**纯净往返包里也有这两行且能导入** ✓ ⇒ 罪在 **[998~1007] 这 10 条站内新建行本身**(其余一切都已被 V5 证明是兼容的)。 - 静态差分:这 10 条与无辜的 [1008~1017] **逐列完全同形**(同为 v5 UUID、同标志位、ZRAW 都有值、TZ 都是 NULL) —— 看不出任何结构差异,只能继续对半。 - 第三轮:`V10` = 删 [998~1002]、`V11` = 删 [1003~1007](都在 `data/bisect/`)。 ## 二分定案:罪魁是 `ZVISIT #1000`(09-22 06:16→10:48,挂在重复地点 416 上) - 五个「只留一条」对照:**只有 P1000 报错**,P998/P999/P1001/P1002 都能导入 ⇒ 单条罪魁 = #1000。 - 顺带排除的:重复地点本身(手机包自己就有 8 组同名同坐标且都带访,坐标连低位都相同); UUID(#1000 的 UUID 就是标准的 v5("ZVISIT/1000"),与兄弟们同形);ZRAW 量级(2305 与手机的 2209 同量级); 重复组的 ZLASTARRIVALDATE_ 规律(手机各组也是各成员各值)。 - #1000 与四个无辜兄弟的**唯一差异 = 它的地点是 416**,而 416 是「广西壮族自治区邮政公司」的 **重复地点行**(与 417 同名同坐标全字段相同,只有 ZLASTARRIVALDATE_ 不同),且**只有 #1000 用它**; V1/V2/V10 能导入,正是因为那时 #1000 已删、416 成了没有到访的空壳。 - 判别包(都在 `data/bisect/`): `W1` = 只留 #1000 但把它的地点换成 417;`W2` = 只留 #1000 但把 416 的「最近到访」改成与 417 一致; `W3` = 只留 #1000 但把 ZRAW 改成 1421。 W1 能进 ⇒ 罪在「416 这一行 / 这个对」;W1 报错而 W2/W3 能进 ⇒ 罪在 416 的 ZLASTARRIVALDATE_ 或采样数。 ## 定案:真机拒收的根因 = `ZVISIT.ZRAW` 超过包内采样行数(已修,commit `d99c63f`) - 判别包结果:**只有 W3 能进**(W1 改地点、W2 改「最近到访」都报错)⇒ 罪魁是 `ZVISIT #1000` 的 **`ZRAW = 2305`**(「这次到访由多少个原始采样点归并而来」的计数)。 - 不变量:`ZRAW ≤ 包内 ZRAWVISIT 行数`。接受过的三份包(09-14 基线 / 09-20 A 包 / 验收纯净往返) 最大值都 ≤ 采样行数;被拒的那份只有 #1000 越界(2305 > 2246)。夹到 2246 以内即可导入 (W3 用 1421 验证过;被接受的包里 ZRAW 为 NULL/0 的行本来就有 39~139 条 ⇒ 往下夹安全)。 - 成因链:合并进来的到访带着**源包**的 ZRAW,而源包的 `ZRAWVISIT` 采样行站点不托管(只从基线带) ⇒ 计数与包内采样行数脱节。导出侧修法:写完行、清完悬空采样点之后,一条 UPDATE 把 `ZRAW` 夹到 `count(ZRAWVISIT)` 以内。用例 `TestExportClampsRawSampleCount`(变异验证过)。 - 顺手:`readDupBaseline` 先 os.Stat(sql.Open 默认 READWRITE|CREATE,路径不存在会静默建空库, 报「no such table」而不是「没有基线」)。 - 给用户的即时包:`data/20260930-fixed-zraw.rondbackup`(就是那份被拒的包,只把 #1000 的 ZRAW 2305→2246,其余一字未动,审计干净)。 - 更彻底的做法(记着):把数据集的**基线换成一份较新的手机包**——采样行来自基线, 基线越新,新到访的采样行越齐,`ZRAW` 也就天然自洽。 ## 基线替换:用户按链路操作,差异页显示「完全一致」(= 预期结果,不是失败) 用户把 App 导出的 `20260930_180637.rondbackup` 直接导入成新数据集,再用旧的站点导出 `20260930.rondbackup` 做差异合并 → 差异页说「与当前数据集完全一致:没有新增、没有被删、也没有冲突」。 原因:喂给 App 的那份包**就是站点自己的导出**(`20260930-fixed-zraw.rondbackup` = 站点导出只夹了 ZRAW), 所以 App 再导出来内容当然一样 ⇒ **换基线这一步已经达成**(新数据集的基线 = 一份新鲜手机包 ✓), 清单里的「把旧站点导出合并进去」在他这条路径上是**多余的**(那一步是为「上传一份全新手机包、 里面没有站点数据」的路径准备的)。 顺带记:新数据集的收益主要是**编号/存储对齐**(App 导入时会重排 Z_PK,新数据集的 src_pk 与它的基线同一套); 采样行(ZRAWVISIT)**不会**因此变多——那些采样点从来不在我们的包里(真实缺失), 以后靠导出侧的 ZRAW 夹取兜住即可。 ## 闭环完成:换基线成功并验证(2026-09-30 18:20) 用户流程:App 导出的 `20260930_180637.rondbackup` 直接导入成新数据集 → 网页端显示 **937 条到访** (= 包内 1001 − 64 条隐藏行 ✓ 说明 App 那份把数据放在 `-wal`(3.16MB)里也被我们完整读入 ✓)→ 以它为基线在网页端导出 `20260930_180637-export-20260930.rondbackup` → **导入 App 正常** ✓✓。 我这边独立验证(新导出 vs 新基线):跨表计数 **全部 ±0**(1001 到访 / 436 地点 / 815 位移 / 589 天气 / 2246 采样 / 6 方式 / 32 活动 / 8 标签);表 31/31、索引 47/47、`CREATE TABLE` 逐字符、 `Z_PLIST`/`Z_MODELCACHE` sha256 全部一致;`ZRAW` 最大 2246 ≤ 采样行 2246 ✓;打包层 bit3=0 ✓。 (App 那份的 zip flags 是 `0x0800`——只有 UTF-8 位,数据在 `-wal` 里,与我们 `0x0000` 的写法不同但都被接受。) 技术结论已写进 `rond-backup-parse` 技能:ZRAW 不变量、逐行二分手法(`tools/bisect_export.py`)、 换基线六步 + 自检、以及排查顺序里新增的「先验纯净往返包」与「值域不变量」两步。 ## 项目记忆纳入版本控制 + 注入上限取证(2026-09-30 18:30) 用户要求「记忆跟着项目走」,把 `.workbuddy/memory/` 从 gitignore 里放出来。 - `.gitignore` 挡路的是 `.workbuddy/` 那行。改成「反忽略」六行:`.workbuddy/` → `!.workbuddy/` → `.workbuddy/*` → `!.workbuddy/memory/` → `.workbuddy/memory/*` → `!.workbuddy/memory/*.md`。 ⚠️ 别用 `!.workbuddy/memory/**`——它把 memory 下的**任何**文件都放行。实测 `./web.test.exe` 混进 暂存过,所以收紧成只认 `.md`。复核用 `git check-ignore -q --no-index ` 逐条打靶: `memory/*.png`、`memory/sub/x.md`、`memory/MEMORY.md.bak`、`plans/a.md` 全 IGNORED, 三个 `.md` 全 included,`git add -An .workbuddy/` 恰好 16 个文件。 - 记忆里**没有真凭据**(唯一命中是 `2026-09-14.md` 里的 `password=rond-admin`,那是项目默认口令)。 - **注入上限取证**(以前一直靠猜):`app.asar` 里 `getWorkingMemoryContent()` 用 `var MAX_MEMORY_CHARS = 1e4`,比较的是 JS `content.length`——**字符数(UTF-16 单元),不是字节**; 中文 1 字算 1。超了就 `substring(0,1e4)` 且尾部静默丢失,只在当轮挂一句「ACTION REQUIRED」。 ⇒ 以前按「15.6KB 字节」判断是否超限是**错的**(中文文本 1 万字符≈1.5 万字节)。 本 details 与每日日志**都不参与注入**,只有 `MEMORY.md` 一个文件受约束。 - 索引现状:`MEMORY.md` 6604 字符 / 10526 字节,距上限留 34% 余量;仍有 12 节,34 节正文在 details。 本次知识已抽成**跨项目**技能:`~/.workbuddy/skills/workbuddy-memory-versioning/SKILL.md` (反忽略六行写法 + 只放行 .md 的理由 + `git check-ignore --no-index` 打靶复核 + `MAX_MEMORY_CHARS=1e4` 按字符判定 + 只有 MEMORY.md 参与注入 + CRLF 归一与 「`grep -c $'\r'` 在 Git Bash 里会全命中」这个假信号)。