Read a short subject's only number as its reading
A two-character Chinese subject — 温度, 室温, 设定, 预算 — is the common way to state a reading, and the rename rule was swallowing its number: 「温度 22 度」 became a thing called 温度22 with nothing left to report, so the lane recorded no conflict at all and no later memory could disagree with it. A bare number written onto the subject is a name only while the memory still has another number waiting behind it; with nothing after it, it is the reading. Measured across 33 pairs: 6 change — five missed conflicts now recorded, plus one new disclosed boundary (「Room 3 is big」 and 「Room 4 is big」 read as one quantity that moved, because a label cannot tell an identifier from a reading). Everything else is byte-equal. Also corrected: a memory that is only a number does have a subject — the number itself — so 「22 度」 and 「24 度」 record nothing because they name two different things, not because the store found nothing to compare. The smoke script now refuses an answer from a server that predates the memory tiers: it requires recall_count in the remember response, a field the pre-p2-3 Go build never emits.
This commit is contained in:
1 parent
8bed319848
commit
11e31eb7e3
4 files changed
+73
-14
No files matched your search
+5
-6
@@ -331,7 +331,7 @@
|
||||
- [x] p1-6.3 删 v1/v2/v3 废弃端口
|
||||
- 量完的结论是**没有可删的**,不是没做:每个 `v1/v2/v3` 都承重——磁盘格式标签(`ely-eye-memory-capsule-v1` `proofs/checks_cartridge.py:128`、`ely-eye-{text,visual}-vectors-v1`、`ely-eye-kivi-replay-v1` `cartridge_assets.py:100,127,158`)、迁移阶梯(`migrations.py:181,186,239,241,243`,行号从 commit 本身量:`git show <sha>:path | grep -n`,工作区会被 pi-lens 重排而漂 9 行)、矛盾语料 fixture 名(`proofs/contradictions.py:114-135`)、第三方名字(MiniLM-L12-v2、ScreenSpot-v2、LongBench v2)、OpenAI 兼容的 `/v1` URL、缓存键里的 `compiler-v2` salt。没有废弃端口、别名或双代码路径
|
||||
- [x] p1-6.4 注释压到 ≤1 行
|
||||
- 口径:`tokenize` 找连续 `#` 行块。全仓 **39 块 / 86 行 → 0 块**,每块压成一行,压的时候留承重那句,实测数字与错误原文不删(`Unable to allocate 582. MiB for an array with shape (5632, 9024, 3)`、`288,266 rows for 165,973 atoms`、`[^[^\W\s]` 在 3.14 会吃掉 CJK)
|
||||
- 口径:`tokenize` 找连续的**整行** `#` 块(行尾注释不算)。全仓 **39 块 / 86 行 → 0 块**,每块压成一行,压的时候留承重那句,实测数字与错误原文不删(`Unable to allocate 582. MiB for an array with shape (5632, 9024, 3)`、`288,266 rows for 165,973 atoms`、`[^[^\W\s]` 在 3.14 会吃掉 CJK)
|
||||
- docstring 不动(全仓多行 docstring 114 条,与父提交同数):docstring 是模块与函数的契约,不是注释;p1-4 review 用的口径也是「新增多行 docstring 数不得增加」。第一轮 review 抓到搬家途中塌了 3 条——`store_search.py` 的 `search_atoms`(那条「每个过滤条件都是同一条字面 SQL 里的 `? IS NULL OR` 谓词」规则和 `as_holder` 的共享规则)和 `FtsPage`、`bench/run.py` 的模块 docstring——已按原文补回,行数仍全部在 500 以内
|
||||
- 门禁:`pytest -q` **269 passed**(那是 `9cb46c1` 当时的套件规模,p2-1 加完测试已到 287);`ruff check backend/src/ely_eye backend/tests bench` All checks passed;mypy 冷缓存(`.venv/bin/python -m mypy --cache-dir <新目录> backend/src bench`)**24 errors / 9 files**,与父提交同命令量到的 **24 / 6** 净新增 0——同样 24 条错,`training.py` 拆开后落到 4 个新文件里,所以文件数从 6 变 9;`pyright` 查该 commit 改动的 **46 个文件**(`git show --name-only 9cb46c1` = 46 个 `.py` + 1 个 `.md`,**0 个前端文件**)→ **4 errors**,与 HEAD 单个 `training.py` 在包副本里量到的 4 条同源(qwen_vl_utils 元组返回、两处 `save_pretrained(Path)`、`bitsandbytes` 未解析);go build/vet/test + gofmt 干净;`npm run build` + eslint 干净
|
||||
- herdr review(同一个 `p1review` pi,`strata/qwen3.8-flash-next-abliterated-iq3_s:high`)三轮:**round 1 FAIL 4** → **round 2 FAIL 1** → **round 3 PASS**。round 1 它自己跑出门禁、行数、搬体同一性、死代码、注释压缩、v1/v2/v3 全部承重,报的 4 条里 1 条是真产品缺陷:拆文件时掉了一些多行 docstring(全仓 114→111),掉的是契约不是装饰——`search_atoms` 的「每个过滤条件都是同一条字面 SQL 里的 `? IS NULL OR` 谓词」规则和 `as_holder` 共享规则、`FtsPage` 的游标理由、`bench/run.py` 的五臂说明(commit `28959c5` 按原文补回,114 条同父提交);其余 3 条是我写错的数:`store_search.py`/`bench/run.py`/`query_set.py` 行数、`cli.py` 调用点数(实为 11 个调用点亗 7 个方法)。round 2 剩 1 条:`migrations.py` 行号引成了 248/250,在 commit 里那是 PRAGMA 和空行——**根因是我在工作区量的,而 pi-lens 会在本轮之外重排文件把行号推下 9 行**;从此行号一律 `git show <sha>:path | grep -n` 从 commit 量(commit `9682657`)。round 3 它逐条重验:迁移阶梯 181/186/239/241/243 全中(并指出 `:243` 本身不带版本 token,`grep "v1\|v2"` 拓不到),CHECKLIST 那一行其余引用全部重验(`checks_cartridge.py:128`、`cartridge_assets.py:100/127/158`、`contradictions.py:114/135`、`config.py:20/27`、`cache_fabric.py:21`、`benchmarks.py:85`、`bench/build_screenspot.py:1/28`),`git diff --stat 28959c5..9682657` 只动一行,门禁重跑 269 passed / ruff / filesize_check 全绿
|
||||
@@ -409,16 +409,15 @@
|
||||
- [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)。主语落在虚词后面要扫到第一个实词(`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 条);一条记忆只有数字没有词时没有主语可比(`22 度` + `24 度` → 0 条)。检索抑制有**同页条件**:`losing_sides` 要求旧新两侧都在候选 id 里,页面先排宽到 `limit * CANDIDATE_WINDOW`(=10)再裁(`_rank` 内部已截断),实测 `top_k=1` 仍给新侧;只有新侧完全没被召回时旧值才留在页上,此时读者看到的是被替换过的数字、且没有冲突标记。裁决后两侧都回来且 `valid_to` 仍为 None——裁决不等于 supersede。验收:`backend/tests/test_memory_conflicts.py`(22)+ 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)。现在的规则:主语之前独立成词的裸数字并入主语键、不当值(`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
|
||||
- 验收(原文两条):
|
||||
- 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` 379 passed;六文件 78 passed(tiers 13 / conflicts 22 / schema 12 / cli 14 / api 8 / product_api 9);ruff 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 项全跑):一条行为缺陷 + 一条规则串与行为不符 + 四条记录——D31 只共享谓语的不同事实被记成冲突(客厅/卧室、她/他,后果是问客厅拿到卧室的数字);D30 归档 rule 字符串漏了 `importance IS NULL` 也归档;D32 「只给新的一侧」少了同页条件;D33 「前后逐字相同」没有留档产物(改成可复算的对照路径);D34 引了没提交进产物的 p50;D35 引了不存在的测试名。评审同时实测确认:门禁全绿且 mypy 与 `9cb46c1` 基线逐字相同、pyright 0、go 全套、`index-CSDHCFNo.js` 未变、smoke PASS;删掉主语相等那三行的反向实验恰好让那三条新测试变红;它自己重跑 `bench.leg_check`,32 行表格里除两列延迟外全部与提交产物相同,并量到 scratch store 里冲突 0 条 / 巩固 0 条 / 归档 0 行(所以「这批语料没有冲突行也没有归档行」从断言变成实测)。
|
||||
- **评审第 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`)。
|
||||
- D36–D39 修完的门禁:`pytest -q` **379 passed**(conflicts 17→22,六文件 78、三文件 35)、ruff 干净、`bench.filesize_check` 干净、mypy 冷缓存 **24 errors / 9 files**(净 0)、pyright 对触碰文件 **0 errors**、go 全套 + `gofmt -l` 空、`npm run build` 产物未变、`bench.audit --product` 32 / 2 / 6 clean、`mcp_write_smoke.sh` PASS、全仓整行 `#` 连续 ≥2 的块 **0**(tokenize 扫全部 tracked `.py`)。
|
||||
- 门禁:`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 冲突仍成立)。
|
||||
- **评审第 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 全同,所以报告里的数字就是这份代码跑出来的,不必重测(重测只会把延迟换成另一个机器负载下的值)。
|
||||
- 顺带:`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
|
||||
|
||||
@@ -225,7 +225,9 @@ def numeric_values(text: str) -> StatedNumbers:
|
||||
differently but lead with the same thing, and it is that shared label which makes the pair one
|
||||
disagreement about one quantity rather than two unrelated counts. A number written onto the
|
||||
subject itself is part of the object's name, not a quantity — 「Room 3」 and 「Room 4」 are two
|
||||
rooms — so it joins the subject instead of being recorded as a value.
|
||||
rooms — but only while the memory still has another number waiting to be its reading. A number
|
||||
with nothing after it is the reading itself: 「温度 22 度」 states a temperature, and renaming the
|
||||
thermostat after it would leave the memory with no quantity left to compare.
|
||||
"""
|
||||
try:
|
||||
tokens = fts_terms(text)
|
||||
@@ -257,20 +259,34 @@ def _subject_and_rename_slot(tokens: list[str]) -> tuple[str, int]:
|
||||
A memory that opens with a number is about that number: 「2026 saw 3 releases」 counts releases
|
||||
for 2026, so 2026 names the thing and 3 measures it. An English memory usually opens with an
|
||||
article, and a subject behind one is still the subject: 「The router forwards 220 packets」 leads
|
||||
with its router, not with 「the」. Only a number written onto the subject renames it; one
|
||||
standing past a word is a value, which is why 「The rent is 2200 dollars」 can move. A CJK run can
|
||||
with its router, not with 「the」. Only a number written onto the subject renames it, and only
|
||||
when the memory still has a later number to report: 「Room 3 holds 22 degrees」 names a room and
|
||||
then reads it, while 「温度 22 度」 has nothing behind the 22 but the unit. A number standing past
|
||||
a word is a value either way, which is why 「The rent is 2200 dollars」 can move. A CJK run can
|
||||
fuse the subject and what it reports into one token, and the number after that is a value too:
|
||||
「恒温器调到 24 度」 adjusts a thermostat, it does not name a thing called 「恒温器调到 24」.
|
||||
"""
|
||||
for index, token in enumerate(tokens):
|
||||
if _NUMBER.fullmatch(token.strip(_PUNCTUATION)):
|
||||
stated = _NUMBER.fullmatch(token.strip(_PUNCTUATION))
|
||||
if stated:
|
||||
return token.strip(_PUNCTUATION), -1
|
||||
head = _label(token, first=True)
|
||||
if head:
|
||||
return head, -1 if _label(token) != head else index + 1
|
||||
renames = _label(token) == head and _numbers_follow(tokens, index + 2)
|
||||
return head, index + 1 if renames else -1
|
||||
return "", -1
|
||||
|
||||
|
||||
def _numbers_follow(tokens: list[str], start: int) -> bool:
|
||||
"""Whether the memory still has a bare number to state after the slot in question.
|
||||
|
||||
A name is only worth taking when something else is left to be the reading. Without this test the
|
||||
shortest Chinese phrasings — 「温度 22 度」, 「预算 5000 元」 — fold their only number into the
|
||||
subject and the memory reports no quantity at all, so no later memory can ever disagree with it.
|
||||
"""
|
||||
return any(_NUMBER.fullmatch(token.strip(_PUNCTUATION)) for token in tokens[start:])
|
||||
|
||||
|
||||
def quantity_conflict(older_text: str, newer_text: str) -> QuantityConflict | None:
|
||||
"""The quantity two memories state differently, but only while they lead with the same subject.
|
||||
|
||||
|
||||
@@ -198,12 +198,12 @@ def test_the_same_subject_measuring_two_things_still_reads_as_one_quantity(tmp_p
|
||||
)
|
||||
|
||||
|
||||
def test_a_memory_that_leads_with_a_number_has_no_subject_to_conflict_on(tmp_path) -> None:
|
||||
def test_two_memories_that_are_only_a_number_do_not_name_the_same_thing(tmp_path) -> None:
|
||||
service, _ = _lane(tmp_path, ["22 度", "24 度"])
|
||||
|
||||
assert service.conflicts("wife") == [], (
|
||||
"the number is all the memory has to name itself with, so the store has no subject it could "
|
||||
"call the two one thing and records nothing"
|
||||
"each number becomes its own subject — 「22」 and 「24」 — so these are two different things "
|
||||
"to the store, not one quantity that moved; it finds a subject, it just is not the same one"
|
||||
)
|
||||
|
||||
|
||||
@@ -279,3 +279,42 @@ def test_a_letter_qualifier_does_not_rename_the_room(tmp_path) -> None:
|
||||
"room whose reading moved — the pair is recorded for the holder to settle rather than "
|
||||
"silently resolved, and this is the boundary the rule does not cross"
|
||||
)
|
||||
|
||||
|
||||
def test_a_short_chinese_reading_is_a_value_and_not_part_of_a_name(tmp_path) -> None:
|
||||
service, _ = _lane(tmp_path, ["温度 22 度", "温度 24 度"])
|
||||
|
||||
found = service.conflicts("wife")
|
||||
assert [conflict.terms for conflict in found] == [["温度"]], (
|
||||
"「温度 22 度」 has nothing behind the 22 but the unit, so the 22 is the reading and not the "
|
||||
"name of a thing called 温度22 — renaming it would leave the memory stating no quantity at "
|
||||
"all, and the most common Chinese phrasing would never be noticed as an update"
|
||||
)
|
||||
assert [(conflict.older_value, conflict.newer_value) for conflict in found] == [("22", "24")]
|
||||
|
||||
|
||||
def test_a_budget_that_changed_is_recorded(tmp_path) -> None:
|
||||
service, _ = _lane(tmp_path, ["预算 5000 元", "预算 8000 元"])
|
||||
|
||||
found = service.conflicts("wife")
|
||||
assert [(conflict.terms, conflict.older_value, conflict.newer_value) for conflict in found] == [
|
||||
(["预算"], "5000", "8000")
|
||||
], "a two-character subject is the common case, not an edge case: the number after it moves"
|
||||
|
||||
|
||||
def test_a_latin_reading_with_nothing_after_it_is_a_value_too(tmp_path) -> None:
|
||||
service, _ = _lane(tmp_path, ["The thermostat 22 degrees", "The thermostat 24 degrees"])
|
||||
|
||||
assert len(service.conflicts("wife")) == 1, (
|
||||
"the same rule has to hold in both scripts or it is a rule about Chinese, not about memories"
|
||||
)
|
||||
|
||||
|
||||
def test_an_identifier_with_no_reading_after_it_reads_as_a_quantity(tmp_path) -> None:
|
||||
service, _ = _lane(tmp_path, ["Room 3 is big", "Room 4 is big"])
|
||||
|
||||
assert len(service.conflicts("wife")) == 1, (
|
||||
"a label cannot tell an identifier from a reading: with no second number in the sentence the "
|
||||
"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"
|
||||
)
|
||||
@@ -172,6 +172,11 @@ for required in ("remember", "supersede_memory", "forget_memory", "mounted_cartr
|
||||
|
||||
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"
|
||||
)
|
||||
if not remembered["holder_id"]:
|
||||
raise SystemExit(f"FAIL: the server stamped no holder: {remembered}")
|
||||
atom_id = remembered["atom_id"]
|
||||
|
||||
Reference in new issue
Block a user