Let a retired memory keep its vector and its place in the present

The second review round of p2-4 measured three behaviour defects behind the
as_of feature it had just accepted, and all three came from one mistake: the
code asked "is this memory current?" in three places and answered differently.

- A memory that has closed kept no vector at all, so the dense leg could only
  exclude an old fact, never recall it. Vectors now follow existence, not
  currency; `_allowed` decides who may see one.
- "Open right now" meant `valid_to IS NULL` in the view and in the words leg
  but `valid_to IS NULL OR valid_to > now` in the vector leg, so a supersede
  booked for next year hid a fact that is still true today. All three now read
  the same rule; schema v12 records the change and the upgrade drops the old
  view, since CREATE VIEW IF NOT EXISTS would have kept the old body.
- The dense as_of test was名不副实: dense is off by default, so it passed while
  the vector side had no coverage. It now turns dense on with a stand-in model
  and pins both `VectorIndex.search(as_of=…)` and the plumbing in retrieval.

Five mutations, each caught by exactly one test: the dense as_of branch, the
retrieval hand-off, the three vector reads, the words-leg currency, the view.

Record side: the round-2 line quoted a D-list I had misread out of the
reviewer's PASS tables (cf2de31 fixed those, and they were its precision notes,
not its findings), the stale line-number parenthetical, an acceptance grep that
scanned CHECKLIST's own quotations, and a Gaps.md tally that disagreed with its
own table (8 closed, 3 closed with reservations, 1 partial).

Gates: 411 passed; ruff, filesize, audit 33/2/6 clean; pyright 0 on the six
touched files; mypy cold cache Success on the same six; scripts/key_test.sh
exit 0 (6 PASS, 1 NOT MEASURED).
This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 22:13:11 -04:00
1 parent cf2de313dc
commit 92237c8f10
7 files changed
+156 -40

No files matched your search

