From 1b8d15f96e110722623ace91e6de87e8e087b427 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sun, 18 Oct 2026 06:59:19 +0530 Subject: [PATCH 1/3] fix(memory): deleting a memory leaves a similar memory's derived data alone The cascading delete found MEMORY.md/USER.md lines, journal lines, summaries and insight evidence by loose word overlap, so deleting one of two similar memories also stripped the other's. Fact evidence now goes by fact id only, and a text line stays when a remaining current memory says it as well or better, or names a remaining memory's id. The same guard applies when a memory becomes disputed or superseded. Closes #456 --- changes/456.fixed.md | 1 + docs/API.md | 6 +- sentient/app.py | 8 +- sentient/memory/facts.py | 36 ++++--- sentient/memory/origins.py | 60 +++++++++-- sentient/memory/workspace.py | 16 +-- tests/memory/test_erase_siblings.py | 149 ++++++++++++++++++++++++++++ 7 files changed, 243 insertions(+), 33 deletions(-) create mode 100644 changes/456.fixed.md create mode 100644 tests/memory/test_erase_siblings.py diff --git a/changes/456.fixed.md b/changes/456.fixed.md new file mode 100644 index 00000000..0bf024d9 --- /dev/null +++ b/changes/456.fixed.md @@ -0,0 +1 @@ +**Deleting a memory no longer removes what Sentient knows from a similar one.** Before, deleting "drives a red Honda City" could also take the MEMORY.md line, journal lines and user model evidence of "sold her red Honda City and now rides a blue Ather". Now only the deleted memory's own lines and evidence go. diff --git a/docs/API.md b/docs/API.md index 937145da..f1de4dbc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1773,7 +1773,11 @@ attribute and no word of what it says in common ("a piano recital in July" for " earlier wording, chat summaries made of the message it came from that repeat it (with their vectors), the nightly journal's lines about it, the user model's evidence from it (an inferred insight with nothing else behind it goes), the memory chips on replies and task runs that had it in mind, its USER.md bullet and the MEMORY.md lines that repeat - it. The chats themselves stay. The tidy-up's own merges and contradictions don't count as deletes. + it. The chats themselves stay. The tidy-up's own merges and contradictions don't count as deletes. The user model's + evidence from a memory is found by the memory's id only. Text found by its words (summaries, journal lines, evidence + quotes from chats, USER.md and MEMORY.md lines) stays when a memory still in use says it as well or better, or names + that memory's id, so deleting one of two similar memories leaves the other's lines alone (#456). The same holds when + a memory becomes disputed or superseded and leaves USER.md and MEMORY.md. - Migration 0015 gives memories saved before #153 from a messaging app or a device chat their real source (found from the review note of a held memory, the `memory_remember` call that saved it, or the one message in such a chat, up to 30 minutes before it, that says it), so forgetting that app covers them too. diff --git a/sentient/app.py b/sentient/app.py index e2b83971..ac5fe7ba 100644 --- a/sentient/app.py +++ b/sentient/app.py @@ -319,11 +319,11 @@ async def delete_chat(self, session_id: str, *, reason: str | None = None) -> No log.exception("removing the selected text of chat %s failed", session_id) self.bus.publish("session.deleted", {"session_id": session_id, **({"reason": reason} if reason else {})}) - def _after_memory_erased(self, texts: list[str]) -> None: - """A memory was deleted (#152): its words leave USER.md and MEMORY.md, and the user model reads its insights - again (some may be gone with it).""" + def _after_memory_erased(self, erased: Any) -> None: + """A memory was deleted (#152): its words leave USER.md and MEMORY.md (not a remaining memory's similar lines, + #456), and the user model reads its insights again (some may be gone with it).""" try: - self.workspace.scrub(texts, self.config.assistant.user_name) + self.workspace.scrub(erased.wordings, self.config.assistant.user_name, repeats=erased) except OSError: log.exception("removing a deleted memory from the workspace files failed") model = getattr(self, "user_model", None) diff --git a/sentient/memory/facts.py b/sentient/memory/facts.py index eb419e3f..547ac4c7 100644 --- a/sentient/memory/facts.py +++ b/sentient/memory/facts.py @@ -667,7 +667,7 @@ def __init__(self, store: Store, llm: LLMProvider, config: SentientConfig, bus: self.episodic = EpisodicMemory(store, llm, config) self._qcache: OrderedDict[tuple[str, str], list[float]] = OrderedDict() # set by the app: also scrub the words of erased memories from the workspace files and the user model's - # caches (#152); called with the texts that went + # caches (#152); called with an ``origins.Erased``: the texts that went, and which lines repeat them (#456) self.on_erase: Any = None async def ensure_schema(self) -> None: @@ -921,7 +921,9 @@ async def erase(self, fid: int, *, notify: bool = False) -> bool: merged copies and source links (:meth:`forget`), the chat summaries made of the messages it came from that repeat it, the nightly journal's lines about it, the user model's evidence from it (an inferred insight with nothing else behind it goes), the memory chips on replies and task runs that had it in mind, and (through - ``on_erase``) the lines of USER.md and MEMORY.md that say it. Its own previous wording goes with it.""" + ``on_erase``) the lines of USER.md and MEMORY.md that say it. Its own previous wording goes with it. Text found + by its words is left alone when a memory still in use says it as well or better (#456): deleting one of two + similar memories never strips the other's lines or evidence.""" await self.ensure_schema() row = await self.store.fetchone( "SELECT id, content, previous_content, source_ref FROM facts WHERE id = ?", (fid,) @@ -948,11 +950,17 @@ async def erase(self, fid: int, *, notify: bool = False) -> bool: log.exception("cleaning up after memory %s failed", fid) return True - async def _cascade(self, ids: list[int], texts: list[str], refs: list[dict]) -> None: - store, user = self.store, self.config.assistant.user_name + async def _erased(self, texts: list[str], ids: list[int]) -> origins.Erased: + """What ``texts`` said, against every memory still in use (#456): what derived data repeats them goes, + unless a remaining memory says it as well or better (:class:`origins.Erased`).""" + rows = await self.store.fetchall(f"SELECT id, content FROM facts WHERE {IN_USE}") + gone = set(ids) + remaining = [(int(r["id"]), str(r["content"])) for r in rows if int(r["id"]) not in gone] + return origins.Erased(texts, remaining, self.config.assistant.user_name) - def repeats(text: str) -> bool: - return any(origins.says(t, text or "", user) for t in texts) + async def _cascade(self, ids: list[int], texts: list[str], refs: list[dict]) -> None: + store = self.store + repeats = await self._erased(texts, ids) # chat summaries made of the messages it came from, that repeat it, and their vectors said_in = {str(r["message_id"]) for r in refs if r.get("message_id")} @@ -972,7 +980,7 @@ def repeats(text: str) -> bool: for line in lines] if kept != lines: await store.execute("UPDATE dreams SET journal_md = ? WHERE id = ?", ("\n".join(kept), r["id"])) - # the user model's evidence from it + # the user model's evidence from it: a memory's by its id only, a quote of a chat or summary when it says it wanted = {str(i) for i in ids} try: insights = await store.fetchall("SELECT rid, id, source, evidence FROM user_insights") @@ -986,7 +994,7 @@ def repeats(text: str) -> bool: if not isinstance(evidence, list): continue kept = [e for e in evidence if not (isinstance(e, dict) and ( - (e.get("kind") == "fact" and str(e.get("ref")) in wanted) or (e.get("quote") and repeats(e["quote"])) + str(e.get("ref")) in wanted if e.get("kind") == "fact" else bool(e.get("quote")) and repeats(e["quote"]) ))] if len(kept) == len(evidence): continue @@ -1017,17 +1025,17 @@ def repeats(text: str) -> bool: f"UPDATE {table} SET memory_sources = ? WHERE rowid = ?", (json.dumps(kept), r["rowid"]) ) if self.on_erase is not None: - res = self.on_erase(texts) + res = self.on_erase(repeats) if hasattr(res, "__await__"): await res - async def _out_of_prompts(self, texts: list[str]) -> None: + async def _out_of_prompts(self, texts: list[str], ids: list[int]) -> None: """A memory left the prompts (disputed or superseded): its lines leave USER.md and MEMORY.md too, which go - into every prompt (``on_erase``).""" + into every prompt (``on_erase``), unless a memory still in use says them as well (#456).""" if self.on_erase is None: return try: - res = self.on_erase(texts) + res = self.on_erase(await self._erased(texts, ids)) if hasattr(res, "__await__"): await res except Exception: @@ -1055,7 +1063,7 @@ async def set_state(self, fid: int, state: str) -> dict: await self.store.execute( "UPDATE facts SET state = 'disputed', updated_at = ? WHERE id = ?", (now_iso(), fid) ) - await self._out_of_prompts([row["content"]]) + await self._out_of_prompts([row["content"]], [fid]) if row["state"] != state: self.publish("UPDATE", fid, row["content"], state=state, reason="confirmed" if state == "current" else "disputed") fact = await self.get_fact(fid) @@ -2104,7 +2112,7 @@ async def supersede(self, keep_id: int, drop_id: int) -> bool: (keep_id, ts, drop_id), ) await self.vec.delete(drop_id) - await self._out_of_prompts([drop["content"]]) + await self._out_of_prompts([drop["content"]], [drop_id]) self.publish("DELETE", drop_id, drop["content"], reason="contradicted", superseded_by=keep_id) return True diff --git a/sentient/memory/origins.py b/sentient/memory/origins.py index dbb02e7c..84782f8e 100644 --- a/sentient/memory/origins.py +++ b/sentient/memory/origins.py @@ -183,14 +183,62 @@ def quote(text: str, fact: str) -> str: return best if len(best) <= QUOTE_CHARS else best[: QUOTE_CHARS - 1].rstrip() + "…" +def _share(want: set[str], have: set[str]) -> float: + if len(want) < 2: + return 0.0 + shared = len(want & have) + return shared / len(want) if shared >= max(2, -(-len(want) * 3 // 5)) else 0.0 + + +def match(fact: str, text: str, user_name: str = "") -> float: + """How fully ``text`` repeats ``fact``: the share of the fact's words it has (besides the user's own name) when + it :func:`says` the fact, else 0.""" + return _share(_words(fact) - _words(user_name), _words(text)) + + def says(fact: str, text: str, user_name: str = "") -> bool: """Whether ``text`` repeats ``fact``: most of its words (three in five, at least two), besides the user's own - name. Stricter than :func:`backs`: it decides what is scrubbed when a memory is deleted.""" - want = _words(fact) - _words(user_name) - if len(want) < 2: - return False - have = _words(text) - return len(want & have) >= max(2, -(-len(want) * 3 // 5)) + name. Stricter than :func:`backs`: it decides what is scrubbed when a memory is deleted (with :class:`Erased`).""" + return match(fact, text, user_name) > 0 + + +_QUOTED = re.compile(r'"([^"\n]+)"|“([^”\n]+)”') +_NAMES_ID = re.compile(r"\b(?:id|memory|fact)\s*[:#]?\s*(\d+)\b", re.IGNORECASE) + + +class Erased: + """What deleted memories said, weighed against the memories that remain (#456). + + Called with a line of derived text (a MEMORY.md bullet, a journal line, a summary, an evidence quote), it says + whether the line repeats a deleted wording (:func:`says`) and so goes. A line stays when a remaining current + memory says it as well or better, or when it names a remaining memory's id: then it is that memory's line, not + the deleted one's. Each quoted part of a line is weighed on its own as well, so a journal line like + ``I used to remember "A", but now "B"`` goes when A (or B) is deleted, even though B (or A) remains.""" + + def __init__(self, texts: list[str], remaining: list[tuple[int, str]], user_name: str = "") -> None: + self.user = _words(user_name) + self.texts = [t for t in dict.fromkeys(texts) if t] + self._gone = [_words(t) - self.user for t in self.texts] + self._left = [_words(c) - self.user for _, c in remaining] + self._ids = {str(i) for i, _ in remaining} + self._held = {" ".join(str(c).lower().split()) for _, c in remaining} + + @property + def wordings(self) -> list[str]: + """The deleted wordings no remaining memory says word for word (an exact USER.md line of one of those is + the remaining memory's line too).""" + return [t for t in self.texts if " ".join(t.lower().split()) not in self._held] + + def _repeats(self, part: str) -> bool: + have = _words(part) + best = max((_share(w, have) for w in self._gone), default=0.0) + return best > 0 and not any(_share(w, have) >= best for w in self._left) + + def __call__(self, text: str) -> bool: + text = str(text or "") + if any(m.group(1) in self._ids for m in _NAMES_ID.finditer(text)): + return False + return any(self._repeats(p) for p in (text, *(a or b for a, b in _QUOTED.findall(text)))) def confidence(source: str, *, held: bool = False) -> float: diff --git a/sentient/memory/workspace.py b/sentient/memory/workspace.py index a4a440d5..658fe095 100644 --- a/sentient/memory/workspace.py +++ b/sentient/memory/workspace.py @@ -12,6 +12,7 @@ from __future__ import annotations +from collections.abc import Callable from datetime import date, timedelta from pathlib import Path @@ -171,20 +172,19 @@ def remove_learned(self, facts: list[str], heading: str = "## Learned") -> int: self.user.write_text("\n".join(out) + "\n", encoding="utf-8") return removed - def scrub(self, facts: list[str], user_name: str = "") -> int: + def scrub(self, facts: list[str], user_name: str = "", repeats: Callable[[str], bool] | None = None) -> int: """A memory was deleted (#152): its bullet goes from USER.md's Learned section, and every bullet of MEMORY.md - (written from memories by the nightly upkeep) that repeats it (``origins.says``). The user's own text in - USER.md is never touched. Returns how many lines went.""" + (written from memories by the nightly upkeep) that repeats it: ``repeats`` (an ``origins.Erased``, which spares + a remaining memory's similar lines, #456), else ``origins.says``. The user's own text in USER.md is never + touched. Returns how many lines went.""" from sentient.memory import origins removed = self.remove_learned(facts) - if not facts or not self.memory.exists(): + if not self.memory.exists() or not (facts or repeats is not None): return removed + said = repeats or (lambda line: any(origins.says(f, line, user_name) for f in facts)) lines = self.memory.read_text(encoding="utf-8", errors="replace").splitlines() - kept = [ - line for line in lines - if not (line.lstrip().startswith(("- ", "* ")) and any(origins.says(f, line, user_name) for f in facts)) - ] + kept = [line for line in lines if not (line.lstrip().startswith(("- ", "* ")) and said(line))] if len(kept) != len(lines): self.memory.write_text("\n".join(kept) + "\n", encoding="utf-8") return removed + len(lines) - len(kept) diff --git a/tests/memory/test_erase_siblings.py b/tests/memory/test_erase_siblings.py new file mode 100644 index 00000000..d852a056 --- /dev/null +++ b/tests/memory/test_erase_siblings.py @@ -0,0 +1,149 @@ +"""Deleting one of two similar memories leaves the other's derived data alone (#456). + +The cascading delete (#152, #436) finds MEMORY.md and USER.md lines, journal lines, summaries and evidence quotes by +their words. A line that a remaining current memory says as well or better (or that names a remaining memory's id) is +that memory's line and stays; a memory's evidence in the user model goes by its id only. +""" + +from __future__ import annotations + +import json + +from sentient.memory import origins +from sentient.store.db import now_iso + +PUNE = "Maya lives in Pune" +MOVED = "Maya moved from Pune to Bengaluru" +HONDA = "Maya drives a red Honda City" +ATHER = "Maya sold her red Honda City and now rides a blue Ather" + + +async def _ids(app, sql: str, params: tuple = ()) -> list: + return [r[0] for r in await app.store.fetchall(sql, params)] + + +async def _fact(app, content: str) -> int: + """A current memory with exactly this wording. The save path would rightly replace one of a similar pair (the + newer residence wins), so it is saved under a stand-in wording and then given its own: the pair a user has after + keeping both, or after a replaced memory's history (#453) is deleted.""" + res = await app.memory.remember(f"Stand-in note number {len(content)}", source="manual", use_llm=False) + assert res["action"] == "ADD", res + await app.store.execute("UPDATE facts SET content = ? WHERE id = ?", (content, res["id"])) + return int(res["id"]) + + +def test_a_line_a_remaining_memory_says_as_well_stays(): + gone = origins.Erased([HONDA], [(7, ATHER)], "Maya") + assert gone("- Maya drives a red Honda City") + assert gone("- Maya's car: a red Honda City") + assert not gone("- Maya sold her red Honda City and now rides a blue Ather") # the remaining one's line + assert not gone("- Maya's car (id 7) was a red Honda City") # names the remaining memory + assert gone("- Maya's car (id 8) is a red Honda City") # names a memory that isn't there + assert not gone("- Maya plays the veena") + # a tie goes to the remaining memory + pune = origins.Erased([PUNE], [(3, MOVED)], "Maya") + assert not pune("- Maya moved from Pune to Bengaluru and lives there now") + assert pune("- Maya lives in Pune") + # a quoted part is weighed on its own: a journal line about a change goes when either side is deleted + line = f'- I used to remember "{PUNE}", but now "{MOVED}"' + assert pune(line) and origins.Erased([MOVED], [(1, PUNE)], "Maya")(line) + assert not pune(f'- "{MOVED}" keeps coming up, so I will keep it.') + # with nothing left, it is the plain word match it always was + assert origins.Erased([HONDA], [], "Maya")("- Maya sold her red Honda City and now rides a blue Ather") + # an exact USER.md line a remaining memory says word for word is that memory's too + assert origins.Erased([HONDA, PUNE], [(2, "maya drives a red honda city")], "Maya").wordings == [PUNE] + + +async def test_deleting_one_of_two_similar_memories_keeps_the_others_derived_data(app): + mem, store, ws, users = app.memory, app.store, app.workspace, app.user_model + tg = await store.create_session(channel="telegram") + said = await store.add_message(tg, "user", "I drive a red Honda City to work") + pune, moved = await _fact(app, PUNE), await _fact(app, MOVED) + first = await mem.remember(HONDA, use_llm=False, session_id=tg) # the first car memory: saved as said + honda = int(first["id"]) + assert (await mem.get_fact(honda))["ref"]["message_id"] == said + later = await store.add_message(tg, "user", "Update: I sold my red Honda City and now ride a blue Ather") + ather = await _fact(app, ATHER) + assert {r["state"] for r in await store.fetchall("SELECT state FROM facts")} == {"current"} + + ws.append_learned([PUNE, MOVED, HONDA, ATHER]) + ws.memory.write_text( + "# Memory\n" + f"- {PUNE}\n" + "- Maya moved from Pune to Bengaluru and lives there now\n" + f"- {HONDA}\n" + f"- {ATHER}\n" + f"- Maya's first car (memory {ather}) was a red Honda City\n", + encoding="utf-8", + ) + msgs = await store.recent_messages(tg, 10) + gone_summary = await mem.episodic.add_summary(tg, "Maya drives a red Honda City to the office.", msgs) + kept_summary = await mem.episodic.add_summary(tg, "Maya sold her red Honda City and now rides a blue Ather.", msgs) + await store.execute( + "INSERT INTO dreams(id, started_at, status, trigger, stats, journal_md) VALUES('d1', ?, 'completed', 'manual'," + " '{}', ?)", + (now_iso(), f'Tonight I tidied up.\n- I used to remember "{PUNE}", but now "{MOVED}".\n' + f'- "{ATHER}" keeps coming up, so I will keep it.\n- "{HONDA}" keeps coming up, so I will keep it.'), + ) + + def fact_ev(fid: int, quote: str) -> dict: + return {"kind": "fact", "ref": str(fid), "quote": quote} + + sibling_only = await users._insert( + "Maya switched to an electric scooter", "lifestyle", confidence=0.6, status="active", source="inferred", + evidence=[fact_ev(ather, ATHER)], + ) + mixed = await users._insert( + "Maya gets around on two or four wheels", "lifestyle", confidence=0.6, status="active", source="inferred", + evidence=[fact_ev(honda, HONDA), fact_ev(ather, ATHER), fact_ev(moved, MOVED), fact_ev(pune, PUNE)], + ) + deleted_only = await users._insert( + "Maya commutes by car", "lifestyle", confidence=0.6, status="active", source="inferred", + evidence=[fact_ev(honda, HONDA)], + ) + chats = await users._insert( + "Maya talks about her vehicles", "lifestyle", confidence=0.6, status="active", source="inferred", + evidence=[{"kind": "message", "ref": said, "quote": "I drive a red Honda City to work"}, + {"kind": "message", "ref": later, "quote": "Update: I sold my red Honda City and now ride a blue Ather"}], + ) + + assert await mem.erase(honda, notify=True) + assert await mem.erase(pune, notify=True) + + # the deleted memories' own lines are gone, the remaining ones' stay + memory_md = ws.memory.read_text(encoding="utf-8") + assert f"- {PUNE}\n" not in memory_md and f"- {HONDA}\n" not in memory_md + assert "Bengaluru and lives there now" in memory_md and ATHER in memory_md + assert f"(memory {ather})" in memory_md + user_md = ws.user.read_text(encoding="utf-8") + assert f"- {PUNE}\n" not in user_md and f"- {HONDA}\n" not in user_md + assert MOVED in user_md and ATHER in user_md + journal = (await store.fetchone("SELECT journal_md FROM dreams WHERE id = 'd1'"))["journal_md"] + assert f'"{PUNE}"' not in journal and f'"{HONDA}"' not in journal and f'"{ATHER}" keeps coming up' in journal + assert journal.count("(a memory you deleted)") == 2 + summaries = await _ids(app, "SELECT id FROM summaries") + assert gone_summary["id"] not in summaries and kept_summary["id"] in summaries + + insights = {r["id"]: json.loads(r["evidence"]) for r in await store.fetchall("SELECT id, evidence FROM user_insights")} + assert [e["ref"] for e in insights[sibling_only["id"]]] == [str(ather)] + assert [e["ref"] for e in insights[mixed["id"]]] == [str(ather), str(moved)] + assert deleted_only["id"] not in insights + assert [e["ref"] for e in insights[chats["id"]]] == [later] + for fid in (moved, ather): + assert (await mem.get_fact(fid))["state"] == "current" + + +async def test_a_disputed_memory_leaves_a_similar_memorys_lines(app): + mem, ws = app.memory, app.workspace + honda, ather = await _fact(app, HONDA), await _fact(app, ATHER) + ws.append_learned([HONDA, ATHER]) + ws.memory.write_text(f"# Memory\n- {HONDA}\n- {ATHER}\n", encoding="utf-8") + await mem.set_state(honda, "disputed") + for f in (ws.memory, ws.user): + text = f.read_text(encoding="utf-8") + assert f"- {HONDA}\n" not in text and ATHER in text + # superseding the other way round takes the replaced one's lines, not the newer one's + await mem.set_state(honda, "current") + ws.memory.write_text(f"# Memory\n- {HONDA}\n- {ATHER}\n", encoding="utf-8") + await mem.supersede(honda, ather) + assert ws.memory.read_text(encoding="utf-8") == f"# Memory\n- {HONDA}\n" From 991eaabc851b169e248d9ac7a62eed873ac2d719 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sun, 18 Oct 2026 07:22:51 +0530 Subject: [PATCH 2/3] fix(memory): only a written memory reference spares a line on delete A line is kept for naming a remaining memory only when it writes a reference like "(memory 7)", and never for a number the deleted words themselves say ("phone has memory 7 GB"). --- sentient/memory/origins.py | 9 ++++++--- tests/memory/test_erase_siblings.py | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/sentient/memory/origins.py b/sentient/memory/origins.py index 84782f8e..5b1a8ddd 100644 --- a/sentient/memory/origins.py +++ b/sentient/memory/origins.py @@ -203,7 +203,9 @@ def says(fact: str, text: str, user_name: str = "") -> bool: _QUOTED = re.compile(r'"([^"\n]+)"|“([^”\n]+)”') -_NAMES_ID = re.compile(r"\b(?:id|memory|fact)\s*[:#]?\s*(\d+)\b", re.IGNORECASE) +# a reference to a memory by its id, as derived text writes one: "(memory 7)", "(id: 7)", "[fact #7]" +_NAMES_ID = re.compile(r"[(\[]\s*(?:id|memory|fact)\s*[:#]?\s*(\d+)\s*[)\]]", re.IGNORECASE) +_NUMBER = re.compile(r"\d+") class Erased: @@ -211,7 +213,7 @@ class Erased: Called with a line of derived text (a MEMORY.md bullet, a journal line, a summary, an evidence quote), it says whether the line repeats a deleted wording (:func:`says`) and so goes. A line stays when a remaining current - memory says it as well or better, or when it names a remaining memory's id: then it is that memory's line, not + memory says it as well or better, or when it names a remaining memory's id ("(memory 7)"): then it is that memory's line, not the deleted one's. Each quoted part of a line is weighed on its own as well, so a journal line like ``I used to remember "A", but now "B"`` goes when A (or B) is deleted, even though B (or A) remains.""" @@ -222,6 +224,7 @@ def __init__(self, texts: list[str], remaining: list[tuple[int, str]], user_name self._left = [_words(c) - self.user for _, c in remaining] self._ids = {str(i) for i, _ in remaining} self._held = {" ".join(str(c).lower().split()) for _, c in remaining} + self._own = {n for t in self.texts for n in _NUMBER.findall(t)} # a number the deleted words say is no reference @property def wordings(self) -> list[str]: @@ -236,7 +239,7 @@ def _repeats(self, part: str) -> bool: def __call__(self, text: str) -> bool: text = str(text or "") - if any(m.group(1) in self._ids for m in _NAMES_ID.finditer(text)): + if any(m.group(1) in self._ids and m.group(1) not in self._own for m in _NAMES_ID.finditer(text)): return False return any(self._repeats(p) for p in (text, *(a or b for a, b in _QUOTED.findall(text)))) diff --git a/tests/memory/test_erase_siblings.py b/tests/memory/test_erase_siblings.py index d852a056..16f3ca0d 100644 --- a/tests/memory/test_erase_siblings.py +++ b/tests/memory/test_erase_siblings.py @@ -40,6 +40,10 @@ def test_a_line_a_remaining_memory_says_as_well_stays(): assert not gone("- Maya's car (id 7) was a red Honda City") # names the remaining memory assert gone("- Maya's car (id 8) is a red Honda City") # names a memory that isn't there assert not gone("- Maya plays the veena") + # only a written reference counts, and never a number the deleted memory itself says + assert gone("- Maya drives a red Honda City, memory 7 of her notes") + phone = origins.Erased(["Maya's phone has memory 7 GB"], [(7, "Maya likes filter coffee")], "Maya") + assert phone("- Maya's phone has memory 7 GB") and phone("- Maya's phone (memory 7) has 7 GB") # a tie goes to the remaining memory pune = origins.Erased([PUNE], [(3, MOVED)], "Maya") assert not pune("- Maya moved from Pune to Bengaluru and lives there now") From c709145acfe934bc819e15b1de3a036041faffbc Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sun, 18 Oct 2026 07:45:38 +0530 Subject: [PATCH 3/3] fix(memory): weigh a memory reference per matching wording and quote A deleted wording's own numbers no longer excuse a reference in a line that repeats another deleted wording, and a quoted part of a line is still judged when the line also names a remaining memory. --- sentient/memory/origins.py | 22 +++++++++++++--------- tests/memory/test_erase_siblings.py | 5 +++++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/sentient/memory/origins.py b/sentient/memory/origins.py index 5b1a8ddd..2758797c 100644 --- a/sentient/memory/origins.py +++ b/sentient/memory/origins.py @@ -213,18 +213,18 @@ class Erased: Called with a line of derived text (a MEMORY.md bullet, a journal line, a summary, an evidence quote), it says whether the line repeats a deleted wording (:func:`says`) and so goes. A line stays when a remaining current - memory says it as well or better, or when it names a remaining memory's id ("(memory 7)"): then it is that memory's line, not - the deleted one's. Each quoted part of a line is weighed on its own as well, so a journal line like - ``I used to remember "A", but now "B"`` goes when A (or B) is deleted, even though B (or A) remains.""" + memory says it as well or better, or when it names a remaining memory's id ("(memory 7)", unless the deleted + wording it repeats says that number itself): then it is that memory's line, not the deleted one's. Each quoted + part of a line is weighed on its own as well, so a journal line like ``I used to remember "A", but now "B"`` goes + when A (or B) is deleted, even though B (or A) remains.""" def __init__(self, texts: list[str], remaining: list[tuple[int, str]], user_name: str = "") -> None: self.user = _words(user_name) self.texts = [t for t in dict.fromkeys(texts) if t] - self._gone = [_words(t) - self.user for t in self.texts] + self._gone = [(_words(t) - self.user, set(_NUMBER.findall(t))) for t in self.texts] self._left = [_words(c) - self.user for _, c in remaining] self._ids = {str(i) for i, _ in remaining} self._held = {" ".join(str(c).lower().split()) for _, c in remaining} - self._own = {n for t in self.texts for n in _NUMBER.findall(t)} # a number the deleted words say is no reference @property def wordings(self) -> list[str]: @@ -234,13 +234,17 @@ def wordings(self) -> list[str]: def _repeats(self, part: str) -> bool: have = _words(part) - best = max((_share(w, have) for w in self._gone), default=0.0) - return best > 0 and not any(_share(w, have) >= best for w in self._left) + said = [(_share(w, have), nums) for w, nums in self._gone] + best = max((share for share, _ in said), default=0.0) + if best == 0: + return False + own = {n for share, nums in said if share for n in nums} # numbers the matching deleted words say + if any(m.group(1) in self._ids and m.group(1) not in own for m in _NAMES_ID.finditer(part)): + return False + return not any(_share(w, have) >= best for w in self._left) def __call__(self, text: str) -> bool: text = str(text or "") - if any(m.group(1) in self._ids and m.group(1) not in self._own for m in _NAMES_ID.finditer(text)): - return False return any(self._repeats(p) for p in (text, *(a or b for a, b in _QUOTED.findall(text)))) diff --git a/tests/memory/test_erase_siblings.py b/tests/memory/test_erase_siblings.py index 16f3ca0d..1e897c4d 100644 --- a/tests/memory/test_erase_siblings.py +++ b/tests/memory/test_erase_siblings.py @@ -44,6 +44,11 @@ def test_a_line_a_remaining_memory_says_as_well_stays(): assert gone("- Maya drives a red Honda City, memory 7 of her notes") phone = origins.Erased(["Maya's phone has memory 7 GB"], [(7, "Maya likes filter coffee")], "Maya") assert phone("- Maya's phone has memory 7 GB") and phone("- Maya's phone (memory 7) has 7 GB") + # a number only says nothing for the deleted wording that says it + two = origins.Erased([HONDA, "Maya's flat is number 7"], [(7, ATHER)], "Maya") + assert not two("- Maya drives a red Honda City (memory 7)") + # naming a remaining memory never keeps a quote of the deleted one + assert gone(f'- (memory 7) is new; I used to remember "{HONDA}"') # a tie goes to the remaining memory pune = origins.Erased([PUNE], [(3, MOVED)], "Maya") assert not pune("- Maya moved from Pune to Bengaluru and lives there now")