diff --git a/CHECKLIST.md b/CHECKLIST.md index c11faf3..94a10c7 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -421,7 +421,7 @@ - **评审第 4 轮 FAIL**(同一独立 pi,7 项全跑,~557 工具轮;D40/D41 的修法实测成立,16 对逐字复现、五对 D40 全记上):三条——D42 就是上面 P1 那句被实验推翻(Go 层重发缺失字段,旧服务照样过);D43 `:412` 的机制句**方向写反**,我写「主语之前的裸数字并入主语键」,而代码的改名槽是 `index + 1`(主语 token 的**下一个**,`trust.py:275-276`),实测 `3 room holds 22 degrees` 主语是 `3`、不会向前融合,三个例子里只有 `2026 saw …` 数字在前、走的是 `trust.py:270-272` 首数字分支;D44 `_numbers_follow` 的**漏报侧没披露**:改不改名取决于每条记忆自己后面还留着没有第二个数字,`温度 22 度` + `温度 24 度 湿度 40`(`温度`/`温度24`)、`温度 22 度 湿度 40` + `温度 24 度`、`温度 22 度 湿度 40` + `温度 24 度 湿度 60`(`温度22`/`温度24`)、`预算 5000 元 超了 8000` + `预算 6000 元`(`预算5000`/`预算`)四对全 0 条,方向 fail-closed(两条都留在页上)。D44 按评审建议只**披露 + 加测试**(`test_a_second_number_in_one_memory_hides_the_update`),不用「未改名主语兜底比对」,那会把 `Room 3 holds 22 degrees` + `Room 4 holds 24 degrees` 退回 D37 的误报。四条精度说明全部采纳:P-a `degrees` 键也吞年份(`in 2026` → `{'degrees':'2026'}`),P5 那句已改成「第二个数字,含年份」;P-b 反向实验的红名单是 4 条不是 5 条(全套 4 failed / 379 passed,无连带);P-c 「数字只要站在某个词之后就是量不是名字」已就地加限定词(且不在紧挨主语那个位置);P-d 8799 守卫仍只在开跑时探一次,现在指纹补上了**旧构建**答题这一口——同树残留的服务占住端口时指纹仍给 200(评审实测:同树同 `ELY_EYE_AUTH_TOKEN` 的服务把 5 条 atom 落进它自己的 home,直到挂载卡带才失败),那种情形仍只有 busy-port 守卫能挡。该轮同时实测确认:门禁全绿且 mypy 两边逐字相同、tokenize 0 块、最大文件 `bench/systems.py` 498 行;D41 的新测试文案与实测一致;P4 的 stamp 判断站得住(`bench/leg_report.py` 两处 blob 全同 `40596d29…`)。判定:p2-3.1/3.2/3.3 与两条验收都能 `[x]`,p2-3.4 因 D43/D44 的记录级缺陷暂缓。 - **评审第 5 轮 PASS**(同一独立 pi,7 项全跑,本轮 diff 仅 3 个文件 +31/−5、机制 blob 全同:`trust.py d0b1a090`、`memory.py 13b0ebbf`、`retrieval.py 305d6e3a`、`compiler.py 5d341ca5`、`api_memory.py 22a03666`):D42/D43/D44 全部按实测改掉并被独立复现——D42 的判别子它自己起两边服务量过(`4c6af87` 的 `/api/memory/working` 404、HEAD 200);D43 三个主语实测 `3` / `room3` / `2026`;D44 四对逐对 0 条且贴出了页面(两条都留着),新测试的两条断言它逐条读过(第二条真的检查了「都在页上」)。**「不用未改名主语兜底比对」这个判断它用实验支持了**:把 `quantity_conflict` 改成主语不等时退回比值键,`Room 3 holds 22 degrees` + `Room 4 holds 24 degrees` 立刻变回 `('holds','22','24')`,即第 2 轮 D37 的误报复活。反向实验(去掉 `_numbers_follow` 条件)红名单仍是 4 条、全套 4 failed / 379 passed,**新加那条不变红**(无条件改名下它两条断言仍成立)。20 对冲突 + D40 五对 + `Room 3 is big` 边界逐字复现。门禁全部重跑且与本报告逐格一致(384 passed、六文件 83、conflicts 27、三文件 35、mypy 与 `9cb46c1` 文件集合与去行号文本逐字相同、tokenize 148 个 `.py` 0 块、最大文件 `bench/systems.py` 498、go `-count=1` 全套 + gofmt 空、`index-CSDHCFNo.js 221.48 kB`、smoke PASS)。两条精度说明(不算缺陷)已采纳:P-e 把 D44 成因从「一侧改名」改成「改名后主语键各自吃进自己的数字,两侧都改名也永不相等」(我另测 `温度 22 度 湿度 40` + `温度 24 度 湿度 40` → 0 条证实对称情形);P-f 把「指纹补上 8799 那一口」限定为旧构建那一口。判定:p2-3.1/3.2/3.3/3.4 与两条验收全部 `[x]`。 - 钥匙测试场景 3 暂露的真缺口(终验时补):`v_atoms_current` 只答「现在开着」的记忆,所以被 supersede 的旧事实**无论怎么设 `since`/`until` 都答不出来**——那两个字段框的是 event time,不是「从哪个时刻答题」。补成能力:`SearchFilters.as_of`(`schemas.py`),`store_search.py` 把视图换成 `atoms` + 两分支谓词(不带 as_of 时逐字重复视图条件,带时 `valid_from <= as_of < valid_to`、`event_time <= as_of`、`retention_until > as_of`,且**不看 `archived_at`**,因为归档是存储层不是关于世界的断言),`vector_index.py:_allowed()` 同一套窗口,`retrieval.py:119` 透传,CLI `search --as-of`。 - - 评审第 2 轮实测把这条能力拆穿了一半,三处已按机制修掉(不是加披露):**D9** 那个名字不副实的 dense 测试根本没开 dense(`config.py:36 dense_embeddings_enabled=False`),它把 `VectorIndex.search` 包一层计数,跑完 `test_as_of_search.py` 与 `test_search_api.py` 记下 **0 次调用**;把 `_allowed` 的 as_of 分支换成现行分支,`test_as_of_search.py` 仍 5 passed、key_test 仍 exit 0;删掉 `retrieval.py:119` 也一样。现在那条测试自己开 dense(假 embedding 模型),既直接调 `index.search(..., as_of=…)`,也调 `retrieval._dense_search(...)` 透传 criteria,两条都钉住。**D10** 真开着 dense 时两条腿不一致:词腿在 `as_of` 下回「钥匙放在玄关抽屉里」,`retrieval._dense_search` 回 **空**——因为 `_current`/`_stale`/`_expected`(`vector_index.py:397`、`:451`、`:474`)都读 `v_atoms_current`,退役的记忆**连向量都没有**,dense 的 as_of 分支只能排除、召不回旧事实。改法:一个还存在的记忆就保留向量(三处改读 `atoms`),谁能看由 `_allowed` 决定;`_expected` 的计数同步,否则 `pending` 会永远不为 0。**D11** 排期到未来的 supersede 不对称:旧行 `valid_to=2099`、新行 `valid_from=2099`,不带 as_of 的搜索回 **0 命中**(现行读法只认 `valid_to IS NULL`,把「明年才失效」的记忆当成已经不真),而 dense 腿回 1 行——两边谓词不一致。改法:「现在开着」在三处统一成 `valid_to IS NULL OR valid_to > now`(视图 `migrations.py:191`、`store_search.py:71`,`vector_index.py:268` 早就是这样),schema 因此从 v11 升到 **v12**,并把 `DROP VIEW` 的闸门从 `version < 10` 放宽到 `version < 12`(`CREATE VIEW IF NOT EXISTS` 会留着旧视图的旧身体,升级过的库必须被提醒重看一遍)。验收:`pytest backend/tests/test_as_of_search.py` → **6 passed**(旧记忆答它当真的那阵、下周的记忆不答今天的问、归档前的时段仍答、as_of 不借别人的记忆、dense 腿与文字腿读同一个窗口、明年才退役的记忆今天仍答)。五个变异各自只红一条测试(这是评审要的判别子):M1 dense as_of 分支拒绝任何退役行 → dense 测试红;M2 删 `retrieval.py:119` → dense 测试红;M3 三处改回读 `v_atoms_current` → dense 测试红;M4 `store_search.py` 现行分支退回 `valid_to IS NULL` → 排期那条红;M5 视图退回 `WHERE valid_to IS NULL` → 排期那条红。变异脚本必须备份它碰过的每个文件,别在树里还有未提交改动时用 `git checkout --` 还原(我这样丢过一次自己的 store_search 修复,症状是「还原后仍红」)。 + - 评审第 2 轮实测把这条能力拆穿了一半,三处已按机制修掉(不是加披露):**D9** 那个名字不副实的 dense 测试根本没开 dense(`config.py:36 dense_embeddings_enabled=False`),它把 `VectorIndex.search` 包一层计数,跑完 `test_as_of_search.py` 与 `test_search_api.py` 记下 **0 次调用**;把 `_allowed` 的 as_of 分支换成现行分支,`test_as_of_search.py` 仍 5 passed、key_test 仍 exit 0;删掉 `retrieval.py:119` 也一样。现在那条测试自己开 dense(假 embedding 模型),既直接调 `index.search(..., as_of=…)`,也调 `retrieval._dense_search(...)` 透传 criteria,两条都钉住。**D10** 真开着 dense 时两条腿不一致:词腿在 `as_of` 下回「钥匙放在玄关抽屉里」,`retrieval._dense_search` 回 **空**——因为 `_current`/`_stale`/`_expected`(`vector_index.py:397`、`:451`、`:474`)都读 `v_atoms_current`,退役的记忆**连向量都没有**,dense 的 as_of 分支只能排除、召不回旧事实。改法:一个还存在的记忆就保留向量(三处改读 `atoms`),谁能看由 `_allowed` 决定;`_expected` 的计数同步,否则 `pending` 会永远不为 0。**D11** 排期到未来的 supersede 不对称:旧行 `valid_to=2099`、新行 `valid_from=2099`,不带 as_of 的搜索回 **0 命中**(现行读法只认 `valid_to IS NULL`,把「明年才失效」的记忆当成已经不真),而 dense 腿回 1 行——两边谓词不一致。改法:「现在开着」在三处统一成 `valid_to IS NULL OR valid_to > now`(视图 `migrations.py:191`、`store_search.py:71`,`vector_index.py:268` 早就是这样),schema 因此从 v11 升到 **v12**,并把 `DROP VIEW` 的闸门从 `version < 10` 放宽到 `version < 12`(`CREATE VIEW IF NOT EXISTS` 会留着旧视图的旧身体,升级过的库必须被提醒重看一遍)。验收:`pytest backend/tests/test_as_of_search.py` → **6 passed**(旧记忆答它当真的那阵、下周的记忆不答今天的问、归档前的时段仍答、as_of 不借别人的记忆、dense 腿与文字腿读同一个窗口、明年才退役的记忆今天仍答)。变异实测(评审第 4 轮自己量的,不是我预测的):M1 dense as_of 分支拒绝任何退役行 → **红 2 条**(dense 测试 + `test_a_superseded_memory_keeps_its_vector_but_leaves_the_current_answer_alone`);M2 删 `retrieval.py:119` → 红 1 条;M3 三处一起改回读 `v_atoms_current` → 红 2 条,拆开看 `_current` 红 2、`_expected` 红 1、`_stale` 当时红 **0**;M4 `store_search.py` 现行分支退回 `valid_to IS NULL` → 红 1 条;M5 视图退回 `WHERE valid_to IS NULL` → 红 1 条。`_stale` 那个 0 红的洞本轮补了(见 p2-4 第 4 轮那条)。变异脚本必须备份它碰过的每个文件,别在树里还有未提交改动时用 `git checkout --` 还原(我这样丢过一次自己的 store_search 修复,症状是「还原后仍红」)。 - 顺带:`cli.py` 495→432 行(写路径拆进 `cli_memory.py`),`bench/leg_check.py` 拆出 `bench/leg_report.py`;typer 0.27.2 用**关键字**调用回调,`*atom_ids: str` 形式的命令一调就 `TypeError`,改成 `list[str]`——这才是之前 `test_maintenance` 那条 TypeError 的真因,命令重名只是表象。报告 stamp 早于 harness 的缺陷也修了(`a52c104`)。 - [x] **p2-4** 共享记忆 - [x] p2-4.1 多用户/多 agent 作用域 + ACL(schema v11:holder token + 三态眼睛) @@ -442,10 +442,13 @@ - **评审第 2 轮 FAIL**(同一独立 pi,`df812f3`,7 项全跑):D1–D8 全部实测确认已修。as_of 的行为面它自己写脚本测通:退役的记忆在 `as_of` 下应答、下周的事今天不应答、归档不挡 as_of、不跨 holder 借看。`scripts/key_test.sh` 在 `df812f3` 上 exit 0(6 PASS + 1 NOT MEASURED),把 `store_search.py` 换回父提交就红在场景 3。新缺陷六条,三条行为三条记录:**D9** 那条 dense 测试名字不副实、as_of 的向量侧零覆盖(包一层计数实测 0 次调用;改 `_allowed` 的 as_of 分支、删 `retrieval.py:119` 都全绿);**D10** 真开 dense 时两条腿不一致,词腿回旧事实、dense 腿回空,因为退役记忆根本没向量(`_expected`/追赶都读 `v_atoms_current`),commit message 里「cannot disagree」写过头;**D11** 排期到未来的 supersede 不披露且不对称:不带 as_of 的搜索 0 命中、`GET /api/atoms` total 0、dense 腿 1 行;**D12** `:430` 那个旧行号括号仍是错的;**D13** p2-5 验收 grep 实测 **2 命中**不是 0(它扫到了 CHECKLIST 自己引用的被删字符串);**D14** `:455` 写「10 项闭合、2 项部分闭合」与 Gaps §0.5 自己那张表对不上(实为 8 / 3 / 1)。六条已全部按机制修完,见上面 p2-3 那条 as_of 与下面本轮门禁格。**读错评审答案的教训(真实代价)**:我第一次读的是它 session JSONL 里最后一个文本块的 PASS 表格,据此拆出一份错的 D-list(compare_digest / 历史行没钉提交 / 401 vs 406 / 缓存 key 后果 / video harness 写死报告),`cf2de31` 就是按那份错的清单修的——那些修法本身都成立(它们是评审的精度说明与上一轮残留),但不是本轮的 D9–D14,而且第 3 轮 prompt 已按错清单发出去了(作废重发)。以后读 verdict:先按 index 数文本块、只读它自己的「缺陷清单」那一节,不读 PASS 表。另采纳它的四条精度说明(不算缺陷):本轮范围是 3 个 commit 不是 2;naive `--as-of` 被接受(与 `--since` 同策略,awareness 校验只在 `schemas.py:128-145` 的 `EvidenceAtom` 上,`SearchFilters` 没有);smoke 的端口守卫只在启动前查(它曾拿到一个正在死的 server:「FAIL: the session did not reload in the order it was recorded: []」);`browse.py` 那条子句只被 pytest 卡住(删掉它 key_test 与 smoke 都绿,因为场景 5 测的是隔离不是 ACL)。 - **第 3 轮作废重发**:`cf2de31` 那一轮是按我读错的 D-list 发的(见上面那条教训),它验的仍是记录面那五条;本轮真正的 D9–D14 已在 `eab6bd7` 之后修完,重发的 prompt 把三条行为缺陷的复现方式写死(dense 腿零覆盖的包层计数、`_expected` 的向量计数、排期 supersede 的三种读法)。 - **评审第 3 轮 FAIL**(`cf2de31`,D15–D22,全部实测):D15 = 我写「dense 腿也验了」时它其实是零覆盖——`test_as_of_search.py:105` 没开 `dense_embeddings_enabled`、没建 `VectorIndex`,在 `VectorIndex.search` 里加 `print("[CALL]")` 对该文件 **0 次**(`test_vector_index.py` 17 次),真开 dense 后 words 腿 `as_of=上周+1` 答抽屉、`index.search(as_of=…)` 返空,`expected_vectors` 只算出 1、`vec_atoms` 只有一行;把 `_allowed` 里 6 个 `as_of` 参数全绑成 `None` 仍 `5 passed`。⇒ 第 2 轮的 D9/D10 既没修也没记,却被写成已验。**已由 `92237c8` 修**(D9 测试真开 dense 并断言两条腿读同一窗口、D10 向量改读 `atoms`、D11 币种统一 + schema v12,五条变异各打红一条)。D16/D17 = 我上一行把功劳写错了对象:「在 `209f2d0` 重现旧 key 无身份成分」是**第 3 轮**做的,且实测比这句话多一层——`209f2d0:compiler.py:43` 根本不传 filters,那里没有「陌生人那份」可串,`compiler.py:33 if compiled.hits:` 说明空 pack 从不被端出;`as_holder or ""` 这个串**代码里从来没有**,只在 CHECKLIST 里出现过(`git log -S` 列出 `83dfc14`/`df812f3`/`cf2de31` 三个提交,都是改 CHECKLIST 的)。D18 = D-list 被换掉(已由 `92237c8` 恢复)。D19 = 「当前 411」是我自己工作树的数,被审的 `cf2de31` 是 410。D20 = 我那句「视频 harness 会拒写冻结报告」三处不实:`VIDEO_AUDIO.md`/`video_audio.json` 当时**未入库**(worktree 里根本没有可弄脏的冻结件)、worktree 跑不到写那步(死在 `no store to copy` / `123 store sources are not corpus files`)、守卫按 cwd 相对比较所以**绝对路径指向冻结件本身也能过**;且该 harness 是 `cf2de31` 自己引入的,「已修」修的是同一提交刚造的东西。D21 = `--limit 1 --probes 1`(含 `--probes 5`)死在 `aggregate needs at least one answerable query`、exit 1、零产物——冒烟旋钮在小数值时不是「少测」而是崩。D22 = 第 2 轮的 D13/D14 未动(已由 `92237c8` 修)。 - - 本轮(D15–D22 收尾)harness 三修:`refuse_frozen_overwrite` 改成比较 `resolve()` 后的目录(绝对路径指向冻结件也拒,D20);新增 `probe_sample(probes, limit)`,`--probes 0` 关掉该臂;当 `--limit` 把样本收到只剩视频时小 count 会整块跳过该模态(评审量的 `--limit 1 --probes 1` 与 `--probes 5` 就是),此时在**二十分钟 ingest 之前**就 `SystemExit` 点名「picked no answerable video query…Pass --probes 0 to skip the probe arm, or the shipped 300」(D21:分层样本按全库跨步,小 count 会整块跳过本模态);`rank_arm` 空样本返 `None`,`_required` 保证视频臂不会静默变成一行零,`_score_of`/markdown 打印 `skipped`。新增 3 条测试(绝对路径守卫、`probe_sample` 两态、`_required`/`_score_of`)。 - - 本轮另修的两处产品事实(都是 D10/D11 带出来的):一个还存在的记忆就保留它的向量(`vector_index.py:397`/`:451`/`:474` 改读 `atoms`),谁能看由 `_allowed` 决定;「现在开着」的谓词在视图与两条搜索腿里统一成 `valid_to IS NULL OR valid_to > now`,schema 以 **v12** 记录这次语义改变,升级时 `DROP VIEW`(`CREATE VIEW IF NOT EXISTS` 不会换身体)。`db.close_atoms` 的 `WHERE valid_to IS NULL` 不是现行读法而是写闸门(别去闭一条已经闭上的),故意保留。 - - 本轮门禁(D9–D14 修完,数字钉在本轮的 commit 上):`pytest -q` **411 passed**(+1 排期 supersede 测试;`test_superseding_moves_the_vector_rather_than_keeping_two` 改成新契约 `test_a_superseded_memory_keeps_its_vector_but_leaves_the_current_answer_alone`:vectors **2**、现行搜索只回替换者、`as_of` 下回旧者);`ruff check backend/src/ely_eye backend/tests bench` 干净;`bench/filesize_check` 干净;`bench.audit --product` → `audited 33 harness files, 2 reports, 6 set files` / `clean`;`./node_modules/.bin/pyright` 对 6 个改动文件 → **0 errors**;mypy 冷缓存对同 6 个文件 `Success: no issues found`;五个变异 M1–M5 各自只红一条测试(见 p2-3 那条 as_of)。 + - 本轮(D15–D22 收尾)harness 三修:`refuse_frozen_overwrite` 改成比较 `resolve()` 后的目录(绝对路径指向冻结件也拒,D20);新增 `probe_sample(probes, limit)`,`--probes 0` 关掉该臂;当 `--limit` 把样本收到只剩视频时小 count 会整块跳过该模态(评审量的 `--limit 1 --probes 1` 与 `--probes 5` 就是),此时在**二十分钟 ingest 之前**就 `SystemExit` 点名「picked no answerable video query…Pass --probes 0 to skip the probe arm, or the shipped 300」(D21:分层样本按全库跨步,小 count 会整块跳过本模态);`rank_arm` 空样本返 `None`,`_required` 保证视频臂不会静默变成一行零,`_score_of`/markdown 打印 `skipped`。本轮新增 7 条测试(评审第 4 轮 D25 纠正了我写的「3 条」):`bench/tests/test_video_audio_check.py` 4 → 8(绝对路径守卫、`probe_sample` 两态、`_required`/`_score_of`、size cap 等),新建 `bench/tests/test_scratch.py` 3 条(`HELD` 认领:活人拒、死人接管、同进程可重复),套件 411 → 418。 + - 本轮另修的两处产品事实(都是 D10/D11 带出来的):一个还存在的记忆就保留它的向量(`vector_index.py:397`/`:451`/`:474` 改读 `atoms`),谁能看由 `_allowed` 决定;「现在开着」的谓词在视图与两条搜索腿里统一成 `valid_to IS NULL OR valid_to > now`,schema 以 **v12** 记录这次语义改变,升级时 `DROP VIEW`(`CREATE VIEW IF NOT EXISTS` 不会换身体)。`db.close_atoms` 的 `WHERE valid_to IS NULL` 不是现行读法而是写闸门(别去闭一条已经闭上的),故意保留。`retrieval.py:107` 原先写「索引会在 `search` 里追上」是**错的**(评审第 4 轮 P-b):`_stale` 只被 `sync()`(`:104`) 与 `stats()`(`:348`) 用,`search` 里没有追赶;后果是 v11 的库升到 v12 后 `pending=2`、dense 腿两种读法都返空,必须跑 `ely-eye build-dense-index`。本轮把那句 docstring 改成实测行为(写的时候索引;升级过的库是例外,`ely-eye doctor` 会点名 `the dense index holds X of the Y memories with words; run \`ely-eye build-dense-index\``,实测谓词在 `senses.py:44` + `maintenance.py:_check_indexes`)。 + - 本轮门禁(D9–D14 修完,数字钉在本轮的 commit 上):`pytest -q` **411 passed**(+1 排期 supersede 测试;`test_superseding_moves_the_vector_rather_than_keeping_two` 改成新契约 `test_a_superseded_memory_keeps_its_vector_but_leaves_the_current_answer_alone`:vectors **2**、现行搜索只回替换者、`as_of` 下回旧者);`ruff check backend/src/ely_eye backend/tests bench` 干净;`bench/filesize_check` 干净;`bench.audit --product` → `audited 33 harness files, 2 reports, 6 set files` / `clean`;`./node_modules/.bin/pyright` 对 `92237c8` 改动的 **5 个 `.py`**(该提交共改 7 个文件,评审第 4 轮 D24 纠正了我写的「6 个改动文件」)→ **0 errors**;mypy 冷缓存对同 5 个文件 `Success: no issues found`;变异 M1–M5 的红条数见上面 p2-3 那条(不是「各红一条」)。 - 门禁(`83dfc14` 那轮量的 401;`eab6bd7` 加 5 条 as_of 测试后 406,`cf2de31` 加 4 条视频 harness 测试后 **410**(评审第 3 轮 D19:我原先写「当前 411」是我工作树里的数,被审 commit 是 410),`92237c8` 加 1 条排期 supersede 测试后 **411**):`pytest -q` **401 passed**;`ruff check` 对 22 个改动文件干净;`bench/filesize_check` 干净;`bench.audit --product` → `audited 32 harness files, 2 reports, 6 set files` / `clean`;mypy 冷缓存 **24 errors in 9 files (checked 112 source files)**,与 `9cb46c1` 基线(24/9)**净新增 0**;`./node_modules/.bin/pyright` 对 17 个后端源文件 + 5 个测试文件 → **0 errors**;`go build` / `go vet` / `go test`(apiclient ok、mcpserver ok)+ `gofmt -l` 空;`npm run build` → `dist/assets/index-CSDHCFNo.js 221.48 kB`(bundle 哈希与 `246dae9` 相同,与「本轮没动前端」一致)+ eslint exit 0;`bash scripts/mcp_write_smoke.sh` 3.3s 两行 PASS。 + - **评审第 4 轮 FAIL**(同一独立 pi,8 项全跑,diff `cf2de31..efbca0b`):功能面它自己复验通过——门禁全绿(`pytest -q` 418、mypy 冷缓存 24/9 与 `9cb46c1` worktree 逐条同消息 ⇒ 净 0、pyright 0、go 全套 + `gofmt -l` 空、`npm run build` 221.48 kB + eslint 0);`VectorIndex.search` 插桩在 `test_as_of_search.py` 打出 6 次 `as_of`(第 3 轮是 0 次);排期 supersede 两腿同向;认领机制四条对照全测通;D9/D11/D17/D18/D19/D21/D22 判为完全修好。七条新缺陷全在记录与判别子上:**D23** 「五个变异各红一条」不实(M1 红 2、M3 三处一起红 2、`_stale` 单独改红 0);**D24** pyright/mypy 的文件集写错(`92237c8` 改 7 个文件、其中 5 个 `.py`);**D25** 「新增 3 条测试」少算 4 条;**D26** 两处接线没有判别测试(`_tracked` 恒假 → 418 全绿,两条守卫测试都 monkeypatch 它;删掉 `copy_store` 里的 `claim(scratch)` → 418 全绿,scratch 测试只直接调 `claim()`);**D27** 本轮自己在 `bench/tests/test_video_audio_check.py:53-54` 造了一处整行 `#` 两行块,而记录说全仓 0;**D28** v10/v11→v12 的 `DROP VIEW` 闸门无测试(`version < 12` 改成 `version < 10` 全绿,手工升级的库里视图 0 行、`browse` total=0 而 search 有 1);**D29** 「绝对路径也拒」只在同一 checkout 成立(worktree 里指主仓的冻结件被放行)。四条精度说明:P-b 见上面 `retrieval.py` 那条;P-c 提交信息「Six harnesses share that copy path」不准,六个默认 scratch 各不相同、共用的是代码路径不是目录;P-e `_alive` 对垃圾内容 fail-open(`not-a-pid` → 接管,无 `/proc` 同样放行,docstring 自陈);P-g 「整行」口径已在 p1-6.4 写明,允许行尾注释的算法在基线上就有 2 块。 + - 本轮按机制修,不按披露修:`_tracked` 改问**拥有那个路径的仓库**(`git -C <目录> ls-files --error-unmatch <文件>`),守卫去掉目录比较——**任何被 git 跟踪的文件都是冻结证据**,worktree 指主仓的 `LEGS.md` 也拒(D29);补四条判别测试:`test_a_committed_report_is_frozen_without_any_stub`(不 stub `_tracked`,用真 git 索引)、`test_copy_store_claims_the_copy_it_makes`(活人占着 scratch 时 `copy_store` 拒,且断言 `data/` 还没被创建 ⇒ 复制一个字节之前就拒)、`test_a_closed_memory_owes_the_index_a_vector_even_though_it_answers_nothing_now`(`_stale` 改读视图 → 正好它红)、`test_an_eleventh_version_database_learns_that_a_future_retirement_is_still_current`(先断言 v11 视图体下确实看不见,再升级断言看得见)。本轮四个新变异各红一条:M5 `version < 12`→`version < 10` → 只红 v11 升级那条;M6 `_stale` 改读 `v_atoms_current` → 只红那条向量测试;M7 `copy_store` 去掉 `claim()` → 只红认领那条;M8 `_tracked` 恒假 → 只红不 stub 那条。 + - 本轮门禁(钉在本轮 commit):`pytest -q` **422 passed**;`ruff check backend/src/ely_eye backend/tests bench` 干净;`bench.filesize_check` 干净;`bench.audit --product` → `audited 33 harness files, 2 reports, 6 set files` / `clean`;mypy 冷缓存对 9 个触及文件 `Success: no issues found`,全仓 **24 errors / 9 files**(= `9cb46c1` 基线,净 0);`./node_modules/.bin/pyright` 对同 9 个文件 **0 errors**;全仓整行 `#` 连续 ≥2 的块 **0**(157 个 tracked `.py`,本轮把那处收回一行,D27)。本轮未动 Go 与前端,沿用 `efbca0b` 那轮量的 go 全套 + `gofmt -l` 空 与 `npm run build` 221.48 kB + eslint 0。 - [x] **p2-5** 文档单一真相 - [x] p2-5.1 `AGENTS.md` 重写并停止 gitignore - `.gitignore` 删了 `AGENTS.md` 那一行(`CLAUDE.md` 仍忽略,它不是本仓库的真相源),`git check-ignore AGENTS.md` 现在 exit 1。新文件 5.5KB,写的不是风格偏好而是**这个仓库付过学费的约束**:门禁命令清单(含 mypy 基线是 24 errors in 9 files、必须在分支点的 worktree 里量而不是猜);一个 writer 一条规则(Go 不写 SQLite);fail fast 必须点名(引真实报错 `nothing to forget: no atom or source was given`);≤500 行、无死代码、注释单行、docstring 是契约;「缺席是一种状态」——`v_atoms_current` 隐藏而不删除。 @@ -472,7 +475,7 @@ - 为什么要有这一条:验收线 `recall@10 ≥ 0.90`(m00217 定的)现在 0.863,而缺口不是均匀的。按全库可答题数加权(code 1237/2928=0.4225、pdf 766=0.2616、ui_screenshot 320=0.1093、image 240=0.0820、video 215=0.0734、conversation 150=0.0512)用当前每模态实测重算:0.945×0.4225 + 0.885×0.2616 + 0.818×0.1093 + 0.720×0.0820 + 0.493×0.0734 + 0.965×0.0512 = **0.865**,距 0.90 差 **0.035**。 - 谁能补上这 0.035:ui_screenshot 0.818→0.95 只值 +0.0144,video 0.493→0.75(即音频就抬 +0.256)值 +0.0189,image 0.720→0.85 值 +0.0107——**三条加起来 +0.044 才过线,任何单条都不够**;code/pdf/conversation 已经没多少可抬。所以这不是“再调一下排序”能解决的,是图片与视频的内容表示问题。 - [ ] p2-6.1 音频进视频:`bench/video_audio_check` 在 129 个视频上重录一遍(同库副本、同题、同 words-only 路径,只差音频),报告 `bench/reports/VIDEO_AUDIO.md`。验收 = 两行对照 + 当时的机器负载 + 音频 atom 数;跑前已量到 before 行复现 LEGS.md 的 shipped 0.863(同一产品路径的交叉校验)。 - - [ ] p2-6.2 给截图一个局部的图片腿:整屏的 CLIP 向量不含「搜索图标」这种局部信息(VISUAL.md 实测 picture 腿把 ui_screenshot 从 0.583 拉到 0.528,因为它把整屏编成一个向量)。试法:一张屏切成重叠小块,**每块一个 atom**(`layout` 带 bbox,source 不变所以 source 级 recall 可比),CLIP 只编块。验收 = `bench/visual_recall.py` 加一臂,与现有 arms 并排(image n=240、ui_screenshot n=320);不涨就删掉该臂并把“不涨”写进 VISUAL.md,不留在默认路径里。 + - [x] p2-6.2 给截图一个局部的图片腿:**假设被实测推翻,默认路径不动**。池实验(`/tmp/region_clip.py`,日志 `bench/runs/region_clip.log`):275 张 gold 截图、320 条查询、chance@10 = 0.036,三臂只比排名——屏上文字(空白分词的 BM25,比现行 CJK 词腿弱)0.584(text 135/169 = 0.799、icon 52/151 = 0.344);整屏 CLIP 0.269(text 0.272、icon 0.265);OCR 框裁块 CLIP(每屏取 ~36 块的最高分)0.475(text 0.663、icon 40/151 = 0.265)。读法:裁块**确实**好过整屏(0.475 vs 0.269,VISUAL.md 那条「picture 腿把 ui_screenshot 从 0.583 拉到 0.528」的整屏结论没有被推翻),但两条图片腿都打不过屏上的文字,而在真正需要图片的 icon 半边,裁块与整屏同为 0.265、文字反而最高(0.344)——**裁块没有补上 icon 的缺口**,所以不加 `bench/visual_recall.py` 的新臂、不进默认路径,按 p2-6.2 自己写的「不涨就删掉该臂并把不涨写进去」处理。口径交代:该实验在 275 张池内排名,不是 5 万源的现行排序;文字臂是空白分词,故它是被低估的基线,图片臂输给的是一个更弱的对手。 - [ ] p2-6.3 若仍不达 0.90:把「不达」与原因写进 README/Gaps(已有的 VLM 区域描述、widget tree 两条外部能力,本轮按 m00217 未下 8B VLM),不用改验收线口径硬凑。 - [ ] **final-accept** Gaps.md §6「钥匙测试」7 条场景逐条跑通 - 命令:`bash scripts/key_test.sh`(自建临时 home、假模型快照、起 API、Go MCP 走 stdio、CLI 挂卡带;一张表,任何 FAIL 退出码非 0)。实测:**6 PASS + 1 NOT MEASURED**,exit 0。 diff --git a/backend/src/ely_eye/retrieval.py b/backend/src/ely_eye/retrieval.py index e7bd055..4c0a6df 100644 --- a/backend/src/ely_eye/retrieval.py +++ b/backend/src/ely_eye/retrieval.py @@ -104,7 +104,9 @@ class RetrievalService: Every filter is applied inside the engine, in the same statement as the nearest-neighbour walk, so a scoped search cannot return a memory the caller was never allowed to see. The - index catches up inside `search`, so nothing here has to rebuild it first. + writer indexes a memory in the call that saves it, so a search never waits for a rebuild; + a store upgraded from an older schema is the exception, and `ely-eye doctor` names it until + `ely-eye build-dense-index` runs. """ query_vector = self.embeddings.embed_query(query) return self.vectors.search( diff --git a/backend/tests/test_memory_schema.py b/backend/tests/test_memory_schema.py index 0e7ab88..e9ba929 100644 --- a/backend/tests/test_memory_schema.py +++ b/backend/tests/test_memory_schema.py @@ -311,3 +311,34 @@ def test_a_ninth_version_database_gains_the_tiers_in_place(tmp_path: Path) -> No assert hidden == 0, ( "the old view body survived the upgrade, so an archived memory would still answer" ) + + +def test_an_eleventh_version_database_learns_that_a_future_retirement_is_still_current(tmp_path: Path) -> None: + """v11's view hid a memory whose retirement is scheduled for next year; the upgrade must rewrite the body.""" + clock = datetime.now(UTC) + database = _database(tmp_path) + _seed_source(database) + database.upsert_atoms([_atom("key", ingested_at=clock, valid_from=clock, valid_to=clock + timedelta(days=365))]) + with database.connect() as connection: + # The body v11 shipped, written out here so the upgrade is judged against the thing it replaced. + connection.executescript( + """ + DROP VIEW IF EXISTS v_atoms_current; + CREATE VIEW v_atoms_current AS + SELECT * FROM atoms + WHERE valid_to IS NULL + AND (valid_from IS NULL OR valid_from <= strftime('%Y-%m-%dT%H:%M:%f+00:00','now')) + AND (retention_until IS NULL OR retention_until >= strftime('%Y-%m-%dT%H:%M:%f+00:00','now')); + PRAGMA user_version = 11; + """ + ) + hidden = connection.execute("SELECT count(*) FROM v_atoms_current").fetchone()[0] + assert hidden == 0, "the fixture must really look like a v11 store before it is upgraded" + + upgraded = _database(tmp_path) + with upgraded.connect() as connection: + assert int(connection.execute("PRAGMA user_version").fetchone()[0]) == SCHEMA_VERSION + visible = [str(row[0]) for row in connection.execute("SELECT atom_id FROM v_atoms_current")] + assert visible == ["key"], ( + "the old view body survived, so a memory retiring next year would answer nothing today" + ) diff --git a/backend/tests/test_vector_index.py b/backend/tests/test_vector_index.py index bb00901..a982faa 100644 --- a/backend/tests/test_vector_index.py +++ b/backend/tests/test_vector_index.py @@ -244,6 +244,22 @@ def test_a_superseded_memory_keeps_its_vector_but_leaves_the_current_answer_alon assert list(then) == [old.atom_id], "asked before the retirement, the old memory is still findable" +def test_a_closed_memory_owes_the_index_a_vector_even_though_it_answers_nothing_now( + tmp_path: Path, fake_embeddings: Callable[[], Any] +) -> None: + """`sync` walks the atoms table, not the current view, or an as-of question would find nothing.""" + settings, database = _settings(tmp_path), Database(_settings(tmp_path)) + index = _index(settings, database, fake_embeddings) + memory = _memory(settings, database, index) + old = memory.remember(KEYS, holder_id="wife", origin=MemoryOrigin.self_) + memory.supersede(old.atom_id, text=PASSPORT, holder_id="wife") + index.drop_atoms([old.atom_id]) + + assert index.stats()["pending"] == 1, "the closed memory answers nothing now, but the index still owes it a vector" + assert index.sync()["encoded"] == 1, "sync re-encodes the closed memory rather than skipping it" + assert index.stats()["vectors"] == 2 + + def test_a_store_that_never_built_its_index_says_so_instead_of_stalling_a_search( tmp_path: Path, fake_embeddings: Callable[[], Any] ) -> None: diff --git a/bench/tests/test_scratch.py b/bench/tests/test_scratch.py index 8f5c0c9..b4ba117 100644 --- a/bench/tests/test_scratch.py +++ b/bench/tests/test_scratch.py @@ -29,3 +29,18 @@ def test_one_run_may_copy_more_than_once(tmp_path: Path) -> None: scratch.claim(tmp_path) scratch.claim(tmp_path) assert (tmp_path / scratch.HELD).read_text(encoding="utf-8") == str(os.getpid()) + + +def test_copy_store_claims_the_copy_it_makes(tmp_path: Path) -> None: + """The claim lives inside `copy_store`, so a harness cannot step on a live run by forgetting to call `claim`.""" + if not Path("/proc/1").exists(): + pytest.skip("liveness is read from /proc") + source = tmp_path / "source" + (source / "data").mkdir(parents=True) + (source / "data" / "ely_eye.sqlite").write_text("store", encoding="utf-8") + busy = tmp_path / "scratch" + busy.mkdir() + (busy / scratch.HELD).write_text("1", encoding="utf-8") + with pytest.raises(SystemExit, match="is held by pid 1"): + scratch.copy_store(source, busy) + assert not (busy / "data").exists(), "the refusal must come before a byte is copied" diff --git a/bench/tests/test_video_audio_check.py b/bench/tests/test_video_audio_check.py index 0d06950..9a67940 100644 --- a/bench/tests/test_video_audio_check.py +++ b/bench/tests/test_video_audio_check.py @@ -11,8 +11,7 @@ from bench import video_audio_check def test_a_frozen_report_is_not_overwritten_by_a_harness( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A reviewer runs harnesses in a worktree of its own; the committed evidence stays put.""" - monkeypatch.setattr(video_audio_check, "PAYLOAD", tmp_path / "video_audio.json") + """A reviewer runs harnesses in a worktree of its own; the committed evidence stays put, in any directory.""" monkeypatch.setattr(video_audio_check, "_tracked", lambda path: True) frozen = tmp_path / "VIDEO_AUDIO.md" frozen.write_text("committed evidence", encoding="utf-8") @@ -21,14 +20,14 @@ def test_a_frozen_report_is_not_overwritten_by_a_harness( elsewhere = tmp_path / "scratch" / "VIDEO_AUDIO.md" elsewhere.parent.mkdir() elsewhere.write_text("scratch", encoding="utf-8") - video_audio_check.refuse_frozen_overwrite(elsewhere) + with pytest.raises(SystemExit, match="is a frozen report"): + video_audio_check.refuse_frozen_overwrite(elsewhere) def test_a_harness_may_write_its_own_untracked_output( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The default destination is the reports directory; refusing it would stop the shipped run.""" - monkeypatch.setattr(video_audio_check, "PAYLOAD", tmp_path / "video_audio.json") monkeypatch.setattr(video_audio_check, "_tracked", lambda path: False) fresh = tmp_path / "VIDEO_AUDIO.md" fresh.write_text("not yet evidence", encoding="utf-8") @@ -38,8 +37,7 @@ def test_a_harness_may_write_its_own_untracked_output( def test_an_absolute_path_to_the_frozen_directory_is_refused_too( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The guard compares where the file really lands, not how the path was spelled.""" - monkeypatch.setattr(video_audio_check, "PAYLOAD", tmp_path / "video_audio.json") + """The guard asks about the file, not the spelling of its path.""" monkeypatch.setattr(video_audio_check, "_tracked", lambda path: True) frozen = tmp_path / "VIDEO_AUDIO.md" frozen.write_text("committed evidence", encoding="utf-8") @@ -47,11 +45,21 @@ def test_an_absolute_path_to_the_frozen_directory_is_refused_too( video_audio_check.refuse_frozen_overwrite(Path(frozen.resolve())) +def test_a_committed_report_is_frozen_without_any_stub(tmp_path: Path) -> None: + """A stubbed `_tracked` proves the branch works, not that the guard does: this test uses the real git index.""" + committed = Path("bench/reports/LEGS.md") + assert committed.exists(), "the acceptance needs a tracked report to refuse" + with pytest.raises(SystemExit, match="is a frozen report"): + video_audio_check.refuse_frozen_overwrite(committed) + elsewhere = tmp_path / "LEGS.md" + elsewhere.write_text("mine", encoding="utf-8") + video_audio_check.refuse_frozen_overwrite(elsewhere) + + def test_the_probe_arm_switches_off_rather_than_ranking_nothing() -> None: """--probes 0 means "skip that arm"; a small count with the smoke knob means "you measured nothing".""" assert video_audio_check.probe_sample(0, 0) == [] - # The shipped sample keeps every modality; the smoke knob narrows it to video, where a - # small count can miss the modality entirely (the reviewer's --limit 1 --probes 1 crash). + # The shipped sample keeps every modality; the smoke knob narrows it to video, where a small count can miss one entirely. assert any(query.modality == video_audio_check.VIDEO_MODALITY for query in video_audio_check.probe_sample(300, 0)) for probes in (1, 5): with pytest.raises(SystemExit, match=f"--probes {probes} picked no answerable video query"): diff --git a/bench/video_audio_check.py b/bench/video_audio_check.py index ba8dd8b..c1947eb 100644 --- a/bench/video_audio_check.py +++ b/bench/video_audio_check.py @@ -272,10 +272,17 @@ def write_markdown(report: dict[str, Any], destination: Path) -> None: def _tracked(path: Path) -> bool: - """Only committed evidence is frozen; a harness's own untracked output is fair game.""" + """Only committed evidence is frozen; a harness's own untracked output is fair game. + + The question goes to the repository that owns the path, so a reviewer working in a worktree + cannot point at another checkout's committed report and be told it is untracked. + """ + owner = path.resolve().parent return ( subprocess.run( - ["git", "ls-files", "--error-unmatch", str(path)], capture_output=True, check=False + ["git", "-C", str(owner), "ls-files", "--error-unmatch", path.resolve().name], + capture_output=True, + check=False, ).returncode == 0 ) @@ -283,9 +290,7 @@ def _tracked(path: Path) -> bool: def refuse_frozen_overwrite(destination: Path) -> None: """A reviewer running this in a worktree of its own must not overwrite the committed evidence.""" - if destination.exists() and destination.resolve().parent == PAYLOAD.resolve().parent and _tracked( - destination - ): + if destination.exists() and _tracked(destination): raise SystemExit( f"{destination} is a frozen report; point --report and --payload somewhere else, or " "delete the frozen copy on purpose rather than under a harness"