The smoke script's build fingerprint asserted on the MCP reply, but the Go
layer decodes the HTTP answer into its own struct and re-emits a missing field
as zero, so a server from before the memory tiers still passed. Point the
fingerprint at the Python answer instead: /api/memory/working answers 404 on
4c6af87 and 200 here, both measured by starting each build myself.
Record the miss side of the rename rule. Whether a bare number renames its
subject depends on that memory's own sentence, so 「温度」 and 「温度24」 never
compare and an update goes unnoticed; both memories stay on the answer page,
so the miss costs a notice rather than a wrong number. Pin it with a test and
keep the mechanism, since comparing an unrenamed subject as a fallback would
put room numbers back in the ring as readings.
Correct the direction of the rule in the record: the rename slot is the token
after the subject word, not before it. A leading number becomes the subject
rather than fusing forward, which 「3 room holds 22 degrees」 shows by taking
「3」 as its subject.
门禁:pytest 384 passed;六文件 83(conflicts 27);三文件 35;ruff clean;
filesize clean;audit 32/2/6 clean;mypy 冷缓存 24 errors / 9 files(净 0);
pyright 0;go 全套 + gofmt 空;npm 产物未变;smoke PASS。
334 lines
15 KiB
Python
334 lines
15 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"
|
|
)
|
|
|
|
|
|
def test_a_second_number_in_one_memory_hides_the_update(tmp_path) -> None:
|
|
service, database = _lane(tmp_path, ["温度 22 度", "温度 24 度 湿度 40"])
|
|
|
|
assert service.conflicts("wife") == [], (
|
|
"whether a bare number renames its subject depends on that memory's own sentence, so 「温度」 "
|
|
"and 「温度24」 never compare — the temperature moved and the store did not notice"
|
|
)
|
|
assert sorted(_search(tmp_path, database, "温度")) == ["温度 22 度", "温度 24 度 湿度 40"], (
|
|
"the fail-closed side of the boundary: nothing is suppressed, the reader sees both readings "
|
|
"and decides for themselves"
|
|
)
|