From 3d131e6a7e154d5a06c56e9204f0d8fe00855e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 12:54:06 -0400 Subject: [PATCH] Stop a stale server from answering for this tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- CHECKLIST.md | 7 ++++--- backend/tests/test_memory_conflicts.py | 13 +++++++++++++ scripts/mcp_write_smoke.sh | 16 ++++++++++++++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/CHECKLIST.md b/CHECKLIST.md index 9f37d04..b68886f 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -409,15 +409,16 @@ - [x] p2-3.1 工作记忆层:`working ` 按记录顺序返回该 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`(Go 4 条测试,`go test ./internal/mcpserver/` 按名跑 4 PASS)。 + - [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 度 湿度 40` + `温度 24 度`、`温度 22 度 湿度 40` + `温度 24 度 湿度 60`、`预算 5000 元 超了 8000` + `预算 6000 元` 各 0 条(`test_a_second_number_in_one_memory_hides_the_update`),同样是 fail-closed,两条都留在答案页上,只是这次更新没被当成不一致;不用「未改名主语兜底比对」修它,那样 `Room 3 holds 22 degrees` + `Room 4 holds 24 degrees` 会退回第 2 轮 D37 的误报;两条都只剩数字的记忆各有各的主语(`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`(27)+ MCP `memory_conflicts`(Go 4 条测试,`go test ./internal/mcpserver/` 按名跑 4 PASS)。 - 验收(原文两条): - 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=/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=/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 diff --git a/backend/tests/test_memory_conflicts.py b/backend/tests/test_memory_conflicts.py index b934ed1..9742ab3 100644 --- a/backend/tests/test_memory_conflicts.py +++ b/backend/tests/test_memory_conflicts.py @@ -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" + ) diff --git a/scripts/mcp_write_smoke.sh b/scripts/mcp_write_smoke.sh index 3f4eb31..5ae86ee 100755 --- a/scripts/mcp_write_smoke.sh +++ b/scripts/mcp_write_smoke.sh @@ -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}")