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:
7 files changed
+156
-40
No files matched your search
+12
-8
@@ -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` 的绿。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 仍是引擎启动参数,没有前缀复用实现 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user