Stop a stale server from answering for this tree

The smoke script's build fingerprint asserted on the MCP reply, but the Go
layer decodes the HTTP answer into its own struct and re-emits a missing field
as zero, so a server from before the memory tiers still passed. Point the
fingerprint at the Python answer instead: /api/memory/working answers 404 on
4c6af87 and 200 here, both measured by starting each build myself.

Record the miss side of the rename rule. Whether a bare number renames its
subject depends on that memory's own sentence, so 「温度」 and 「温度24」 never
compare and an update goes unnoticed; both memories stay on the answer page,
so the miss costs a notice rather than a wrong number. Pin it with a test and
keep the mechanism, since comparing an unrenamed subject as a fallback would
put room numbers back in the ring as readings.

Correct the direction of the rule in the record: the rename slot is the token
after the subject word, not before it. A leading number becomes the subject
rather than fusing forward, which 「3 room holds 22 degrees」 shows by taking
「3」 as its subject.

门禁:pytest 384 passed;六文件 83(conflicts 27);三文件 35;ruff clean;
filesize clean;audit 32/2/6 clean;mypy 冷缓存 24 errors / 9 files(净 0);
pyright 0;go 全套 + gofmt 空;npm 产物未变;smoke PASS。
This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 12:54:06 -04:00
1 parent 11e31eb7e3
commit 3d131e6a7e
3 files changed
+31 -5

No files matched your search

