Stop a room number from being read as a room's reading

A quantity conflict compared labels, and a label can carry a number that
identifies the thing rather than measuring it. 「Room 3 holds 22 degrees」 and
「Room 4 holds 24 degrees」 shared the subject 「room」, so the store recorded a
conflict over the room numbers and dropped Room 3 from the answer page: asking
about degrees returned one room's reading and hid the other room entirely.
「The router 1/2 forwards 220/240 packets」 and 「2026 saw 3 / 2027 saw 5
releases」 failed the same way.

A bare number standing before any predicate is part of the subject's name, so it
joins the subject key and is never recorded as a value. A number that stands past
a word still measures: 「The rent is 2200 / 2400 dollars」 conflicts over rent,
and a number fused inside a CJK run stays a value, because 「恒温器调到」 states its
predicate inside the subject token and the motivating pair must keep conflicting.
The same year counting twice is now a conflict (「2026 saw 3 / 5 releases」), which
the old rule missed.

Only a number renames a subject: 「Room A」 and 「Room B」 still read as one room
whose reading moved, and an English qualifier does not split a subject the way a
Chinese one does. Both boundaries are disclosed and pinned by tests rather than
smoothed over.

Also collapses six two-line comment blocks back to one line each, which is the
rule this repo already enforces, and corrects the record where it claimed the
nearest label was 「书房」 (it is 「设定」) and that the subject rule only ever
missed conflicts.

Gates: pytest -q 379 passed (conflicts 17 -> 22, six p2-3 files 78, three tier
files 35); ruff clean; bench.filesize_check clean; mypy cold cache 24 errors in 9
files, byte-identical to the recorded 9cb46c1 baseline; pyright 0 errors on the
touched files; go build/vet/test and gofmt clean; npm run build unchanged at
index-CSDHCFNo.js; mcp_write_smoke.sh PASS; whole-line comment runs of length >=2:
0 across tracked Python files.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 10:46:11 -04:00
1 parent 3daface332
commit 8bed319848
7 files changed
+104 -27

No files matched your search

