Name the subject a conflict is actually about

A shared verb is not a shared fact. 「客厅的恒温器调到 22 度」 and
「卧室的恒温器调到 24 度」 agree with each other; the store had been
recording them as one thermostat moving, and the older side then left the
answer page — asking about the living room returned the bedroom's reading
as the only evidence. A conflict now requires the two memories to lead
with the same subject, and the subject is the first content word rather
than the first word, so 「The router forwards 220 packets」 still leads
with its router behind the article.

The rule trades one error for another, and both directions are measured
and pinned: a qualifier on the subject reads as a different thing, and
the first number under a label is the value that gets compared. Neither
hides a memory — each leaves a disagreement unrecorded for a person to
supersede by hand.

Six review findings, all measured before being changed:
- the archive rule string hid that an unset importance archives too
- a never-archived id reported {"restored": 1} while nothing moved
- the suppression condition (both sides in the page) was undocumented
- a determinism claim had no reproducible path, two latency numbers were
  never committed, and one cited test name did not exist

Gates: 374 passed, six acceptance files 73 passed, ruff clean, mypy cold
cache 24 errors in 9 files (the recorded 9cb46c1 baseline, net 0),
pyright 0 on the touched files, go build/vet/test + gofmt clean, npm
bundle unchanged, product audit clean, MCP smoke passing across processes.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 09:49:25 -04:00
1 parent deabbf76b8
commit 3daface332
7 files changed
+185 -26

No files matched your search

+7 -6
View File
@@ -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 <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`(12 + 14 + 8)+ `bash scripts/mcp_write_smoke.sh` 里跨进程那一段(session 名不合并两个 holder;陌生 session 返回空而不是别人的记忆)。提交 `dc9cfb5`。
- [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 自己观察的。验收:`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
+13 -2
View File
@@ -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)
+9 -9
View File
@@ -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,
+4 -1
View File
@@ -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:
+62 -8
View File
@@ -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:
+74
View File
@@ -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"
)
+16
View File
@@ -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"
)