diff --git a/CHECKLIST.md b/CHECKLIST.md index 3064504..d690bf7 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -406,15 +406,16 @@ - **评审第 8 轮 PASS**(同一独立 pi,6 项全跑,本轮 diff 仅 `CHECKLIST.md` +4/−3、代码 blob 全同):D28/D29 已按实测改掉并逐词复验,第 7 轮的精度建议也落实(`:381` 钉完整字符串 + 加长反例);全套门禁重测(325 passed、四文件 60 passed = 8/26/8/18、ruff、filesize、`--product` 31/2/6 clean、pyright 0、mypy 与 `9cb46c1` 基线逐字相同、go 全套、`index-CSDHCFNo.js 221.48 kB` + eslint 0);smoke **三条路径**全测:干净端口 exit 0、被 ely-eye 服务器占用 → 端口守卫 exit 1、被**非** ely-eye 的 HTTP 服务器占用(`/api/status` 404)→ 端口守卫放行但被自身进程存活检查兜住,仍 exit 1。评审另确认第 7 轮记录行引文属实(在 `d56996b` worktree 重跑摘表实验,恰好 3 条测试变红且含新加那条)。两条精度说明(不算缺陷)已采纳:`:387` 补上 `she` 与指示/疑问词一类。 - 顺带修的 harness 缺陷:`bench/audit.py --product` 还指着 `evals.py`,而证明套件在 p1-6 已拆进 `backend/src/ely_eye/proofs/`。它 fail fast 报了「not there」,但那条反自证规则其实已经**审不到产品**。改成 `product_sources(root)` 审整个包,并加测试钉住「必须有 `suite.py`、整包零 re-roll 违规」。现在 `--product` → `audited 31 harness files, 2 reports, 6 set files` / `clean`,顺带在当前树上重新证了 p0-6。 - [x] **p2-3** 短期/长期分层 - - [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`(12 + 14 + 8)+ `bash scripts/mcp_write_smoke.sh` 里跨进程那一段(session 名不合并两个 holder;陌生 session 返回空而不是别人的记忆)。提交 `dc9cfb5`。 + - [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 自己观察的。验收:`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` 不减)。 - - [x] p2-3.4 冲突记录持久化:`conflicts` 表存(旧, 新, 共享标签, 两个值, 是否已裁决),只在同 holder 同 scenario 且**数字**变了时记录;纯措辞分歧不记(换句话就会每次触发)。检索在未裁决的对里只给新的一侧(`losing_sides`,先排宽到 `limit * CANDIDATE_WINDOW` 再裁,因为 `_rank` 内部已经截断),裁决后两侧都回来且 `valid_to` 仍为 None——裁决不等于 supersede。数字按「主语 + 最近标签」双键归一,实测依据:`恒温器放在书房,设定 22 度` 与 `恒温器调到 24 度` 只共享主语,只按最近词会键成 `书房`/`调到` 而找不到冲突。验收:`backend/tests/test_memory_conflicts.py`(10)+ MCP `memory_conflicts`(Go 4 条测试,`go test ./internal/mcpserver/` 按名跑 4 PASS)。 + - [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)。 - 验收(原文两条): - 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`(`test_the_current_view_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 表同样),因为这批语料里没有冲突行也没有归档行——新增的是状态与规则,不是排序器。延迟三次分别 p50 101.6 / 87.5 / 122.5 ms(load [27.4,13.8,7.5] / [21.4,10.4,5.5] / [17.2,10.8,6.5]),报告已写明跨 run 不可比。 - - 门禁:`pytest -q` 366 passed;六文件 65 passed;ruff clean;mypy 全仓 24 errors/9 files = `9cb46c1` 基线(净 0);pyright 触碰文件 0 errors;go build/vet/test + gofmt clean;`bench.audit --product` → `audited 32 harness files, 2 reports, 6 set files` / clean;`mcp_write_smoke.sh` PASS。 + - 被 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 冲突仍成立)。 + - **评审第 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** 共享记忆 - [ ] p2-4.1 多用户/多 agent 作用域 + ACL diff --git a/backend/src/ely_eye/lifecycle_store.py b/backend/src/ely_eye/lifecycle_store.py index 4d9f32d..20810f6 100644 --- a/backend/src/ely_eye/lifecycle_store.py +++ b/backend/src/ely_eye/lifecycle_store.py @@ -163,7 +163,12 @@ def open_conflicts(database: Database, holder_id: str | None = None) -> list[Con def losing_sides(database: Database, atom_ids: list[str]) -> dict[str, str]: - """Among these atoms, which lost to a newer one in a conflict nobody resolved.""" + """Among these atoms, which lost to a newer one in a conflict nobody resolved. + + Both sides have to be in the list. A conflict is a claim about a pair, so an unresolved pair + that arrives whole is settled by dropping the older side; if only the older side turned up, the + store has not seen the replacement in this answer and says nothing about it. + """ if not atom_ids: return {} payload = to_json(atom_ids) @@ -211,11 +216,17 @@ def archive_atoms(database: Database, atom_ids: list[str], at: datetime) -> int: def restore_atoms(database: Database, atom_ids: list[str]) -> int: + """Bring archived memories back, counting only the rows that were actually archived. + + An id that was never archived changed nothing, so it is not counted: a report that says + 「restored 1」 has to mean one memory came back. + """ if not atom_ids: return 0 with database.connect() as connection: cursor = connection.execute( - "UPDATE atoms SET archived_at = NULL WHERE atom_id IN (SELECT value FROM json_each(?))", + "UPDATE atoms SET archived_at = NULL" + " WHERE archived_at IS NOT NULL AND atom_id IN (SELECT value FROM json_each(?))", (to_json(atom_ids),), ) return int(cursor.rowcount) diff --git a/backend/src/ely_eye/memory.py b/backend/src/ely_eye/memory.py index e119880..d92eca3 100644 --- a/backend/src/ely_eye/memory.py +++ b/backend/src/ely_eye/memory.py @@ -137,18 +137,18 @@ class MemoryService: """A number that moves inside one holder's scenario is a disagreement, not a second fact. Only a memory that replaces nothing can start a conflict: a supersede has already - picked a side, and recording it as unresolved would contradict that. + picked a side, and recording it as unresolved would contradict that. And only a memory that + leads with the same subject as its neighbour: two rooms' thermostats are two facts. """ if atom.supersedes: return - stated = trust.numeric_values(atom.text) - if not stated: + if not trust.numeric_values(atom.text).values: return for earlier in lifecycle_store.lane_atoms(self.db, atom.holder_id, atom.scenario): if earlier.atom_id == atom.atom_id: continue - term = trust.conflicting_number(trust.numeric_values(earlier.text), stated) - if term is None: + clash = trust.quantity_conflict(earlier.text, atom.text) + if clash is None: continue lifecycle_store.record_conflict( self.db, @@ -156,9 +156,9 @@ class MemoryService: newer_atom_id=atom.atom_id, holder_id=atom.holder_id, scenario=atom.scenario, - terms=[term], - older_value=trust.numeric_values(earlier.text)[term], - newer_value=stated[term], + terms=[clash.term], + older_value=clash.older_value, + newer_value=clash.newer_value, detected_at=utc_now(), ) @@ -360,7 +360,7 @@ class MemoryService: moved = 0 if dry_run else lifecycle_store.archive_atoms(self.db, candidates, moment) if not dry_run and moved != len(candidates): raise ValueError(f"{len(candidates)} memories were chosen for the archive but {moved} were moved") - floor = "any importance" if max_importance is None else f"importance at or under {max_importance}" + floor = "any importance" if max_importance is None else f"importance unset or at or under {max_importance}" return ArchiveReport( atom_ids=candidates, archived_at=moment, diff --git a/backend/src/ely_eye/retrieval.py b/backend/src/ely_eye/retrieval.py index 3fc968c..305d6e3 100644 --- a/backend/src/ely_eye/retrieval.py +++ b/backend/src/ely_eye/retrieval.py @@ -185,7 +185,10 @@ class RetrievalService: Both memories stay in the store and this only filters the page; the conflict row names which side was dropped and why. Settling is the holder's call, and until it happens the - newer statement is what a reader should be shown. + newer statement is what a reader should be shown. Only a pair seen whole is settled: the + page is ranked from a window wider than itself, so both sides normally arrive together, + and an answer that never recalled the newer memory leaves the older value standing — + stale, but not silently contradicted by nothing. """ losers = lifecycle_store.losing_sides(self.db, [hit.atom.atom_id for hit in hits]) if not losers: diff --git a/backend/src/ely_eye/trust.py b/backend/src/ely_eye/trust.py index 9c22eec..1dad3e6 100644 --- a/backend/src/ely_eye/trust.py +++ b/backend/src/ely_eye/trust.py @@ -10,6 +10,7 @@ from __future__ import annotations import re from collections.abc import Iterable +from dataclasses import dataclass from typing import TYPE_CHECKING from unicodedata import normalize @@ -195,7 +196,28 @@ def _spread(pairs: list[str]) -> list[str]: return [pairs[0], pairs[len(pairs) // 2], pairs[-1]] -def numeric_values(text: str) -> dict[str, str]: +@dataclass(frozen=True) +class StatedNumbers: + """What a memory leads with, and every number it states keyed by the label it hangs on. + + The subject is kept apart from the other labels because a shared label alone does not make two + memories disagree about one thing: see ``quantity_conflict``. + """ + + subject: str + values: dict[str, str] + + +@dataclass(frozen=True) +class QuantityConflict: + """One quantity two memories state differently, named by its label and both values.""" + + term: str + older_value: str + newer_value: str + + +def numeric_values(text: str) -> StatedNumbers: """Every number a memory states, keyed by what it measures, so a moving value can be named. A number is attributed twice: to the subject the memory leads with, and to the content word @@ -206,8 +228,14 @@ def numeric_values(text: str) -> dict[str, str]: try: tokens = fts_terms(text) except ValueError: - return {} - subject = _label(tokens[0], first=True) if tokens else "" + 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 values: dict[str, str] = {} beside = "" for token in tokens: @@ -220,11 +248,36 @@ def numeric_values(text: str) -> dict[str, str]: values.setdefault(subject, value) if beside: values.setdefault(beside, value) - return values + return StatedNumbers(subject=subject, values=values) + + +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. + + A shared predicate is not enough. 「客厅的恒温器调到 22 度」 and 「卧室的恒温器调到 24 度」 + share 「调到」 and disagree about nothing: they are two thermostats, each steady at its own + number. Requiring the same leading subject lets each keep its own reading and still catches one + thermostat moving. What that does not buy: two memories that lead with the same subject and + measure different things — 「恒温器设定 22 度」 and 「恒温器功率 100 瓦」 — still read as one + moving quantity, because the store compares labels, not meanings. + """ + older = numeric_values(older_text) + newer = numeric_values(newer_text) + if not older.subject or older.subject != newer.subject: + 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. + 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: + return None + return QuantityConflict(term=term, older_value=older.values[term], newer_value=newer.values[term]) def conflicting_number(older: dict[str, str], newer: dict[str, str]) -> str | None: - """The one quantity both memories state a different value for, or None if they do not disagree. + """The one label both sides state a different value for, or None if they do not disagree. Only a shared label counts: two memories that each mention a number are not in conflict until they name the same thing. The label is reported, not the verdict — which side is current is a @@ -244,9 +297,10 @@ def _as_float(value: str) -> float: def _label(token: str, *, first: bool = False) -> str: """The content word a number hangs on: a CJK run's two-character tail, or a plain Latin word. - The subject of a memory is read from the head of its first run instead, because two memories - about the same thing usually phrase the rest differently: 「恒温器放在书房,设定 22 度」 and - 「恒温器调到 24 度」 share only their subject, and that is the label which makes them disagree. + The subject of a memory is read from the head of its first content run instead, because two + memories about the same thing usually phrase the rest differently: 「恒温器放在书房,设定 22 度」 + and 「恒温器调到 24 度」 share only their subject, and that is the label which makes them + disagree. """ letters = "".join(char for char in token if is_cjk(char)) if letters: diff --git a/backend/tests/test_memory_conflicts.py b/backend/tests/test_memory_conflicts.py index 51c615d..069acb6 100644 --- a/backend/tests/test_memory_conflicts.py +++ b/backend/tests/test_memory_conflicts.py @@ -147,3 +147,77 @@ def test_a_conflict_belongs_to_the_memory_it_was_found_in(tmp_path) -> None: assert service.conflicts("wife") == [], ( "one side is gone, so there is nothing left to settle and no ghost row to list" ) + + +def _lane(tmp_path, texts: list[str], *, scenario: str = "home") -> tuple[MemoryService, Database]: + settings = Settings(home=tmp_path, default_holder="wife") + database = Database(settings) + database.init() + service = MemoryService(settings=settings, database=database) + for text in texts: + service.remember(text, holder_id="wife", origin=MemoryOrigin.self_, scenario=scenario) + return service, database + + +def _search(tmp_path, database: Database, query: str) -> list[str]: + retrieval = RetrievalService(settings=Settings(home=tmp_path, default_holder="wife"), database=database) + return [hit.atom.text for hit in retrieval.search(query, filters=SearchFilters(as_holder="wife")).hits] + + +def test_two_rooms_each_keep_their_own_number(tmp_path) -> None: + service, database = _lane(tmp_path, ["客厅的恒温器调到 22 度", "卧室的恒温器调到 24 度"]) + + assert service.conflicts("wife") == [], "they share the verb 调到 and disagree about nothing" + assert _search(tmp_path, database, "客厅") == ["客厅的恒温器调到 22 度"], ( + "asking about the living room must not answer with the bedroom's reading" + ) + + +def test_two_people_each_keep_their_own_count(tmp_path) -> None: + service, _ = _lane(tmp_path, ["她买了 3 本书", "他买了 5 本书"]) + + assert service.conflicts("wife") == [], "two buyers are two facts, not one moving count" + + +def test_one_router_moving_its_counter_is_a_conflict(tmp_path) -> None: + service, _ = _lane(tmp_path, ["The router forwards 220 packets", "The router forwards 240 packets"]) + + clashes = service.conflicts("wife") + + assert len(clashes) == 1 + assert clashes[0].terms == ["router"], "the subject both memories lead with is what the pair disagrees about" + assert (clashes[0].older_value, clashes[0].newer_value) == ("220", "240") + + +def test_the_same_subject_measuring_two_things_still_reads_as_one_quantity(tmp_path) -> None: + service, _ = _lane(tmp_path, ["恒温器设定 22 度", "恒温器功率 100 瓦"]) + + assert len(service.conflicts("wife")) == 1, ( + "the store compares labels, not meanings: 设定 and 功率 both hang off 恒温, so this pair is " + "recorded as one moving quantity and the holder is the one who settles it" + ) + + +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" + + +def test_a_qualifier_on_the_subject_reads_as_a_different_thing(tmp_path) -> None: + service, _ = _lane(tmp_path, ["恒温器调到 22 度", "客厅的恒温器调到 24 度"]) + + assert service.conflicts("wife") == [], ( + "the store compares labels, not meanings: adding 客厅 moves the subject, so the update is " + "kept as a second fact rather than noticed as a disagreement — nothing is hidden, and " + "supersede is still how the holder says which one stands" + ) + + +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"]) + + 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" + ) diff --git a/backend/tests/test_memory_tiers.py b/backend/tests/test_memory_tiers.py index 38a5c7f..ddd116a 100644 --- a/backend/tests/test_memory_tiers.py +++ b/backend/tests/test_memory_tiers.py @@ -204,3 +204,19 @@ def test_importance_is_a_stated_value_and_not_a_free_number(tmp_path) -> None: holder_id="wife", importance=1.5, ) + + +def test_a_restore_counts_the_memories_it_brought_back_and_only_those(tmp_path) -> None: + service, database = _service(tmp_path) + settings = Settings(home=tmp_path, default_holder="wife") + retrieval = RetrievalService(settings=settings, database=database) + archived = _remember(service, "我们的钥匙放在玄关抽屉里") + standing = _remember(service, "恒温器设定 22 度", importance=0.9) + + service.archive_stale(older_than_days=0, at=NOW + timedelta(days=365), max_importance=0.5) + + assert service.restore([archived]) == 1 + assert [hit.atom.atom_id for hit in retrieval.search("钥匙", filters=SearchFilters(as_holder="wife")).hits] == [archived] + assert service.restore([standing, archived]) == 0, ( + "a memory that was never archived, or came back already, changed nothing and says so" + )