+6 -3
View File
@@ -409,12 +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 且**数字**变了时记录;纯措辞分歧不记(换句话就会每次触发)。数字按「主语 + 最近标签」双键归一,实测依据:`恒温器放在书房,设定 22 度` 与 `恒温器调到 24 度` 只共享主语,只按最近词会键成 `书房`/`调到` 而找不到冲突。**但共享标签必须两侧主语相同才算冲突**(`trust.quantity_conflict`):评审实测出 D31——只共享谓语的不同事实早先被记成冲突(`客厅的恒温器调到 22 度` + `卧室的恒温器调到 24 度` → `调到` 22→24;`她买了 3 本书` + `他买了 5 本书` → `买了` 3→5),后果是 `compile("客厅的恒温器现在几度")` 的 `packed_text` 只剩卧室那条,问客厅拿到卧室的数字;现在这两对都不记,而 `The router forwards 220/240 packets` 主语相同仍是冲突,且报 `router`(主语自己变了就点主语)而不是排序靠前的 `forwards`。主语落在虚词后面要扫到第一个实词(`The router …` 的主语是 `router` 不是 `the`),扫不到主语就不记(fail-closed,`22 度` + `24 度` → 0 条)。明写的限制:store 比的是标签不是语义,同主语量不同东西仍算一个量——`恒温器设定 22 度` 与 `恒温器功率 100 瓦` 记一条冲突,由人裁决,测试 `test_the_same_subject_measuring_two_things_still_reads_as_one_quantity` 钉住这个边界。主语这边也有两个方呰,都是**漏报而不是误报**,两条都有测试钉住:给主语加限定词会被当成另一个东西(`恒温器调到 22 度` 然候 `客厅的恒温器调到 24 度` → 0 条,主语从 `恒温` 变成 `客厅`;两条都留着、什么都没藏,只是这次更新没被当成不一致,人仍可 `supersede`);一个标签下**第一个**数字就是被比较的值(`2026 saw 3 releases` 记下的是 2026,所以 `2026 saw 5 releases` → 0 条)。检索抑制有**同页条件**:`losing_sides` 要求旧新两侧都在候选 id 里,页面先排宽到 `limit * CANDIDATE_WINDOW`(=10)再裁(`_rank` 内部已截断),实测 `top_k=1` 仍给新侧;只有新侧完全没被召回时旧值才留在页上,此时读者看到的是被替换过的数字、且没有冲突标记。裁决后两侧都回来且 `valid_to` 仍为 None——裁决不等于 supersede。验收:`backend/tests/test_memory_conflicts.py`(17)+ 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)。主语落在虚词后面要扫到第一个实词(`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)。
- 验收(原文两条):
- 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 产品不会迁移)。延迟只引提交产物里的数:shipped 臂 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` 374 passed;六文件 73 passed(tiers 13 / conflicts 17 / 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 冲突仍成立)。
- 分层没动检索质量:同一 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`)。
- **评审第 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 格子。
- 顺带:`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** 共享记忆
+1 -2
View File
@@ -322,8 +322,7 @@ class MemoryService:
)
self.db.upsert_atoms([rollup])
self.vectors.sync_atoms([atom_id])
# The watermark is the newest memory actually rolled up, not the clock of the pass: a
# consolidation run with a clock behind the memories would otherwise count them twice.
# The watermark is the newest memory actually rolled up, not the clock of the pass: a pass run behind the memories would count them twice.
record = ConsolidationRecord(
holder_id=holder_id,
scenario=scenario,
+1 -2
View File
@@ -315,8 +315,7 @@ def apply(connection: sqlite3.Connection) -> None:
if name not in existing:
connection.execute(f"ALTER TABLE atoms ADD COLUMN {name} {definition}")
if version < 10:
# CREATE VIEW IF NOT EXISTS keeps the old body, and the tenth version changed which rows
# the current tier contains, so an upgraded database has to be told to look again.
# 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.
connection.execute("DROP VIEW IF EXISTS v_atoms_current")
connection.executescript(VIEWS)
+31 -11
View File
@@ -223,26 +223,26 @@ def numeric_values(text: str) -> StatedNumbers:
A number is attributed twice: to the subject the memory leads with, and to the content word
standing beside it. 「恒温器放在书房,设定 22 度」 and 「恒温器调到 24 度」 phrase the setting
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.
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.
"""
try:
tokens = fts_terms(text)
except ValueError:
return StatedNumbers(subject="", values={})
subject = ""
for token in tokens:
# 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」.
subject = _label(token, first=True)
if subject:
break
subject, named_at = _subject_and_rename_slot(tokens)
values: dict[str, str] = {}
beside = ""
for token in tokens:
for index, token in enumerate(tokens):
stated = _NUMBER.findall(token)
if not stated:
beside = _label(token) or beside
continue
if index == named_at and not _LETTER.search(token):
# A bare number written onto the subject names the object rather than measuring it: 「Room 3」 is not one reading.
subject = f"{subject}{stated[0]}"
continue
for value in stated:
if subject:
values.setdefault(subject, value)
@@ -251,6 +251,26 @@ def numeric_values(text: str) -> StatedNumbers:
return StatedNumbers(subject=subject, values=values)
def _subject_and_rename_slot(tokens: list[str]) -> tuple[str, int]:
"""The thing a memory is about, and the slot where a number would rename rather than measure it.
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
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)):
return token.strip(_PUNCTUATION), -1
head = _label(token, first=True)
if head:
return head, -1 if _label(token) != head else index + 1
return "", -1
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.
@@ -267,8 +287,7 @@ def quantity_conflict(older_text: str, newer_text: str) -> QuantityConflict | No
return None
subject = older.subject
if subject in newer.values and _as_float(older.values[subject]) != _as_float(newer.values[subject]):
# The subject carries the value here, so it is named rather than whichever shared label
# sorts first: 「router」 is what the pair disagrees about, not the verb beside it.
# The subject itself carries the two values, so it is named rather than the verb beside it.
return QuantityConflict(term=subject, older_value=older.values[subject], newer_value=newer.values[subject])
term = conflicting_number(older.values, newer.values)
if term is None:
@@ -316,3 +335,4 @@ def _label(token: str, *, first: bool = False) -> str:
_PUNCTUATION = " \t\n.,;:!?。,;:!?、"
_NUMBER = re.compile(r"[-+]?\d+(?:\.\d+)?")
_LETTER = re.compile(r"[^\W\d_]")
+63 -5
View File
@@ -201,7 +201,10 @@ 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:
service, _ = _lane(tmp_path, ["22 度", "24 度"])
assert service.conflicts("wife") == [], "with no subject named the store cannot claim they are one thing"
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"
)
def test_a_qualifier_on_the_subject_reads_as_a_different_thing(tmp_path) -> None:
@@ -214,10 +217,65 @@ def test_a_qualifier_on_the_subject_reads_as_a_different_thing(tmp_path) -> None
)
def test_the_first_number_under_a_label_is_the_value_that_is_compared(tmp_path) -> None:
service, _ = _lane(tmp_path, ["2026 saw 3 releases", "2026 saw 5 releases"])
def test_a_year_that_leads_a_memory_names_the_thing_it_counts(tmp_path) -> None:
service, _ = _lane(tmp_path, ["2026 saw 3 releases", "2027 saw 5 releases"])
assert service.conflicts("wife") == [], (
"the year leads, so it is what the subject's value records; a count that moves behind it "
"is not noticed — the store names one quantity per label, not every quantity in a sentence"
"the year is what the count belongs to, not the quantity that moved: 2026 and 2027 are two "
"records, and comparing their counts would call every timeline a disagreement"
)
def test_the_same_year_counting_differently_is_a_conflict(tmp_path) -> None:
service, _ = _lane(tmp_path, ["2026 saw 3 releases", "2026 saw 5 releases"])
found = service.conflicts("wife")
assert [conflict.terms for conflict in found] == [["saw"]], (
"one year, two counts: the year names the record and the count standing behind it is the "
"quantity that moved, which is what the holder asked to be told about"
)
assert [(conflict.older_value, conflict.newer_value) for conflict in found] == [("3", "5")]
def test_room_numbers_name_the_room_rather_than_measure_it(tmp_path) -> None:
service, database = _lane(tmp_path, ["Room 3 holds 22 degrees", "Room 4 holds 24 degrees"])
assert service.conflicts("wife") == [], (
"a number written onto the subject is part of its name: Room 3 and Room 4 are two rooms, "
"each steady at its own temperature, and suppressing one would throw away a real fact"
)
assert sorted(_search(tmp_path, database, "Room")) == sorted(
["Room 3 holds 22 degrees", "Room 4 holds 24 degrees"]
), "both rooms stay findable"
def test_two_routers_keep_their_own_counters(tmp_path) -> None:
service, _ = _lane(tmp_path, ["The router 1 forwards 220 packets", "The router 2 forwards 240 packets"])
assert service.conflicts("wife") == [], (
"the counter that identifies the router is not the counter it reports, so each router keeps "
"its own number the way each room keeps its own temperature"
)
def test_a_number_past_a_word_is_still_the_value_that_moved(tmp_path) -> None:
service, _ = _lane(tmp_path, ["The rent is 2200 dollars", "The rent is 2400 dollars"])
found = service.conflicts("wife")
assert [conflict.terms for conflict in found] == [["rent"]], (
"a number standing past a word measures the subject instead of naming it, which is what "
"keeps the ordinary case — a rent that changed — reaching the holder"
)
assert [(conflict.older_value, conflict.newer_value) for conflict in found] == [
("2200", "2400")
]
def test_a_letter_qualifier_does_not_rename_the_room(tmp_path) -> None:
service, _ = _lane(tmp_path, ["Room A holds 22 degrees", "Room B holds 24 degrees"])
assert len(service.conflicts("wife")) == 1, (
"only a number written onto the subject renames it, so Room A and Room B still read as one "
"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"
)
+1 -2
View File
@@ -284,8 +284,7 @@ def test_a_ninth_version_database_gains_the_tiers_in_place(tmp_path: Path) -> No
_seed_source(database)
database.upsert_atoms([_atom("key")])
with database.connect() as connection:
# The downgrade removes what a ninth build never had, including the view and indexes
# that reference those columns, so the reopened store really is a ninth-version one.
# The downgrade removes what a ninth build never had, view and indexes included, so the reopened store really is ninth-version.
connection.executescript(
"""
DROP VIEW IF EXISTS v_atoms_current;
+1 -2
View File
@@ -323,8 +323,7 @@ def measure(home: Path, scratch: Path, objects: Path, clip_index: Path, probes:
f"p50 {timing['p50'] * 1000:.1f}ms",
flush=True,
)
# Read the version after the arms: the copy arrives at the source's schema and the product
# migrates it the first time it opens, so the version before the run is not the one measured.
# Read the version after the arms: the copy arrives at the source's schema and the product migrates it on first open, so the pre-run version is not what was measured.
return {
"commit": git_commit(),
"schema_version": _schema(copy_home),