Files
ely-eye/backend/tests/test_memory_conflicts.py
T
ZacharyZhang-NY 11e31eb7e3 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.
2026-10-09 11:44:29 -04:00

321 lines
14 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_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") == [], (
"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"
)
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_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 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"
)
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"
)