+4 -3
View File
@@ -409,15 +409,16 @@
- [x] p2-3.1 工作记忆层:`working <holder> <session>` 按记录顺序返回该 session 记下的记忆,不经检索;HTTP `GET /api/memory/working` 与 MCP `working_set` 走同一份实现(Go 只转发,写规则仍在一处)。空 session 直接拒:`a working set needs a session; without one every memory is working memory`。验收:`pytest backend/tests/test_memory_tiers.py backend/tests/test_memory_cli.py backend/tests/test_memory_api.py`(13 + 14 + 8,合跑 35 passed)+ `bash scripts/mcp_write_smoke.sh` 里跨进程那一段(session 名不合并两个 holder;陌生 session 返回空而不是别人的记忆)。提交 `dc9cfb5`。
- [x] p2-3.2 巩固台账:`consolidations` 表按 `(holder, scenario)` 存 watermark + rollup atom id,一条 lane 只有一份常驻摘要、原地替换;`lane_atoms` 用严格 `ingested_at > watermark`,所以 rollup 自己不会被再算一次。**实测出一个真缺陷**:`watermark_at` 原来写成本轮时钟,用落后于记忆的时钟重跑会把同一批算两遍(第二次 `atom_count=4`),现改成 `max(atom.ingested_at for atom in batch)`。验收:`test_a_lane_rolls_up_once_and_keeps_rolling_from_where_it_stopped`(第二次返回 None,第三次 `atom_count=3` 且 `rollup_atom_id` 不变)。
- [x] p2-3.3 时间/价值字段:schema v10 给 atom 加 `session_id` / `importance` / `recall_count` / `last_recalled_at` / `archived_at`;`v_atoms_current` 加 `archived_at IS NULL`,且 `apply()` 先 `DROP VIEW v_atoms_current`——`CREATE VIEW IF NOT EXISTS` 会留着旧视图体、把归档当没看见,升级测试钉住这点。**`decay` 没有落成字段**:没测过的衰减公式就是编故事,所以归档规则把条件写在命令里(`--older-than-days`、`--max-importance`、`recall_count = 0`),`importance` 是人声明的、`recall_count` 是 store 自己观察的。`importance` **未设也算不值钱**(SQL 是 `importance IS NULL OR importance <= ?`),rule 字符串早先只写 `importance at or under 0.5`、漏了 NULL,被评审判为 D30,现写 `importance unset or at or under 0.5`。验收:`test_a_search_counts_the_memory_it_handed_back`、`test_a_memory_a_search_already_returned_is_not_stale`、`test_an_archived_memory_stops_answering_without_leaving_the_store`、`test_the_archive_rule_states_its_own_terms_and_can_be_read_before_it_acts`(dry-run 先点名,`atom_count` 不减)、`test_a_restore_counts_the_memories_it_brought_back_and_only_those`(`restore_atoms` 加了 `archived_at IS NOT NULL`:一个从未归档的 id 早先打印 `{"restored": 1}` 却什么都没变)。
- [x] p2-3.4 冲突记录持久化:`conflicts` 表存(旧, 新, 共享标签, 两个值, 是否已裁决),只在同 holder 同 scenario 且**数字**变了时记录;纯措辞分歧不记(换句话就会每次触发)。数字按「主语 + 最近标签」双键归一(实测 `numeric_values('恒温器放在书房,设定 22 度')` 交出的值键是 `恒温` 与 `设定`,不是 `书房`——评审第 2 轮 D38 纠正的就是这里)。**冲突必须两侧主语相同**(`trust.quantity_conflict`),而且**写在主语上的裸数字是名字、不是量**(`_subject_and_rename_slot`)。评审第 1 轮实测出 D31:只共享谓语的不同事实早先被记成冲突(`客厅的恒温器调到 22 度` + `卧室的恒温器调到 24 度` → `调到` 22→24;`她买了 3 本书` + `他买了 5 本书` → `买了` 3→5),后果是 `compile("客厅的恒温器现在几度")` 的 `packed_text` 只剩卧室那条——问客厅拿到卧室的数字。第 2 轮又实测出 D37,方向相反:主语相同、但那个数字其实在给对象编号,也被当成一个量(`Room 3 holds 22 degrees` + `Room 4 holds 24 degrees` → `room` 3→4,搜 "degrees" 只剩 Room 4;`The router 1 forwards 220 packets` + `The router 2 forwards 240 packets` → `router` 1→2;`2026 saw 3 releases` + `2027 saw 5 releases` → `saw` 2026→2027)。现在的规则:主语之前独立成词的裸数字并入主语键、不当值(`room3` vs `room4`、`router1` vs `router2`、`2026` vs `2027` → 各 0 条),而**同一个主语**下数字变了仍是冲突(`The router forwards 220/240 packets` → 报 `router`,因为主语自己带着值;`2026 saw 3 releases` + `2026 saw 5 releases` → `saw` 3→5,早先读出 0 条,属漏报)。数字只要站在某个词之后就是量不是名字(`The rent is 2200 dollars` + `The rent is 2400 dollars` → `rent` 2200→2400;`恒温器 is 22 degrees` + `恒温器 is 24 degrees` → `恒温` 22→24),CJK 串里融合的数字也仍是量(`温度22度` + `温度24度` → `温度` 22→24)。**但写在主语上的裸数字只有后面还留着别的数字时才是名字**(`_numbers_follow`):句子里再没有第二个数字时,那个数字就是这条记忆要报的量——`温度 22 度` + `温度 24 度`、`室温`、`设定`、`预算 5000 元` + `预算 8000 元`、`The thermostat 22 degrees` + `The thermostat 24 degrees` 各 1 条(评审第 3 轮 D40:早先两字主语后的数字被并进主语、`values` 空,整条 lane 一条都不记,三字主语 `恒温器 22/24 度` 却记 1 条——主语长度决定记不记,当时没写)。主语落在虚词后面要扫到第一个实词(`The router …` 的主语是 `router` 不是 `the`),扫不到主语就不记(fail-closed)。明写的限制(每条都有测试钉住):store 比的是标签不是语义,同主语量不同东西仍算一个量——`恒温器设定 22 度` 与 `恒温器功率 100 瓦` 记一条冲突,由人裁决(`test_the_same_subject_measuring_two_things_still_reads_as_one_quantity`);给主语加限定词会被当成另一个东西,这是**漏报**(`恒温器调到 22 度` 然后 `客厅的恒温器调到 24 度` → 0 条,主语从 `恒温` 变成 `客厅`;两条都留着、什么都没藏,只是这次更新没被当成不一致,人仍可 `supersede`);只有**数字**能给主语改名,字母不行、英文限定词也不行,这是中英不对称的**误报**(`Room A holds 22 degrees` + `Room B holds 24 degrees` → 1 条;`The router model X forwards 220 packets` + `The router forwards 240 packets` → 1 条,而中文 `三楼的恒温器` / `四楼的恒温器` 会分开成 0 条);主语后面没有第二个数字时,编号会被当成量——`Room 3 is big` + `Room 4 is big` → 1 条(`room` 3→4),标签分不出编号和读数,这是误报方向、交人裁决(`test_an_identifier_with_no_reading_after_it_reads_as_a_quantity`);值键仍会把主语之后的第二个数字挂到最近的标签上(`Room 3 holds 22 degrees at 5 pm` 的 `degrees` 键拿到 `5`),本轮比对先看主语键所以没暴露后果(评审精度注 P5);两条都只剩数字的记忆各有各的主语(`numeric_values('22 度').subject` 是 `22`、`'24 度'` 是 `24`,主语不同所以 0 条——评审第 3 轮 D41 纠正的就是我原先写的「没有主语可比」)。检索抑制有**同页条件**:`losing_sides` 要求旧新两侧都在候选 id 里,页面先排宽到 `limit * CANDIDATE_WINDOW`(=10)再裁(`_rank` 内部已截断),实测 `top_k=1` 仍给新侧;只有新侧完全没被召回时旧值才留在页上,此时读者看到的是被替换过的数字、且没有冲突标记。裁决后两侧都回来且 `valid_to` 仍为 None——裁决不等于 supersede。验收:`backend/tests/test_memory_conflicts.py`(26)+ MCP `memory_conflicts`Line truncated
- [x] p2-3.4 冲突记录持久化:`conflicts` 表存(旧, 新, 共享标签, 两个值, 是否已裁决),只在同 holder 同 scenario 且**数字**变了时记录;纯措辞分歧不记(换句话就会每次触发)。数字按「主语 + 最近标签」双键归一(实测 `numeric_values('恒温器放在书房,设定 22 度')` 交出的值键是 `恒温` 与 `设定`,不是 `书房`——评审第 2 轮 D38 纠正的就是这里)。**冲突必须两侧主语相同**(`trust.quantity_conflict`),而且**写在主语上的裸数字是名字、不是量**(`_subject_and_rename_slot`)。评审第 1 轮实测出 D31:只共享谓语的不同事实早先被记成冲突(`客厅的恒温器调到 22 度` + `卧室的恒温器调到 24 度` → `调到` 22→24;`她买了 3 本书` + `他买了 5 本书` → `买了` 3→5),后果是 `compile("客厅的恒温器现在几度")` 的 `packed_text` 只剩卧室那条——问客厅拿到卧室的数字。第 2 轮又实测出 D37,方向相反:主语相同、但那个数字其实在给对象编号,也被当成一个量(`Room 3 holds 22 degrees` + `Room 4 holds 24 degrees` → `room` 3→4,搜 "degrees" 只剩 Room 4;`The router 1 forwards 220 packets` + `The router 2 forwards 240 packets` → `router` 1→2;`2026 saw 3 releases` + `2027 saw 5 releases` → `saw` 2026→2027)。现在的规则:紧挨在主语**之后**的裸数字并入主语键、不当值(改名槽就是主语 token 的下一个,`trust.py:275-276`;`room3` vs `room4`、`router1` vs `router2` → 各 0 条);开头就是数字时那个数字自己成为主语、不向前融合(`2026 saw …` 走 `trust.py:270-272` 的首数字分支,实测 `3 room holds 22 degrees` 主语是 `3` 而不是 `3room`;`2026` vs `2027` 因此也各 0 条),而**同一个主语**下数字变了仍是冲突(`The router forwards 220/240 packets` → 报 `router`,因为主语自己带着值;`2026 saw 3 releases` + `2026 saw 5 releases` → `saw` 3→5,早先读出 0 条,属漏报)。数字只要站在某个词之后、且不在紧挨主语那个位置,就是量不是名字(`The rent is 2200 dollars` + `The rent is 2400 dollars` → `rent` 2200→2400;`恒温器 is 22 degrees` + `恒温器 is 24 degrees` → `恒温` 22→24),CJK 串里融合的数字也仍是量(`温度22度` + `温度24度` → `温度` 22→24)。**但写在主语上的裸数字只有后面还留着别的数字时才是名字**(`_numbers_follow`):句子里再没有第二个数字时,那个数字就是这条记忆要报的量——`温度 22 度` + `温度 24 度`、`室温`、`设定`、`预算 5000 元` + `预算 8000 元`、`The thermostat 22 degrees` + `The thermostat 24 degrees` 各 1 条(评审第 3 轮 D40:早先两字主语后的数字被并进主语、`values` 空,整条 lane 一条都不记,三字主语 `恒温器 22/24 度` 却记 1 条——主语长度决定记不记,当时没写)。主语落在虚词后面要扫到第一个实词(`The router …` 的主语是 `router` 不是 `the`),扫不到主语就不记(fail-closed)。明写的限制(每条都有测试钉住):store 比的是标签不是语义,同主语量不同东西仍算一个量——`恒温器设定 22 度` 与 `恒温器功率 100 瓦` 记一条冲突,由人裁决(`test_the_same_subject_measuring_two_things_still_reads_as_one_quantity`);给主语加限定词会被当成另一个东西,这是**漏报**(`恒温器调到 22 度` 然后 `客厅的恒温器调到 24 度` → 0 条,主语从 `恒温` 变成 `客厅`;两条都留着、什么都没藏,只是这次更新没被当成不一致,人仍可 `supersede`);只有**数字**能给主语改名,字母不行、英文限定词也不行,这是中英不对称的**误报**(`Room A holds 22 degrees` + `Room B holds 24 degrees` → 1 条;`The router model X forwards 220 packets` + `The router forwards 240 packets` → 1 条,而中文 `三楼的恒温器` / `四楼的恒温器` 会分开成 0 条);主语后面没有第二个数字时,编号会被当成量——`Room 3 is big` + `Room 4 is big` → 1 条(`room` 3→4),标签分不出编号和读数,这是误报方向、交人裁决(`test_an_identifier_with_no_reading_after_it_reads_as_a_quantity`);值键仍会把主语之后的第二个数字挂到最近的标签上(`Room 3 holds 22 degrees at 5 pm` 的 `degrees` 键拿到 `5`,`Room 3 holds 22 degrees in 2026` 的 `degrees` 键拿到年份 `2026`——第二个数字,含年份,都会这样挂上去),本轮比对先看主语键所以没暴露后果(评审精度注 P5);`_numbers_follow` 的**漏报**侧:改不改名取决于每条记忆自己后面还留着没有第二个数字,所以同一主语在两条记忆里可能一条被改名、另一条没被改名,两侧键永不相等——`温度 22 度` + `温度 24 度 湿度 40`、`温度 22 Line truncated
- 验收(原文两条):
- LongMemEval 风格两类**有分数**:`bench/reports/LEGS.md` 的 memory 表把 150 条可答 conversation 查询**整集**测了(不是 15 条抽样),shipped 配置下 knowledge-update **1.000**、temporal-reasoning **0.879**、multi-session 0.857、single-session-preference 0.800、single-session-user 0.960;开 dense 腿后 temporal-reasoning **0.950**、multi-session 0.920、preference 0.960。报告另有一列「最新的那条是否第一个回来」:words-only 0.52 / 0.48 / 0.37,dense 0.40 / 0.46 / 0.47——recall 满分不等于排序把被替换的旧值让开了位。
- 被 supersede 的旧事实不当作现行:`v_atoms_current` 过滤 `valid_to`(`backend/tests/test_memory_schema.py:163 test_v_atoms_current_hides_closed_retired_and_unbegun_memories`),Go store 读的也是这个视图(p0-2 时实测跨语言断过),smoke 里 supersede 之后旧 atom 不再出现在 `search_atoms` 结果里。
- 分层没动检索质量:同一 harness、同一 store、同一 300 探针,p2-3 前后 LEGS 每个 recall 格子**逐字相同**(0.847 / 0.863 / 0.873 / 0.860 / 0.873 / 0.817 / 0.848,memory 表同样),因为这批语料里没有冲突行也没有归档行——新增的是状态与规则,不是排序器。复现法(评审 D33 要求可复算):`git worktree add /tmp/pre_p23 4c6af87` 只把产品退回 p2-3 之前、harness 保持当前、同一 store 跑 `python -m bench.leg_check`——我量过一次,独立评审在同一对照下也量过一次,格子逐字相同(stamp 显示 schema v9,因为 pre-v10 产品不会迁移);这条对照要在主仓里跑并 `PYTHONPATH=<worktree>/backend/src`——worktree 里没有 `bench/runs/*`,直接跑会死在 `123 store sources are not corpus files`(`bench/ingest_baseline.py:87` 的 `source_map()` 按 cwd 解析语料路径)。延迟只引提交产物里的数:`arms[1]`「words only (the configured 24)」臂 p50 **122.5 ms**(load [17.23,10.83,6.53],`bench/reports/leg_check.json`);评审重跑同一 harness 得到 91.2 ms 而每个 recall 格子逐字不变——延迟随机器负载走,报告页脚已写明跨 run 不可比(早先两次跑的 101.6 / 87.5 ms 已被后一次覆盖,不作证据)。
- 门禁:`pytest -q` 383 passed;六文件 82 passed(tiers 13 / conflicts 26 / schema 12 / cli 14 / api 8 / product_api 9);三文件(tiers+cli+api)35 passed;ruff clean;`bench.filesize_check` clean;mypy 全仓冷缓存 24 errors/9 files = `9cb46c1` 基线(净 0);pyright 触碰文件 0 errors;go build/vet/test + gofmt clean;`npm run build` 产物 `index-CSDHCFNo.js` 未变(本轮未动前端);`bench.audit --product` → `audited 32 harness files, 2 reports, 6 set files` / clean;`mcp_write_smoke.sh` PASS(主语规则上线后跨进程那条 24/22 冲突仍成立)。
- 门禁:`pytest -q` 384 passed;六文件 83 passed(tiers 13 / conflicts 27 / schema 12 / cli 14 / api 8 / product_api 9);三文件(tiers+cli+api)35 passed;ruff clean;`bench.filesize_check` clean;mypy 全仓冷缓存 24 errors/9 files = `9cb46c1` 基线(净 0);pyright 触碰文件 0 errors;go build/vet/test + gofmt clean;`npm run build` 产物 `index-CSDHCFNo.js` 未变(本轮未动前端);`bench.audit --product` → `audited 32 harness files, 2 reports, 6 set files` / clean;`mcp_write_smoke.sh` PASS(主语规则上线后跨进程那条 24/22 冲突仍成立)。
- **评审第 1 轮 FAIL**(同一独立 pi,8 项全跑,~2 h / 462 工具轮):D30 rule 字符串漏 NULL、D31 标签粒度把不同事实当冲突(真行为缺陷,见 p2-3.4)、D32 没写同页条件、D33 「前后逐字相同」无产物、D34 引了未提交的 p50、D35 引了不存在的测试名——六条已全部按实测改掉,并新增 8 条测试钉住边界(两间房、两个人、一台路由器、同主语不同量、无主语、主语加了限定词、首数字当值、restore 只数真正归档过的)。该轮同时实测确认:全部门禁数字与本报告一致、两个反向实验复现、归档 dry-run 零副作用(sqlite `mtime_ns` 前后相同)、22→24→26 链产生 3 条冲突、跨 holder/跨 scenario 各 0 条、`supersede` 不记冲突、裁决后两侧回且 `valid_to` 仍 None、harness blob 三处全同、json 与 markdown 四表逐字一致、它自己的 pre-p2-3 对照复现了每个 recall 格子。
- **评审第 2 轮 FAIL**(同一独立 pi,8 项全跑,D30–D35 的修法全部实测成立):四条——D36 纪律:本轮新增两处整行 `#` 连续两行(`trust.py:234-235`、`:270-271`),并且它更正了自己第 1 轮的口径:按本仓 p1-6.4 那条规则(tokenize、任意缩进)`9cb46c1` 是 0 块、`deabbf7` 是 4 块(`memory.py:325`、`migrations.py:318`、`test_memory_schema.py:287`、`bench/leg_check.py:326`)、`3daface` 是 6 块,六处全部收成一行;D37 记录与行为不符且**方向与我写的相反**(见上面 p2-3.4 那条:误报同样会把一条记忆从答案页上摘掉,我原先只写了漏报一侧);D38 「最近标签是 `书房`」实测是 `设定`;D39 三处措辞错(「两个方呰」「然候」「主语自己变了就点主语」——代码条件是主语**自己带着**那个值)。修法不是加披露而是换机制:写在主语上的裸数字是名字不是量。评审另给两条精度说明(不算缺陷):那条 worktree 对照直接跑会死在 `123 store sources are not corpus files`,要在主仓里跑并 `PYTHONPATH=<worktree>/backend/src`;「shipped 臂」是我起的标签,产物里的名字是 `arms[1]`「words only (the configured 24)」(来自 `config.py:43 retrieval_top_k: int = 24`)。
- **评审第 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 字段)——已做:`scripts/mcp_write_smoke.sh:175` 现在要求 `remember` 的答复里有 `recall_count`(Go 侧无 `omitempty`,所以必发;`git show 4c6af87:ely-eye-mcp/internal/domain/types.go` 里 0 处、HEAD 1 处,旧构建缺这个字段就会当场 FAIL),这个断言确实会中断跑(字段名写错的那一版在正常跑里就报过 exit 1);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 全同,所以报告里的数字就是这份代码跑出来的,不必重测(重测只会把延迟换成另一个机器负载下的值)。
- **评审第 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 守卫仍只在开跑时探一次,现在指纹补上了这一口。该轮同时实测确认:门禁全绿且 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 的记录级缺陷暂缓。
- 顺带:`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`)。
- [ ] **p2-4** 共享记忆
- [ ] p2-4.1 多用户/多 agent 作用域 + ACL
+13
View File
@@ -318,3 +318,16 @@ def test_an_identifier_with_no_reading_after_it_reads_as_a_quantity(tmp_path) ->
"3 and 4 are compared as one quantity that moved, so this pair is recorded and the holder "
"settles it — the boundary the rule does not cross, and the price of not hiding a disagreement"
)
def test_a_second_number_in_one_memory_hides_the_update(tmp_path) -> None:
service, database = _lane(tmp_path, ["温度 22 度", "温度 24 度 湿度 40"])
assert service.conflicts("wife") == [], (
"whether a bare number renames its subject depends on that memory's own sentence, so 「温度」 "
"and 「温度24」 never compare — the temperature moved and the store did not notice"
)
assert sorted(_search(tmp_path, database, "温度")) == ["温度 22 度", "温度 24 度 湿度 40"], (
"the fail-closed side of the boundary: nothing is suppressed, the reader sees both readings "
"and decides for themselves"
)
+14 -2
View File
@@ -61,6 +61,18 @@ if [ "$ready" -ne 1 ]; then
exit 1
fi
# Fingerprint the answering build where the fields actually come from: the Python answer itself.
# /api/memory/working exists only on builds that carry the memory tiers, so a server left over from
# an older tree answers 404 here. Checking the MCP reply instead would prove nothing — the Go layer
# decodes into its own struct and re-emits a missing field as 0, so an old server still passes.
status="$(curl -s -o /dev/null -w '%{http_code}' \
"http://127.0.0.1:${port}/api/memory/working?holder=fingerprint&session=probe")"
if [ "$status" != "200" ]; then
echo "FAIL: port ${port} answers /api/status but returns HTTP $status for /api/memory/working;"
echo " the server on it predates the memory tiers — stop it and re-run"
exit 1
fi
# The gate, proved before anything else: a query needs nothing, a write needs the token.
status="$(curl -s -o "$home/refused.json" -w '%{http_code}' -X POST "http://127.0.0.1:${port}/api/memory" \
-H 'content-type: application/json' -d '{"text":"a write with no token"}')"
@@ -174,8 +186,8 @@ statement = "keys are in the hall drawer"
remembered = tool("remember", {"text": statement, "scenario": "home"})["atom"]
if "recall_count" not in remembered:
raise SystemExit(
"FAIL: the API answered with no recall_count; a server left over from before the memory tiers "
"took the port after our guard passed — stop it and re-run"
"FAIL: the MCP reply carries no recall_count; the Go binary is not built from this tree — "
"the server's own build is proved above, this proves the other half of the path"
)
if not remembered["holder_id"]:
raise SystemExit(f"FAIL: the server stamped no holder: {remembered}")