+12 -8
View File
@@ -420,14 +420,15 @@
- **评审第 3 轮 FAIL**(同一独立 pi,8 项全跑,~534 工具轮;D36–D39 的修法全部实测成立,16 对冲突逐对复现):两条——D40 机制盲区:主语是两字 CJK 词或单个拉丁词时,紧跟的裸数字被并进主语、`values` 空,`_note_conflicts` 直接返回,整条 lane 一条不记(`温度 22/24 度`、`室温`、`设定`、`预算 5000/8000 元`、`The thermostat 22/24 degrees` 全部 0 条,HTTP 上也是 `[]`),而三字主语 `恒温器 22/24 度` 记 1 条——**主语长度决定记不记**,且 `:412` 那句「数字只要站在某个词之后就是量不是名字」被 `温度 22 度` 直接推翻;D41 归因错:我写「一条记忆只有数字没有词时没有主语可比」,实测 `numeric_values('22 度').subject == '22'`,0 条是因为两侧主语不同。修法换成机制(`_numbers_follow`)而不是加披露,实测 33 对里只动 6 对(五条漏报变记上,加一条新披露边界 `Room 3 is big` + `Room 4 is big` → 1 条),其余逐字不变。该轮同时实测确认:门禁全绿、五个攻击(标点隔开、数字开头、融合写法、一句多数字、反向实验)全部量过,`Room 3 holds 22 degrees at 5 pm` 报 `room3` 22→24 是真读数变化、但 `degrees` 键拿到了 `5`(P5,本轮无害);反向实验删掉改名那四行恰好让两条测试变红(我预测的第三条不变红,因为「数字开头当主语」住在 `_subject_and_rename_slot` 第一个分支 `trust.py:254`);抑制只在页面(搜只给新侧,`GET /api/atoms` 两侧都在且 `valid_to` 仍 None)。另有四条精度说明:P1 smoke 要加指纹断言,否则 8799 上的旧服务能替这棵树答题(它自己就撞上一次:答题体没有 p2-3 字段)——第 3 轮加在 `scripts/mcp_write_smoke.sh` 的 `recall_count` 断言被第 4 轮判为**空判别子**(D42):`ely-eye-mcp/internal/mcpserver/write_tools.go:61-70` 把 HTTP 答复解进强类型 `domain.Memory` 再原样发出,旧服务缺的字段解成 `0` 又被 Go 侧重新发出,所以旧 Python 服务照样通过(评审用 `4c6af87` 的服务 + HEAD 编的 MCP 二进制实测,答复里是 `"recall_count":0`)。指纹现在打在 **Python 答复**上:就绪后先探 `GET /api/memory/working`,实测 `4c6af87` 返 404、HEAD 返 200(两边我都自己起服务量过),旧服务替这棵树答题会当场 FAIL;MCP 侧那条断言留着,但文案改成它真正钉住的东西——脚本自己现编的那个 Go 二进制是不是这份树编的;P2 「整行」口径(已写进 p1-6.4);P3 本报告里门禁行与第 1 轮记录行各重复一份(已去重);P4 `LEGS.md` 的 stamp(`a52c104`,harness blob `a1e9842d`)比改名后的 HEAD(blob `977d19eb`)旧——实测 `git diff a52c104:bench/leg_check.py HEAD:bench/leg_check.py` 只有一行注释被压成一行、零代码变动,`bench/leg_report.py` 两处 blob 全同,所以报告里的数字就是这份代码跑出来的,不必重测(重测只会把延迟换成另一个机器负载下的值)。
- **评审第 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`。验收:`pytest backend/tests/test_as_of_search.py` → **5 passed**(旧记忆答它当真的那阵、下周的记忆不答今天的问、归档前的时段仍答、as_of 不借别人的记忆、dense 腿与文字腿读同一个窗口)。
- 钥匙测试场景 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 修复,症状是「还原后仍红」)。
- 顺带:`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 + 三态眼睛)
- 事实:`holders` 加 `token_hash` / `token_issued_at`(`migrations.py:92` DDL,`_NEW_HOLDER_COLUMNS` 给老库 ALTER)。**只存哈希**(`sha256`,`holder_store._token_hash`),所以拷走的库不能重放 token;明文只在 `POST /api/holders/{id}/token` 的响应里出现一次(`IssuedToken`),`GET /api/holders/wife` 只回 `token_issued_at`。生成用 `secrets.token_urlsafe(32)`。**两条 token 走两条比对**:operator 的明文用 `secrets.compare_digest`(`api_auth.py:54`、`:110`),holder token 是 SQL 等值查摘要(`holder_store.verify_token` = `WHERE token_hash = ?`)——库里只存 sha256,没有明文可做常数时间比较;评审第 1 轮 D1 就是我把这两条混成了一句。`register_minimal` 是 `INSERT … ON CONFLICT DO NOTHING`,`remember` 在写记忆前调它(`memory.py:94`),所以被转介的人一定有目录行,且它绝不覆盖人已声明的 name/contact。
- 身份是**服务器算的**:`api_auth.resolve_caller` 三选一——operator(全局 token,可指名任何人)、holder(自己的 token,`verify_token`)、anonymous(没 token)。`read_as(caller, claimed)` 把「调用者声称的 holder」降级成「服务器认定的眼睛」:operator 不声称 → `None`(整库),operator 声称 → 那个人,holder 声称自己 → 自己,**声称别人 → 403 并把两个人都点名**,anonymous 声称任何人 → 403。`/api/search`、`/api/atoms`、`/api/atoms/{id}/image`、`/api/chat`、`/api/memory/working`、`/api/memory/conflicts`、`/api/ask` 全部走它;`remember`/`supersede` 仍是 **operator 才能写**(holder token 拿去写 → 401,评审实测),operator 写的归属来自请求或 `ELY_EYE_DEFAULT_HOLDER`(评审实测库里 `[('husband',2),('local',2)]`);p0-2 那条「客户端不能声称自己是别人」仍由 `RememberRequest` 没有 holder 字段保证,token 没有取代它(评审第 1 轮 D8:我原先写成「现在由 token 保证,不再靠 `default_holder` 兜底」,与实测相反)。
- **眼睛是三态、不是一个字符串的两可**(跑出来的决定):`NULL` = 整库(operator,或自己拥有这个 store 的进程),holder id = 那个人的眼睛,`''` = 谁也不是(无 token 的 HTTP 调用者)。让 `NULL` 也代表「谁也不是」会逼 CLI、proofs、compiler、bench harness、`who_knows`、`ask` 全都传眼睛,那跟 p0-2 定的「拥有 store 的进程按本机受信主体读」直接冲突——第一次这样改时 `pytest -q` 红了 29 条(`test_vector_index.py:46` `assert [] == ['mem-eaf3b790ca27bf3e:text']`、`test_proofs.py` 回放 0 hits、`test_visual_index.py:43` `ValueError: invalid literal for int() with base 10: 'widget gadget sprocket'`,因为一条本来被跳过的腿开始跑了)。`''` 能当「谁也不是」是因为本 schema 里 `holder_id = ''` 本来就是「无主」。
- 修前是真的漏:`browse.py`(`GET /api/atoms` 与 `/api/atoms/{id}/image`)**完全没有可见性规则**,无 token 就能翻页读到别人的记忆正文和图片;`store_search.py:103` 与 `vector_index.py:288` 的规则是(行号是 as_of 之后的,`83dfc14` 时它们是 81-86 / 245-252) `(? IS NULL OR holder_id = '' OR scope='everyone' OR holder_id = ? OR …)`,`as_holder IS NULL` 短路成「整库」,所以无 token 搜索也看见全部。现在五处副本统一成 `(? IS NULL OR holder_id = '' OR json_extract(visibility_json,'$.scope') = 'everyone' OR (? <> '' AND (holder_id = ? OR EXISTS (… value = ?))))`,**四个参数同一个值**。
- 修前是真的漏:`browse.py`(`GET /api/atoms` 与 `/api/atoms/{id}/image`)**完全没有可见性规则**,无 token 就能翻页读到别人的记忆正文和图片;`store_search.py:103` 与 `vector_index.py:288` 的规则是(行号是 as_of 之后的;评审第 2 轮 D12 指出我原先写的「`83dfc14` 时它们是 81-86 / 245-252」仍是错的,实测那提交上是 `store_search.py:81-88`(锚 `:82`)与 `vector_index.py:251-258`(锚 `:252`,`:245` 是另一条 event_time 谓词)) `(? IS NULL OR holder_id = '' OR scope='everyone' OR holder_id = ? OR …)`,`as_holder IS NULL` 短路成「整库」,所以无 token 搜索也看见全部。现在五处副本统一成 `(? IS NULL OR holder_id = '' OR json_extract(visibility_json,'$.scope') = 'everyone' OR (? <> '' AND (holder_id = ? OR EXISTS (… value = ?))))`,**四个参数同一个值**。
- 规则为什么写五份而不抽常量:p0-1 那轮本仓跑 SQL 注入门时,`connection.execute(f"…{模块级常量}…")` 被拒过,那轮的门是 opengrep(评审第 1 轮 D7:这个容器现在 `which semgrep` / `which opengrep` 都是空,所以这条是**当时观察到的**,今天在这台机器上重跑不了)。今天真正防漂移的是 `backend/tests/test_visibility_rule.py`,它不依赖那个工具:`EXPECTED_COPIES = 5`,正则抓出每处、`_normalize`(去空白、去引号、去 `a.` 别名)后必须**只剩一个字符串**,第二条测试钉住 clause 里 `? IS NULL` / `holder_id = ''` / `$.scope')='everyone'` / `? <> ''` / `holder_id = ?` / `value = ?` 六个特征还在。第一版正则 `\(\? IS NULL[^;]*?` 会把 `(? IS NULL OR modality = ?)` 这类过滤谓词一起吃掉,读出 4 份不同 clause——那是正则错,不是规则漂移。
- [x] p2-4.2 默认可见性:answer-only 转介(p0-5 的转介现在被 ACL 兜住)
- p0-5 只做了一半:`ask` 命中别人的时候只回指针不回正文(`_pointer`),但**搜索本身**还是能读到正文,所以「只给指针」是客气不是边界。现在无 token 的 `search_atoms` 搜不到那句私钥(smoke 实测),`ask` 的 `answer` 仍为空、`secret` 在整个 referral JSON 里不出现,而 `who_knows` 照旧能用——目录路由**故意不设卡**,因为它只出计数、分数、contact 和声明过的话题,不出记忆正文(`api_holders.py` 注册处一行注释写明)。`visibility` 三档实测:默认 `holder`(只有主人)、`everyone`(无 token 可搜到)、`visible_to: [husband]`(主人 + 被指名的人能读,陌生人和第三个 holder 不能)。
@@ -437,9 +438,12 @@
- 偏差(都是跑出来的):吊销过的 token 是**硬拒**(401「this token is neither the operator token nor a holder token」,`api_auth.py:59`),不降级成匿名;匿名调用者带 `?holder=`(空串)也是 403,不是「没声称」;能力卡除计数、分数、contact、声明过的话题之外还带每人的 `newest_memory` 时间戳与 `token_issued`,两者都不含记忆正文。`/api/memory/working` 与 `/api/memory/conflicts` 现在拒绝「谁也不是」的调用者(422,消息含「somebody」),因为这两件事本来就属于某个人;`compiler.py:41` 的缓存 key 加了眼睛——先前**根本没有身份成分**(`semantic_key(question, profile.value, self.db.revision())`;评审第 1 轮 D3:我写的「先前写 `as_holder or ""` 两边都哈希 `""`」那套机制在这个仓里从来没存在过),所以同一个问题在同一个 revision 上只有一个 key,**谁先编译谁的答案就被端给下一个人**——方向取决于谁先写,一份薄但非空的 pack 也会静默串用(评审第 2 轮 D12 实测:先编译陌生人那份,operator 随后拿到陌生人的 1 条命中;我原先写的「陌生人那份空 pack 会端给 operator」只是其中一种方向);现在 key 里带 `"the whole library"` 或 `as holder {as_holder!r}`,两种顺序实测四个 trace id 全不同(`None`→1 hit、`''`→0、`husband`→1、`nobody`→0);`forget` 的级联、卡带成员、目录都不因 ACL 改动。
- 验收:`pytest backend/tests/test_holder_acl.py backend/tests/test_visibility_rule.py backend/tests/test_transactive.py` → **13 + 2 + 19** 全绿(两 holder 互不可见、无 token 读不到私钥、`everyone` 无 token 可搜、`visible_to` 只放行被指名的人、声称别人 403 且点名两人、token 只出现一次且库里只有 sha256、吊销后旧 token 401、卡无 token 可读且不含正文、陌生人的图片 404 而无主的 200、`compile(as_holder="")` 返回 0 hits 且 `cache_trace_id` 与 `as_holder="wife"` / `None` 都不同)。跨进程:`bash scripts/mcp_write_smoke.sh` → 两行 PASS,第二行是「a caller with no token sees only public memory, a holder's token sees her own, naming somebody else is refused, and the capability card answers without a token」——它先证明无 token 搜不到私钥,再用 operator token 铸出妻子的 token、证明她能搜到自己的、搜不到 `local` 写的 `the oven needs descaling`,最后**把 MCP 进程不带 `ELY_EYE_AUTH_TOKEN` 重启一遍**,证明 Go 那条路守同一条规则(不是假设)。
- 顺手修的两条自己写的断言缺陷(都是跑出来的):smoke 里 `if secret in found(None, token=token)` 是同义反复(搜的还是那句话,当然命中),改成拿**别人写的**记忆当探针;指纹探测 `GET /api/memory/working?holder=fingerprint` 现在返回 **403**(路由在、但匿名不能声称 holder),所以探测必须带 operator token——旧树上没有这条路由仍是 404,判别力保住。
- **评审第 1 轮 FAIL**(同一独立 pi,`209f2d0..83dfc14`,736 工具轮):**功能面它全部实测成立**,记录面 8 条对不上,已全部按实测改掉——D1 「比对用 `compare_digest`」对 holder token 是假的(库里只有摘要,`verify_token` 是 SQL 等值,`compare_digest` 只用在 operator 的明文上);D2 指针错(`register_minimal` 在 `memory.py:94`,不是 `:120`);D3 「先前 `as_holder or ""` 两边哈希 `""`」描述的机制从来没存在过(旧 key 根本没有身份成分);D4 `browse.py:14` 注释说「four — two here」而实际五份、这里三份(`:52`/`:66`/`:93`);D5 指针被 as_of 改动挪了行(`vector_index.py:288`、`store_search.py:103`);D6 我 prompt 里写「33 条路由」而实测父提交 34 个装饰器 / 31 条不同路径、HEAD 38 / 35;D7 semgrep 那条在它的环境里无法复现(`which semgrep` / `which opengrep` 都空),已改成「当时观察到的」并把防漂移的责任交给不依赖那个工具的测试;D8 「holder 也只能是 caller 自己…由 token 保证」与实测相反(只有 operator 能写,归属来自 `default_holder`,真正的保护是 `RememberRequest` 没有 holder 字段)。它同时实测确认:三条漏在父提交上是真的、在 HEAD 关上;38 条路由里会吐正文/字节的 7 条全带眼睛;11 个攻击全关(含吊销 token 401、holder token 不能写);三态没被照瞎(CLI 命中、`bench.leg_check --probes 20` recall@10 0.900 / p50 72.3 ms、smoke PASS);五份副本它自己数过,把 `? <> ''` 改掉立刻让 `test_visibility_rule.py` 红;缓存 key 两种顺序四个 trace id 全不同;smoke 新增那段是真判别子(父提交树会把私钥搜出来)。另采纳四条精度说明(吊销硬拒、匿名空串 403、能力卡还带时间戳与 `token_issued`、`leg_check` 的 commit 行是 cwd 的 HEAD)。
- **评审第 2 轮 FAIL**(同一独立 pi,`df812f3`,7 项全跑):D1–D8 全部实测确认已修(它在 `209f2d0` worktree 里重现了旧 key 无身份成分、`git log -S` 重现了 `as_holder or ""` 只存在于 `83dfc14`)。as_of 的行为面它自己写脚本全测通:退役的记忆在 `as_of` 下应答、下周的事今天不应答、归档不挡 as_of、不跨 holder 借看、dense 腿与词腿读同一个时间窗(两条都只回 `drawer`)。`scripts/key_test.sh` 在 `df812f3` 上 exit 0(6 PASS + 1 NOT MEASURED),把 `store_search.py` 换回父提交就红在场景 3。新缺陷六条,五条记录一条纪律:D9 就是上面那条 `compare_digest` 一刀切;D10 是历史行没钉提交(已钉,并量出 `2ea48d8` 的 `test_trust_quarantine.py` 确实是 25 条、`d56996b` 起才是 26);D11 p2-5 验收格写「全套 401」而当前 406;D12 缓存 key 那条的后果写过头(旧 key 无身份 ⇒ 谁先编译谁被端给下一个人,不是单向的空 pack);D13 两条门禁格的 401 没钉提交(已分别钉 `83dfc14` / `89c880f` 并写明当前 406);D14 `bench/video_audio_check.py` 写死 `bench/reports/`,在评审 worktree 里跑会脏了冻结报告(已加 `--scratch` / `--report` / `--payload`,默认值不变)。另采纳三条精度说明(不算缺陷):吊销 token 的 401 消息只点名 operator 没点 holder、匿名带空 `?holder=` 是 403 而 smoke 断言写的是 401/403 并列、能力卡的 `token_issued` 会泄露「某人今天刚拿到 token」这种时间信号。
- 门禁(`83dfc14` 那轮量的;as_of 之后当前是 406):`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。
- **评审第 1 轮 FAIL**(同一独立 pi,`209f2d0..83dfc14`,736 工具轮):**功能面它全部实测成立**,记录面 8 条对不上,已全部按实测改掉——D1 「比对用 `compare_digest`」对 holder token 是假的(库里只有摘要,`verify_token` 是 SQL 等值,`compare_digest` 只用在 operator 的明文上);D2 指针错(`register_minimal` 在 `memory.py:94`,不是 `:120`);D3 「先前 `as_holder or ""` 两边哈希 `""`」描述的机制从来没存在过(旧 key 根本没有身份成分);D4 `browse.py:14` 注释说「four — two here」而实际五份、这里三份(`:52`/`:66`/`:93`);D5 指针被 as_of 改动挪了行(`vector_index.py:288`、`store_search.py:103`);D6 我 prompt 里写「33 条路由」而实测父提交 34 个装饰器 / 31 条不同路径、HEAD 38 / 35;D7 semgrep 那条在它的环境里无法复现(`which semgrep` / `which opengrep` 都空),已改成「当时观察到的」并把防漂移的责任交给不依赖那个工具的测试;D8 「holder 也只能是 caller 自己…由 token 保证」与实测相反(只有 operator 能写,归属来自 `default_holder`,真正的保护是 `RememberRequest` 没有 holder 字段)。它同时实测确认:三条漏在父提交上是真的、在 HEAD 关上;38 条路由里会吐正文/字节的 7 条全带眼睛;11 个攻击全关(含吊销 token 401、holder token 不能写);三态没被照瞎(CLI 命中、`bench.leg_check --probes 20` recall@10 0.900 / p50 72.3 ms、smoke PASS);五份副本它自己数过,把 `? <> ''` 改掉立刻让 `test_visibility_rule.py` 红;缓存 key 两种顺序四个 trace id 全不同;smoke 新增那段是真判别子(父提交树会把私钥搜出来)。另采纳四条精度说明(吊销硬拒、匿名空串 403、能力卡还带时间戳与 `token_issued`、`leg_check` 的 commit 行是 cwd 的 HEAD)。**D7 后续补上的证据**:那个门禁在本机确实跑得起来,只是不是独立 CLI——pi-lens 的 semgrep runner 在 `migrations.py:245`(`f"PRAGMA table_info({table})"`,`git show HEAD` 同位置同样存在)上 fired `python.sqlalchemy.security.sqlalchemy-execute-raw-query`,所以「插值变量会被报」不是口述,可重跑;那个站点是只拿字面量调的私有 helper。
- **评审第 2 轮 FAIL**(同一独立 pi,`df812f3`,7 项全跑):D1–D8 全部实测确认已修(它在 `209f2d0` worktree 里重现了旧 key 无身份成分、`git log -S` 重现了 `as_holder or ""` 只存在于 `83dfc14`)。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 的三种读法)。
- 本轮另修的两处产品事实(都是 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)。
- 门禁(`83dfc14` 那轮量的;as_of 之后 406,D9–D14 修完当前 **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。
- [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` 隐藏而不删除。
@@ -452,11 +456,11 @@
- 删了:`MMMU-Pro=1.00` 与 `OCRBench=1.00`(每个基准只读了 **一个** 样本,`next(iter(stream))`)和「Visual HashHop gate passed 7/7 … 1-hop=1.00 2-hop=1.00」的 gate 口径(那个检查是**重试到达标为止**,现在重试已删)。改而写明:文件是 2026-05-28 在 Windows+4090 上从已不存在的 `PRD.md` 生成的,adapter 与所有 `.ely_eye/` 路径**不被 git 跟踪**,所以 clone 不能重现。
- 保留但说清它到底测了什么:`citation accuracy 1.00` = 引用的 id 在送进去的上下文里存在,不是蕴含(且那道题把答案写在题面里);100M capsule = 检查自己生成的 capsule 自哈希自洽,不是 100M 真实内容;repository input proof 是当天快照(仓库现在更大,且任何 git 仓库都能满足它);runtime profile「verified」限定在那台机器的固定 WSL 发行版(现在读 `ELY_EYE_WSL_DISTRO`,且有 `.sh` 双胞胎与 Linux CI);15 行 `Checks:` 块标成「当天写下的行,不是今天的结论」并指向 Gaps.md 附录 A 的逐条判定;卡带资产清单换成 `cartridge.py` 今天真正写的五个文件,并点名 `kv_snapshots.kivi` / `ttt_vl_adapter.safetensors` 是从未接上 loader 的占位。
- [x] p2-5.4 `Gaps.md` 更新为已闭合状态
- 新增 §0.5(不改写 §0–§4 的历史判断,只加今天复核):12 项差距逐项一行——**10 项闭合、2 项部分闭合**,每行带跑过的命令或报告;四个根因逐一说现在为什么不成立;末尾「还剩什么」只列三件事:验收线 0.90 未达(缺口全在视觉/音频腿)、卡带的算力节省(前缀/KV 复用)没做、生成侧在本仓库一次没跑过。
- 新增 §0.5(不改写 §0–§4 的历史判断,只加今天复核):12 项差距逐项一行——**8 项闭合、3 项闭合但带保留、1 项部分闭合**(评审第 2 轮 D14:我原先写「10 项闭合、2 项部分闭合」,与 §0.5 自己那张表的判定列对不上——带保留的是 3.3 验收线未达、3.4 算力未做、3.8 效果未测,部分闭合只有 3.7;Gaps.md 那行已同步改掉),每行带跑过的命令或报告;四个根因逐一说现在为什么不成立;末尾「还剩什么」只列三件事:验收线 0.90 未达(缺口全在视觉/音频腿)、卡带的算力节省(前缀/KV 复用)没做、生成侧在本仓库一次没跑过。
- 诚实处理两处派生数字:`+0.454` 不在 `BASELINE_EXTERNAL.md` 里,是它两行(0.866 / 0.412)相减得来,Gaps 里写明「该报告只给两行与‘这段差值是 holder 边界的价值’这句话,差值是这里减出来的」;ASR 速度改成当前报告的数(258.18s 音频 / 3.12s,每视频 68.4–111.2×),不再引旧报告的 63.9–112.2。
- 偏差(顺手补的真缺口):CLI `search` 只有 `--holder`(过滤器),**没有**办法用某个 holder 的眼睛看库——即无法回答「我的 agent 会看到什么」。加了 `--as-holder`(`SearchFilters.as_holder`)与两条断言(`--as-holder stranger` → `no_match`,`--as-holder wife` → 命中),否则三态眼睛只在 HTTP/MCP 两面成立而 CLI 面缺一个口。
- 验收:`grep -rn "MMMU-Pro=\|OCRBench=\|1-hop=\|2-hop=" *.md */README.md` → **0 命中**;README 里每个数字都能在报告里找到(0.863→LEGS、0.866/0.412→BASELINE_EXTERNAL、0.0717/0.2177→SCALE、165973/153857/106 per second→DENSE、0.242/533/68.4–111.2→ASR、模态行→LEGS、148.9GB→Gaps 附录 B)。唯一留下的模型分数是 `TRAINING_ARTIFACTS.md:84` 的 `citation accuracy 1.00`:它在证据块里(那是当天产出的工件原文),且紧跟的段落写明了它测的是什么、不测什么。
- 门禁(`89c880f` 那轮量的;`eab6bd7` 加了 5 条 as_of 测试后当前是 406):`pytest -q` **401 passed**;`ruff check backend/src/ely_eye backend/tests bench` 干净;`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` 基线,净新增 0;`./node_modules/.bin/pyright backend/src/ely_eye/cli.py backend/tests/test_search_api.py` → **0 errors**;本轮未动 Go/前端/报告,所以 go/npm 套件沿用 `83dfc14` 的绿。
- 验收:`grep -rn "MMMU-Pro=\|OCRBench=\|1-hop=\|2-hop=" README.md TRAINING_ARTIFACTS.md Gaps.md AGENTS.md backend/README.md bench/README.md ely-eye-mcp/README.md` → **0 命中**(评审第 2 轮 D13:我原先写的命令带 `*.md`,会把 CHECKLIST 自己扫进去,实测 **2 命中**,两处都在本文件里——`:452` 与 `:458` 引用的正是被删掉的字符串;结论不变,命令与数字得改)。README 里每个数字都能在报告里找到(0.863→LEGS、0.866/0.412→BASELINE_EXTERNAL、0.0717/0.2177→SCALE、165973/153857/106 per second→DENSE、0.242/533/68.4–111.2→ASR、模态行→LEGS、148.9GB→Gaps 附录 B)。唯一留下的模型分数是 `TRAINING_ARTIFACTS.md:84` 的 `citation accuracy 1.00`:它在证据块里(那是当天产出的工件原文),且紧跟的段落写明了它测的是什么、不测什么。
- 门禁(`89c880f` 那轮量的;`eab6bd7` 加了 5 条 as_of 测试后 406,D9–D14 修完当前 **411**):`pytest -q` **401 passed**;`ruff check backend/src/ely_eye backend/tests bench` 干净;`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` 基线,净新增 0;`./node_modules/.bin/pyright backend/src/ely_eye/cli.py backend/tests/test_search_api.py` → **0 errors**;本轮未动 Go/前端/报告,所以 go/npm 套件沿用 `83dfc14` 的绿。
---
+2 -2
View File
@@ -22,11 +22,11 @@
## 0.5 现在的状态(2026-10-09 复核,逐项跑过)
上面那三句结论在审查当天成立。经过 `CHECKLIST.md` 上的 b→P0→P1→P2 之后,**12 项差距里 10 项已闭合、2 项部分闭合**;未闭合的两项卡在同一个地方:视觉与音频腿的能力,不是架构。
上面那三句结论在审查当天成立。经过 `CHECKLIST.md` 上的 b→P0→P1→P2 之后,**12 项差距里 8 项闭合、3 项闭合但带保留、1 项部分闭合**;带保留的是 3.3(检索快但 recall@10 0.863 未达 0.90 验收线)、3.4(挂载作用域做了,省算力没做)、3.8(adapter 真加载了,效果没测);部分闭合的是 3.7。三处缺口卡在同一个地方:视觉与音频腿的能力,不是架构。
| 差距 | 状态 | 证据(跑过的命令 / 报告) |
|---|---|---|
| 3.1 记忆模型:谁、何时、何事、对谁可见 | **闭合** | schema 已到 v11;`holder_id/origin/scenario/event_time(+precision)/valid_from/valid_to/supersedes/visibility/retention_until` 全部落库并有往返测试(`backend/tests/test_memory_schema.py`);会话、重要度、归档层、冲突清单在 p2-3 落地(`test_memory_tiers.py`、`test_memory_conflicts.py`) |
| 3.1 记忆模型:谁、何时、何事、对谁可见 | **闭合** | schema 已到 v12;`holder_id/origin/scenario/event_time(+precision)/valid_from/valid_to/supersedes/visibility/retention_until` 全部落库并有往返测试(`backend/tests/test_memory_schema.py`);会话、重要度、归档层、冲突清单在 p2-3 落地(`test_memory_tiers.py`、`test_memory_conflicts.py`);「当时为真」能问回来(`SearchFilters.as_of`,`test_as_of_search.py`) |
| 3.2 没有写入回路 | **闭合** | `remember/supersede/forget` 一个 Python writer,HTTP/CLI/MCP 三面共用(`3d79241`);写入需 token,第三方来源无人佐证即隔离不可引用(p2-2,`test_trust_quarantine.py`) |
| 3.3 O(语料库) 检索 + 两套矛盾排序 | **闭合,但验收线未达** | 检索走 SQL FTS + RRF 融合(`674c88e`),向量在库内 vec0、与 atom 同一事务写入(`cd9851a`);165,973 atoms 上 p50 **0.0717 s**,替换掉的是 **20.16 s**(`bench/reports/SCALE.md`)。recall@10 **0.863**,验收线 0.90 **未达**(`bench/reports/LEGS.md`) |
| 3.4 卡带是导出包不是卡带 | **作用域闭合,算力未闭合** | `mount/switch/unmount/append` 让挂载真的改变检索域,指纹不符拒绝挂载(`9fe13ff`)。原文里「省下真实算力(前缀/KV 复用)」**没做**:`profiles.py` 的 YaRN/量化/CPU-offload 仍是引擎启动参数,没有前缀复用实现 |
+4 -4
View File
@@ -16,7 +16,7 @@ import sqlite3
from .storage import token_equivalent, visual_token_equivalent
from .text_index import index_text
SCHEMA_VERSION = 11
SCHEMA_VERSION = 12
ATOM_COLUMNS_V2 = """
atom_id TEXT PRIMARY KEY,
@@ -188,7 +188,7 @@ tokenize = 'unicode61'
VIEWS = """
CREATE VIEW IF NOT EXISTS v_atoms_current AS
SELECT * FROM atoms
WHERE valid_to IS NULL
WHERE (valid_to IS NULL OR valid_to > strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
AND archived_at 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'));
@@ -328,8 +328,8 @@ def apply(connection: sqlite3.Connection) -> None:
for name, definition in _NEW_HOLDER_COLUMNS:
if name not in existing_holders:
connection.execute(f"ALTER TABLE holders ADD COLUMN {name} {definition}")
if version < 10:
# CREATE VIEW IF NOT EXISTS keeps the old body, and v10 changed which rows are current, so an upgraded store must be told to look again.
if version < 12:
# CREATE VIEW IF NOT EXISTS keeps the old body, and v10 and v12 each changed which rows are current, so an upgraded store must be told to look again.
connection.execute("DROP VIEW IF EXISTS v_atoms_current")
connection.executescript(VIEWS)
+2 -1
View File
@@ -68,7 +68,8 @@ def search_atoms(
WHERE atom_fts MATCH ?
AND (
(? IS NULL
AND a.valid_to IS NULL
AND (a.valid_to IS NULL
OR a.valid_to > strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
AND a.archived_at IS NULL
AND (a.valid_from IS NULL
OR a.valid_from <= strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
+16 -9
View File
@@ -4,7 +4,8 @@ The first version kept the vectors in `cache/dense_index.npy` plus a JSON list o
That pair had no owner: nothing noticed when an atom was added, changed or forgotten, so a
search answered from a snapshot of the past and `forget` had to delete the whole file. Here the
vectors sit in the same SQLite file as the atoms, in a `vec0` table the `sqlite-vec` extension
answers, and the only rule the index has is "one row per current atom, re-encode what changed".
answers, and the only rule the index has is "one row per atom that still exists and has words,
re-encode what changed".
"""
from __future__ import annotations
@@ -58,7 +59,7 @@ def load_extension(connection: sqlite3.Connection) -> None:
class VectorIndex:
"""One float32 vector per current atom: the writer keeps it, the reader asks it questions."""
"""One float32 vector per memory that has words: the writer keeps it, the reader asks it questions."""
def __init__(
self,
@@ -121,7 +122,9 @@ class VectorIndex:
"""Bring the index in step with memories as they are written.
Nothing on the search path rebuilds, so this is the half that keeps "written now,
findable now" true; a memory that has closed or been forgotten loses its vector here too.
findable now" true. A forgotten memory loses its vector here; a memory that has merely
closed keeps one, because a question about the past still has to reach it — the currency
rule in `_allowed` decides who may see it, not the presence of a vector.
"""
wanted = [atom_id for atom_id in atom_ids if atom_id]
if not wanted or not self.settings.dense_embeddings_enabled:
@@ -382,12 +385,16 @@ class VectorIndex:
return removed
def _current(self, connection: sqlite3.Connection, atom_ids: list[str]) -> list[tuple[str, Any]]:
"""The written memories that still deserve a vector: open, and with words."""
"""The written memories that still deserve a vector: ones whose row exists, with words.
Currency is not asked here on purpose. A superseded memory keeps its vector so `as_of`
can answer from it; the search path is where the window gets applied.
"""
from .row_codec import row_to_atom
rows = connection.execute(
"""
SELECT * FROM v_atoms_current
SELECT * FROM atoms
WHERE atom_id IN (SELECT value FROM json_each(?)) AND text IS NOT NULL AND text != ''
""",
(to_json(atom_ids),),
@@ -425,7 +432,7 @@ class VectorIndex:
)
def _stale(self, connection: sqlite3.Connection) -> list[tuple[str, Any]]:
"""Current memories the index does not hold, or holds from an older version of the memory.
"""Memories the index does not hold, or holds from an older version of the memory.
A memory with no words is the visual index's job, so it never reaches this table. The
stored stamps are copied into a temp table first: a vec0 table has no index on atom_id,
@@ -441,7 +448,7 @@ class VectorIndex:
)
rows = connection.execute(
"""
SELECT a.* FROM v_atoms_current AS a
SELECT a.* FROM atoms AS a
LEFT JOIN dense_seen AS d ON d.atom_id = a.atom_id
WHERE a.text IS NOT NULL AND a.text != ''
AND (d.atom_id IS NULL OR d.stamp IS NULL OR d.stamp != atom_stamp(a.ingested_at, a.text))
@@ -457,14 +464,14 @@ def atom_stamp(ingested_at: str, text: str | None) -> str:
def expected_vectors(database: Database) -> int:
"""Memories this index is meant to hold: the open ones that have words."""
"""Memories this index is meant to hold: the ones whose row exists and has words."""
with database.connect() as connection:
return _expected(connection)
def _expected(connection: sqlite3.Connection) -> int:
row = connection.execute(
"SELECT count(*) AS n FROM v_atoms_current WHERE text IS NOT NULL AND text != ''"
"SELECT count(*) AS n FROM atoms WHERE text IS NOT NULL AND text != ''"
).fetchone()
return int(row["n"])
+111 -13
View File
@@ -8,8 +8,10 @@ about, never who is allowed to see the answer.
from __future__ import annotations
import hashlib
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
@@ -59,7 +61,9 @@ def test_a_retired_memory_answers_for_the_time_it_was_true(client: TestClient) -
superseded = client.post("/api/memory/supersede", json={"atom_id": atom_id, "text": CAR})
assert superseded.status_code == 200, superseded.text
assert hits(client, {"query": "钥匙"}) == [CAR], "asked now, the current memory answers"
asked_then = hits(client, {"query": "钥匙", "filters": {"as_of": (week_ago + timedelta(days=1)).isoformat()}})
asked_then = hits(
client, {"query": "钥匙", "filters": {"as_of": (week_ago + timedelta(days=1)).isoformat()}}
)
assert asked_then == [DRAWER], "asked about last week, the retired memory answers"
@@ -71,7 +75,9 @@ def test_a_memory_that_had_not_happened_yet_does_not_answer(client: TestClient)
)
assert response.status_code == 200, response.text
assert hits(client, {"query": "钥匙"}) == [CAR]
assert hits(client, {"query": "钥匙", "filters": {"as_of": datetime.now(UTC).isoformat()}}) == []
assert (
hits(client, {"query": "钥匙", "filters": {"as_of": datetime.now(UTC).isoformat()}}) == []
)
def test_an_archived_memory_still_answers_for_the_time_before_it_was_archived(
@@ -84,11 +90,18 @@ def test_an_archived_memory_still_answers_for_the_time_before_it_was_archived(
)
assert response.status_code == 200, response.text
archived_at = datetime.now(UTC)
moved = lifecycle_store.archive_atoms(Database(settings), [response.json()["atom_id"]], archived_at)
moved = lifecycle_store.archive_atoms(
Database(settings), [response.json()["atom_id"]], archived_at
)
assert moved == 1, "the archive moved nothing, so the test proves nothing"
assert hits(client, {"query": "钥匙"}) == [], "an archived memory is out of the working set"
asked_then = hits(client, {"query": "钥匙", "filters": {"as_of": (archived_at - timedelta(hours=1)).isoformat()}})
assert asked_then == [DRAWER], "archival moves a memory between tiers, it does not make it untrue"
asked_then = hits(
client,
{"query": "钥匙", "filters": {"as_of": (archived_at - timedelta(hours=1)).isoformat()}},
)
assert asked_then == [DRAWER], (
"archival moves a memory between tiers, it does not make it untrue"
)
def test_asking_as_of_another_moment_does_not_borrow_another_holders_memory(
@@ -96,26 +109,111 @@ def test_asking_as_of_another_moment_does_not_borrow_another_holders_memory(
) -> None:
atom_id, week_ago = store_the_pair(client)
client.post("/api/memory/supersede", json={"atom_id": atom_id, "text": CAR})
stranger = hits(client, {"query": "钥匙", "filters": {"as_holder": "stranger", "as_of": week_ago.isoformat()}})
stranger = hits(
client,
{"query": "钥匙", "filters": {"as_holder": "stranger", "as_of": week_ago.isoformat()}},
)
assert stranger == [], "a moment in time does not grant access to somebody else's memory"
as_wife = hits(client, {"query": "钥匙", "filters": {"as_holder": "wife", "as_of": week_ago.isoformat()}})
as_wife = hits(
client, {"query": "钥匙", "filters": {"as_holder": "wife", "as_of": week_ago.isoformat()}}
)
assert as_wife == [DRAWER]
def test_the_dense_leg_reads_the_same_window_as_the_words_leg(settings: Settings) -> None:
"""Both legs answer an as-of question from one rule, or the fusion would mix two moments."""
service = MemoryService(settings, Database(settings))
def test_the_dense_leg_reads_the_same_window_as_the_words_leg(
tmp_path: Path, fake_embeddings: Any
) -> None:
"""Both legs answer an as-of question from one rule, or the fusion would mix two moments.
The dense leg is enabled here with a stand-in model: with the default off, this test would
pass while the vector side of `as_of` had no coverage at all.
"""
from ely_eye.retrieval import RetrievalService
from ely_eye.vector_index import VectorIndex, expected_vectors
settings = Settings(home=tmp_path, default_holder="wife", dense_embeddings_enabled=True)
database = Database(settings)
index = VectorIndex(settings, database, fake_embeddings())
service = MemoryService(settings, database, vectors=index)
week_ago = datetime.now(UTC) - WEEK
drawer = service.remember(
DRAWER, holder_id="wife", origin=MemoryOrigin.self_, event_time=week_ago, event_time_precision=TimePrecision.day
DRAWER,
holder_id="wife",
origin=MemoryOrigin.self_,
event_time=week_ago,
event_time_precision=TimePrecision.day,
)
service.supersede(drawer.atom_id, text=CAR, holder_id="wife")
from ely_eye.retrieval import RetrievalService
assert expected_vectors(database) == 2, (
"a retired memory keeps its vector, or no question can reach it"
)
retrieval = RetrievalService(settings=settings, database=Database(settings))
query = index.embeddings.embed_query("钥匙")
now = index.search(query, 10, as_holder="wife")
assert list(now) == [f"mem-{_sha(CAR, 'wife')}:text"], (
"asked now, the vector side offers the current memory"
)
then = index.search(
query, 10, as_holder="wife", as_of=(week_ago + timedelta(days=1)).isoformat()
)
assert f"mem-{_sha(DRAWER, 'wife')}:text" in then, (
"the retired memory is still reachable through the vectors"
)
# Asked through the retrieval service, so the plumbing that hands `as_of` to the engine is pinned too.
criteria = SearchFilters(as_holder="wife", as_of=week_ago + timedelta(days=1)).model_dump(
exclude_none=True
)
criteria["cartridge_ids"] = None
open_now = SearchFilters(as_holder="wife").model_dump(exclude_none=True)
open_now["cartridge_ids"] = None
retrieval = RetrievalService(settings, database)
retrieval.embeddings = fake_embeddings() # type: ignore[assignment]
retrieval.vectors = index # type: ignore[assignment]
assert f"mem-{_sha(DRAWER, 'wife')}:text" in retrieval._dense_search("钥匙", 10, criteria)
assert f"mem-{_sha(DRAWER, 'wife')}:text" not in retrieval._dense_search("钥匙", 10, open_now)
now_page = retrieval.search("钥匙", filters=SearchFilters(as_holder="wife"))
assert [hit.atom.text for hit in now_page.hits] == [CAR]
then_page = retrieval.search(
"钥匙", filters=SearchFilters(as_holder="wife", as_of=week_ago + timedelta(days=1))
)
assert [hit.atom.text for hit in then_page.hits] == [DRAWER]
def test_a_memory_retired_at_a_future_date_still_answers_now(
client: TestClient, settings: Settings
) -> None:
"""A change booked for next year has not happened, so the old fact is still the current one."""
recorded = datetime.now(UTC) - timedelta(days=3)
stored = client.post(
"/api/memory",
json={"text": DRAWER, "event_time": recorded.isoformat(), "event_time_precision": "day"},
)
assert stored.status_code == 200, stored.text
atom_id = stored.json()["atom_id"]
future = datetime.now(UTC) + timedelta(days=365)
booked = client.post(
"/api/memory/supersede",
json={
"atom_id": atom_id,
"text": CAR,
"event_time": future.isoformat(),
"event_time_precision": "day",
},
)
assert booked.status_code == 200, booked.text
assert hits(client, {"query": "钥匙"}) == [DRAWER], (
"a retirement dated next year has not taken effect"
)
listed = client.get("/api/atoms", params={"source_id": f"mem-{_sha(DRAWER, 'wife')}"})
assert listed.status_code == 200, listed.text
assert listed.json()["total"] == 1, "the listing and the search read the same moment"
later = hits(
client, {"query": "钥匙", "filters": {"as_of": (future + timedelta(days=1)).isoformat()}}
)
assert later == [CAR], "asked about the future, the booked change has taken effect"
def _sha(statement: str, holder: str) -> str:
return hashlib.sha256(f"{holder}\n{statement}".encode()).hexdigest()[:16]
+9 -3
View File
@@ -225,17 +225,23 @@ def test_the_index_survives_a_reopen(tmp_path: Path, fake_embeddings: Callable[[
assert reopened.search(reopened.embeddings.embed_query(KEYS), 10)
def test_superseding_moves_the_vector_rather_than_keeping_two(tmp_path: Path, fake_embeddings: Callable[[], Any]) -> None:
def test_a_superseded_memory_keeps_its_vector_but_leaves_the_current_answer_alone(
tmp_path: Path, fake_embeddings: Callable[[], Any]
) -> None:
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")
replacement = memory.supersede(old.atom_id, text=PASSPORT, holder_id="wife")
facts = index.stats()
assert facts["vectors"] == 1, "the closed memory lost its vector and the replacement took its place"
assert facts["vectors"] == 2, "a closed memory keeps its vector, or a question about the past cannot reach it"
assert facts["pending"] == 0
current = index.search(index.embeddings.embed_query(KEYS), 10, as_holder="wife")
assert list(current) == [replacement.atom_id], "asked now, only the replacement answers"
then = index.search(index.embeddings.embed_query(KEYS), 10, as_holder="wife", as_of=old.ingested_at.isoformat())
assert list(then) == [old.atom_id], "asked before the retirement, the old memory is still findable"
def test_a_store_that_never_built_its_index_says_so_instead_of_stalling_a_search(