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:
1 parent
deabbf76b8
commit
3daface332
7 files changed
+185
-26
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
Reference in new issue
Block a user