Files
ely-eye/backend/tests/test_memory_conflicts.py
T
ZacharyZhang-NY 3daface332 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.
2026-10-09 09:49:25 -04:00

224 lines
9.6 KiB
Python

"""Two memories cannot both be true: the store notices, answers with the newer side, and waits.
Settling a disagreement is the holder's act. Until then the store chooses, but it records that
it chose, and it keeps both sides so the choice can be undone.
"""
from __future__ import annotations
from datetime import UTC, datetime
from ely_eye.config import Settings
from ely_eye.db import Database
from ely_eye.memory import MemoryService
from ely_eye.retrieval import RetrievalService
from ely_eye.schemas import MemoryOrigin, SearchFilters
NOW = datetime(2026, 5, 1, 12, 0, tzinfo=UTC)
OLD = "恒温器放在书房,设定 22 度"
NEW = "恒温器调到 24 度"
def _pair(tmp_path, *, scenario: str = "home", newer_holder: str = "wife") -> tuple[MemoryService, Database, list[str]]:
settings = Settings(home=tmp_path, default_holder="wife")
database = Database(settings)
database.init()
service = MemoryService(settings=settings, database=database)
older = service.remember(OLD, holder_id="wife", origin=MemoryOrigin.self_, scenario=scenario).atom_id
newer = service.remember(NEW, holder_id=newer_holder, origin=MemoryOrigin.self_, scenario=scenario).atom_id
return service, database, [older, newer]
def test_a_number_that_changed_leaves_a_conflict_naming_both_sides(tmp_path) -> None:
service, _, ids = _pair(tmp_path)
clashes = service.conflicts("wife")
assert len(clashes) == 1
record = clashes[0]
assert (record.older_atom_id, record.newer_atom_id) == (ids[0], ids[1])
assert record.older_value == "22" and record.newer_value == "24"
assert record.terms, "the record names what the two memories disagree about"
assert record.resolved_at is None
def test_repeating_the_same_statement_adds_no_memory_and_no_conflict(tmp_path) -> None:
service, database, _ = _pair(tmp_path)
before = len(service.conflicts("wife"))
service.remember(NEW, holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
assert database.atom_count() == 2, "the same holder stating the same sentence holds one memory"
assert len(service.conflicts("wife")) == before, (
"a repeat cannot manufacture a second disagreement"
)
def test_a_memory_that_agrees_leaves_nothing_to_settle(tmp_path) -> None:
settings = Settings(home=tmp_path, default_holder="wife")
database = Database(settings)
database.init()
service = MemoryService(settings=settings, database=database)
service.remember(OLD, holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
service.remember("恒温器设定 22 度", holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
assert service.conflicts("wife") == [], (
"restating the same reading in other words is agreement, and the rule must not flinch at it"
)
def test_two_holders_who_disagree_are_not_a_conflict_in_one_memory(tmp_path) -> None:
service, _, _ = _pair(tmp_path, newer_holder="husband")
assert service.conflicts("wife") == [], "the husband changing his own mind is not the wife's contradiction"
def test_a_change_in_another_scenario_is_a_different_question(tmp_path) -> None:
settings = Settings(home=tmp_path, default_holder="wife")
database = Database(settings)
database.init()
service = MemoryService(settings=settings, database=database)
service.remember(OLD, holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
service.remember(NEW, holder_id="wife", origin=MemoryOrigin.self_, scenario="office")
assert service.conflicts("wife") == [], (
"the office thermostat and the home thermostat are two thermostats"
)
def test_a_supersede_already_chose_a_side_and_records_no_conflict(tmp_path) -> None:
service, _, ids = _pair(tmp_path, scenario="")
before = len(service.conflicts("wife"))
service.supersede(ids[0], text="恒温器调到 26 度", holder_id="wife")
assert len(service.conflicts("wife")) == before, (
"a replacement is a decision, not a disagreement waiting to be settled"
)
def test_a_disagreement_about_words_rather_than_numbers_is_left_alone(tmp_path) -> None:
settings = Settings(home=tmp_path, default_holder="wife")
database = Database(settings)
database.init()
service = MemoryService(settings=settings, database=database)
service.remember("钥匙在玄关抽屉里", holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
service.remember("钥匙在车里", holder_id="wife", origin=MemoryOrigin.self_, scenario="home")
assert service.conflicts("wife") == [], (
"the numeric rule is the one that can be checked; a reworded sentence would fire on every paraphrase"
)
def test_an_open_conflict_makes_the_store_answer_with_the_newer_side(tmp_path) -> None:
_, database, ids = _pair(tmp_path)
retrieval = RetrievalService(settings=Settings(home=tmp_path, default_holder="wife"), database=database)
hits = retrieval.search("恒温器", filters=SearchFilters(as_holder="wife")).hits
assert [hit.atom.atom_id for hit in hits] == [ids[1]], (
"both memories match the words; only the disagreement decides which one answers"
)
def test_settling_a_conflict_gives_both_sides_back(tmp_path) -> None:
service, database, ids = _pair(tmp_path)
retrieval = RetrievalService(settings=Settings(home=tmp_path, default_holder="wife"), database=database)
clash = service.conflicts("wife")[0]
assert service.resolve_conflicts([clash.conflict_id], at=NOW) == 1
assert service.conflicts("wife") == []
hits = retrieval.search("恒温器", filters=SearchFilters(as_holder="wife")).hits
assert {hit.atom.atom_id for hit in hits} == set(ids), (
"a settled disagreement stops the store from choosing; both memories are current again"
)
assert database.get_atoms(ids)[0].valid_to is None, "settling is not the same as superseding"
def test_a_conflict_belongs_to_the_memory_it_was_found_in(tmp_path) -> None:
service, _, ids = _pair(tmp_path)
assert service.conflicts("wife"), "the disagreement exists before the forget"
forgotten = service.forget(atom_id=ids[1], reason="the reading was wrong")
assert forgotten.atoms_deleted == 1
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"
)