Problem
When you need this. The same preference arrives twice, or a new note contradicts an older one. Decide which facts remain current while retaining the source of each decision.
A correction in the provenance build links a new fact to the one it replaces. That works when the application knows a correction is happening. Memories also arrive without that link. On day 0 Alice tells the assistant she is vegetarian; on day 1 a model summarizing her expense documents, in another task, writes “Alice eats meat.” She says she prefers aisle seats, and on day 5 says window seats. She mentions her home airport twice.
Each pair ends up as two current facts. Recall may return both, and the model has to guess which one is true. In this build’s fixture, 9 stored facts contain one conflict between a statement and an inference, one changed preference, one duplicate and one contradiction written in different words.
Consolidation is a pass over a user’s memories that merges duplicates and resolves conflicts. This build implements it with three named rules, records every decision, and tests where it stops working.
Success check: after consolidation, each attribute has one current fact, chosen by a named rule; a second run changes nothing; the retired facts are still stored; and a task-only fact still applies only inside its task.
You need: Python 3.11 or later. Nothing is downloaded and no model is called.
Concepts
An attribute makes a conflict detectable
Two texts can contradict each other in endless ways. A program cannot reliably tell that “Alice is vegetarian.” and “Alice eats meat.” disagree just by comparing words. This build gives facts an optional attribute: a short name for what the fact is about, such as diet, seat or home-airport. A conflict is two current facts at the same level and task with the same attribute and different text.
Facts without an attribute are still compared for exact duplicates after normalizing case, spaces and a final period. A contradiction between two of them is not detected. The fixture’s noise facts show this: both stay current, and the report lists every fact without an attribute so you can see how much is unchecked.
Authority: who said it
Each fact has an authority: user when the user stated it, inferred when a model or a rule concluded it. A correction through correct counts as a user statement, whoever calls it, so an application should call correct only for the user’s own words. The distinction matters because an inference can be wrong in a way the user’s own words usually are not, and the store should not let a newer guess replace an older statement.
Three ordered rules
| Rule | When it applies | Kept |
|---|---|---|
duplicate |
Same attribute (or none) and the same normalized text | The user’s statement over an inference; between equals, the older memory, which has the first source |
user statement over inference |
Same attribute; one stated, one inferred | The stated fact, even if it is older |
newer statement |
Same attribute and the same authority | The fact stated most recently, counting a word-for-word restatement as new |
Duplicates are merged first, so a repeated statement does not count as a conflict with itself. The rules are a policy, not a fact about the world. Some applications should ask the user instead; the rules make the choice visible and testable either way.
Retire, do not delete
The losing fact is retired: a row in resolutions names the kept memory, the retired memory, the rule and the time. Retired memories are no longer current, so recall and retrieval skip them, but they stay stored with their sources. If a rule was wrong, you can see what it retired and why.
Two consequences follow. Forgetting a kept fact must also delete the facts it retired; otherwise, removing the resolution would make “Alice eats meat.” current again. And retiring is not deletion: a user who asks the assistant to forget something needs forget, not consolidation.
An override is not a conflict
“This week Alice is eating vegan” is a task-level fact in porto-trip. It does not contradict the user-level “Alice is vegetarian”; it applies only to this task. The store already separates levels: a task-level fact about an attribute overrides user-level facts about it inside that task, and nowhere else. Consolidation compares only facts at the same level and task, so it never retires either one.
Architecture
consolidate runs in the application process against the SQLite store. plan reads the user’s current semantic facts once through the scoped query, groups them, and returns decisions without changing anything. resolve validates every decision and then writes all of them in one transaction. If one decision is invalid, none is written.
The recorded consolidation resolves 3 pairs. The window-seat statement retires the aisle-seat one by newer statement. Alice’s stated diet retires the inferred one by user statement over inference, even though the inference is newer: day 1 against day 0. The second home-airport fact is a duplicate and is retired in favor of the first, so the current fact keeps the first source. A second run resolves 0 pairs. All 9 memories are still stored.
Working code
Get the files
Download the complete lab, extract it and open a terminal in build-memory-consolidation-with-conflict-tests/. It includes the source, fixtures, recorded results and tests listed in the README.
python3.11 -m venv .venv
source .venv/bin/activateThe lab uses the Python standard library; there are no packages to install.
The consolidation pass
"""Consolidate one user's facts: merge duplicates and resolve conflicts.
A conflict is two current facts at the same level that name the same
attribute and say different things. The rules, in order:
1. duplicate: the same normalized text. Keep the copy with the stronger
authority, the user's statement over an inference; between copies of the
same authority keep the older, which has the first source. A restatement
still counts as new: the kept copy ranks by its newest copy of the same
authority in rule 3.
2. user statement over inference: keep the fact the user stated over one a
model inferred, whatever their order.
3. newer statement: between facts of the same authority, keep the newer.
Retired memories stay stored with a resolution record. Facts without an
attribute are compared only for duplicates; a contradiction written in two
different ways is not detected.
"""
import re
from memory import MemoryStore, Scope
RULES = ('duplicate', 'user statement over inference', 'newer statement')
def normalize(text: str) -> str:
return re.sub(r'\s+', ' ', text.strip().lower()).rstrip('.')
def plan(store: MemoryStore, scope: Scope) -> list[tuple[int, int, str]]:
"""Decide what to retire, without changing the store."""
facts = [row for row in store.current(scope, shadow=False) if row['kind'] == 'semantic']
decisions = []
groups: dict[tuple, list[dict]] = {}
for row in facts:
# Facts without an attribute are grouped by their text, so only exact duplicates meet.
key = (row['task_id'], row['attribute'] or 'text:' + normalize(row['text']))
groups.setdefault(key, []).append(row)
for rows in groups.values():
survivors, latest = [], {}
# The user's statements first, then oldest first: the first copy of a text is the one kept.
for row in sorted(rows, key=lambda r: (r['authority'] != 'user', r['id'])):
twin = next((s for s in survivors if normalize(s['text']) == normalize(row['text'])), None)
if twin:
decisions.append((twin['id'], row['id'], RULES[0]))
if row['authority'] == twin['authority']:
latest[twin['id']] = max(latest[twin['id']], row['id'])
else:
survivors.append(row)
latest[row['id']] = row['id']
if len(survivors) < 2:
continue
stated = [r for r in survivors if r['authority'] == 'user']
pool = stated or survivors
winner = max(pool, key=lambda r: latest[r['id']])
for row in survivors:
if row is not winner:
rule = RULES[1] if stated and row['authority'] == 'inferred' else RULES[2]
decisions.append((winner['id'], row['id'], rule))
return sorted(decisions, key=lambda d: d[1])
def consolidate(store: MemoryStore, scope: Scope) -> dict:
decisions = plan(store, scope)
store.resolve(scope, decisions)
unchecked = [row['id'] for row in store.current(scope, shadow=False)
if row['kind'] == 'semantic' and row['attribute'] is None]
return {'resolved': [{'kept': k, 'retired': r, 'rule': rule} for k, r, rule in decisions],
'facts_without_attribute': unchecked}plan groups facts by task and attribute. Facts without an attribute are grouped by their normalized text, so only exact duplicates can meet. Inside a group, it walks the user’s statements first, then inferences, each from oldest to newest, and retires any fact whose text matches an earlier survivor. A user’s statement therefore survives a duplicate inference, even an older one. Each kept copy remembers its newest duplicate of the same authority, so a user who says aisle, then window, then aisle again ends with aisle. If two or more survivors remain, the winner is the most recently stated fact, or the most recent fact if none was stated. Every other survivor is retired, and the rule name says why.
plan reads current(scope, shadow=False), which is every current fact without the task override applied. Consolidation needs the facts as stored; the override is a retrieval rule.
The version 4 store
Inspect memory.py, schema version 4
"""Memory service, version 4: attributes, authority and resolutions.
Standard library only. Extends the version 3 store (kinds, provenance,
corrections, expiry, deletion). A fact may name the attribute it describes
and whether the user stated it or a model inferred it. A resolution retires a
memory without deleting it. Opening a version 1 to 3 file migrates it to
version 4 in one transaction.
"""
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
import json
import re
import sqlite3
SCHEMA_VERSION = 4
AUTHORITIES = ('user', 'inferred')
KINDS = ('working', 'semantic', 'episodic')
V1_SCHEMA = """
CREATE TABLE memories (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
task_id TEXT,
text TEXT NOT NULL CHECK (length(trim(text)) > 0),
source TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX memories_by_scope ON memories (user_id, task_id);
"""
# Each step moves the file one version forward. All steps run in one transaction.
STEPS = {
0: [statement for statement in V1_SCHEMA.split(';') if statement.strip()],
1: [
"""CREATE TABLE memories_v2 (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
task_id TEXT,
text TEXT NOT NULL CHECK (length(trim(text)) > 0),
source TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT,
supersedes INTEGER UNIQUE REFERENCES memories_v2 (id)
)""",
"""CREATE TABLE deletions (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
memory_ids TEXT NOT NULL,
reason TEXT NOT NULL,
deleted_at TEXT NOT NULL
)""",
'INSERT INTO memories_v2 (id, user_id, task_id, text, source, created_at) '
'SELECT id, user_id, task_id, text, source, created_at FROM memories',
'DROP TABLE memories',
'ALTER TABLE memories_v2 RENAME TO memories',
'CREATE INDEX memories_by_scope ON memories (user_id, task_id)',
],
2: [
"""CREATE TABLE memories_v3 (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
task_id TEXT,
kind TEXT NOT NULL DEFAULT 'semantic' CHECK (kind IN ('working', 'semantic', 'episodic')),
text TEXT NOT NULL CHECK (length(trim(text)) > 0),
source TEXT NOT NULL,
created_at TEXT NOT NULL,
occurred_at TEXT,
expires_at TEXT,
supersedes INTEGER UNIQUE REFERENCES memories_v3 (id),
CHECK ((kind = 'episodic') = (occurred_at IS NOT NULL)),
CHECK (kind != 'working' OR task_id IS NOT NULL)
)""",
'INSERT INTO memories_v3 (id, user_id, task_id, text, source, created_at, expires_at, supersedes) '
'SELECT id, user_id, task_id, text, source, created_at, expires_at, supersedes FROM memories',
'DROP TABLE memories',
'ALTER TABLE memories_v3 RENAME TO memories',
'CREATE INDEX memories_by_scope ON memories (user_id, task_id)',
],
3: [
"""CREATE TABLE memories_v4 (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
task_id TEXT,
kind TEXT NOT NULL DEFAULT 'semantic' CHECK (kind IN ('working', 'semantic', 'episodic')),
attribute TEXT CHECK (attribute IS NULL OR kind = 'semantic'),
authority TEXT NOT NULL DEFAULT 'user' CHECK (authority IN ('user', 'inferred')),
text TEXT NOT NULL CHECK (length(trim(text)) > 0),
source TEXT NOT NULL,
created_at TEXT NOT NULL,
occurred_at TEXT,
expires_at TEXT,
supersedes INTEGER UNIQUE REFERENCES memories_v4 (id),
CHECK ((kind = 'episodic') = (occurred_at IS NOT NULL)),
CHECK (kind != 'working' OR task_id IS NOT NULL)
)""",
'INSERT INTO memories_v4 (id, user_id, task_id, kind, text, source, created_at, occurred_at, expires_at, '
'supersedes) SELECT id, user_id, task_id, kind, text, source, created_at, occurred_at, expires_at, '
'supersedes FROM memories',
'DROP TABLE memories',
'ALTER TABLE memories_v4 RENAME TO memories',
'CREATE INDEX memories_by_scope ON memories (user_id, task_id)',
"""CREATE TABLE resolutions (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
kept_id INTEGER NOT NULL REFERENCES memories (id),
retired_id INTEGER NOT NULL UNIQUE REFERENCES memories (id),
rule TEXT NOT NULL,
resolved_at TEXT NOT NULL
)""",
],
}
EXPECTED_TABLES = {1: {'memories'}, 2: {'memories', 'deletions'}, 3: {'memories', 'deletions'}}
IDENTIFIER = re.compile(r'[a-z0-9][a-z0-9-]{0,63}')
MAX_TEXT = 2_000
WORD = re.compile(r'[a-z0-9]+')
STOPWORDS = frozenset('a an and are as at be by did do does for from how i in is it my of on or that the this '
'to was we what when where which who will with you your'.split())
class MemoryInputError(ValueError):
"""Invalid scope, text, source or setting; nothing was written."""
class MemoryNotFound(LookupError):
"""No such memory in this scope. The same error covers another user's memory."""
class SchemaError(RuntimeError):
"""The database file has a schema this code does not understand."""
def _identifier(value, where):
if not isinstance(value, str) or not IDENTIFIER.fullmatch(value):
raise MemoryInputError(f'{where} must match {IDENTIFIER.pattern}')
return value
@dataclass(frozen=True)
class Scope:
user: str
task: str
def __post_init__(self):
_identifier(self.user, 'scope.user')
_identifier(self.task, 'scope.task')
class Clock:
"""A deterministic clock: each reading is one minute later; advance() jumps ahead."""
def __init__(self, start: str = '2026-10-20T09:00:00+00:00'):
self.moment = datetime.fromisoformat(start)
def now(self) -> str:
value = self.moment
self.moment += timedelta(minutes=1)
return value.isoformat()
def peek(self) -> str:
return self.moment.isoformat()
def advance(self, **delta):
self.moment += timedelta(**delta)
def words(text: str) -> set[str]:
return set(WORD.findall(text.lower())) - STOPWORDS
def _text(value, where):
if not isinstance(value, str) or not value.strip() or len(value) > MAX_TEXT:
raise MemoryInputError(f'{where} must be nonblank and at most {MAX_TEXT} characters')
return value
class MemoryStore:
def __init__(self, path: str, clock: Clock, secure_delete: bool = True):
self.clock = clock
self.db = sqlite3.connect(path)
self.db.execute('PRAGMA foreign_keys = ON')
# Overwrite deleted rows with zeros so forgotten text does not stay in the file.
self.db.execute(f'PRAGMA secure_delete = {"ON" if secure_delete else "OFF"}')
version = self.db.execute('PRAGMA user_version').fetchone()[0]
tables = {row[0] for row in self.db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
if (version == 0 and not tables) or (version in EXPECTED_TABLES and tables == EXPECTED_TABLES[version]):
self._migrate(version)
elif version != SCHEMA_VERSION:
self.db.close()
raise SchemaError(f'database schema version {version}; this code reads versions 1 to {SCHEMA_VERSION}')
def _migrate(self, version: int):
"""Run every step from `version` to the current version in one transaction."""
try:
self.db.execute('BEGIN')
for step in range(version, SCHEMA_VERSION):
for statement in STEPS[step]:
self.db.execute(statement)
self.db.execute(f'PRAGMA user_version = {SCHEMA_VERSION}')
self.db.commit()
except BaseException:
self.db.rollback()
self.db.close()
raise
def close(self):
self.db.close()
# Every read of memories goes through this scoped query.
def _rows(self, scope: Scope) -> list[dict]:
cursor = self.db.execute(
'SELECT id, task_id, kind, attribute, authority, text, source, created_at, occurred_at, expires_at, '
'supersedes, '
'(SELECT newer.id FROM memories AS newer WHERE newer.supersedes = memories.id) AS superseded_by, '
'(SELECT kept_id FROM resolutions WHERE retired_id = memories.id) AS retired_by '
'FROM memories WHERE user_id = ? AND (task_id IS NULL OR task_id = ?) ORDER BY id',
(scope.user, scope.task))
names = [column[0] for column in cursor.description]
return [dict(zip(names, row)) for row in cursor.fetchall()]
def _find(self, scope: Scope, memory_id: int) -> tuple[dict, dict]:
rows = {row['id']: row for row in self._rows(scope)}
if memory_id not in rows:
raise MemoryNotFound(f'no memory {memory_id} in this scope')
return rows[memory_id], rows
def _expiry(self, created: str, ttl_days: int | None) -> str | None:
if ttl_days is None:
return None
if type(ttl_days) is not int or not 1 <= ttl_days <= 3650:
raise MemoryInputError('ttl_days must be an integer from 1 to 3650')
return (datetime.fromisoformat(created) + timedelta(days=ttl_days)).isoformat()
def remember(self, scope: Scope, level: str, text: str, source: str, ttl_days: int | None = None,
kind: str = 'semantic', occurred_at: str | None = None, attribute: str | None = None,
authority: str = 'user') -> int:
if level not in ('user', 'task'):
raise MemoryInputError('level must be user or task')
if authority not in AUTHORITIES:
raise MemoryInputError(f'authority must be one of {AUTHORITIES}')
if attribute is not None:
if kind != 'semantic':
raise MemoryInputError('only a semantic memory names an attribute')
_identifier(attribute, 'attribute')
if kind not in KINDS:
raise MemoryInputError(f'kind must be one of {KINDS}')
if kind == 'working' and level != 'task':
raise MemoryInputError('working memory belongs to one task')
if (kind == 'episodic') != (occurred_at is not None):
raise MemoryInputError('an episode needs occurred_at, and only an episode has one')
if occurred_at is not None:
# Plain dates only, so stored values sort correctly as text.
try:
if len(occurred_at) != 10:
raise ValueError
date.fromisoformat(occurred_at)
except (TypeError, ValueError):
raise MemoryInputError('occurred_at must be a date in YYYY-MM-DD form') from None
_text(text, 'memory text')
_text(source, 'source')
self._expiry(self.clock.peek(), ttl_days) # validate before reading the clock
created = self.clock.now()
with self.db:
cursor = self.db.execute(
'INSERT INTO memories (user_id, task_id, kind, attribute, authority, text, source, created_at, '
'occurred_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(scope.user, scope.task if level == 'task' else None, kind, attribute, authority, text, source,
created, occurred_at, self._expiry(created, ttl_days)))
return cursor.lastrowid
def correct(self, scope: Scope, memory_id: int, text: str, source: str, ttl_days: int | None = None) -> int:
"""Store a corrected memory that supersedes the current one. The old version stays as history."""
old, _ = self._find(scope, memory_id)
if old['superseded_by'] is not None:
raise MemoryInputError(f'memory {memory_id} was already corrected by {old["superseded_by"]}; correct the newest')
_text(text, 'memory text')
_text(source, 'source')
self._expiry(self.clock.peek(), ttl_days)
created = self.clock.now()
with self.db:
cursor = self.db.execute(
'INSERT INTO memories (user_id, task_id, kind, attribute, authority, text, source, created_at, '
'occurred_at, expires_at, supersedes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(scope.user, old['task_id'], old['kind'], old['attribute'], 'user', text, source, created,
old['occurred_at'], self._expiry(created, ttl_days), memory_id))
return cursor.lastrowid
def history(self, scope: Scope, memory_id: int) -> list[dict]:
"""Every version of a memory, oldest first, with its source."""
_, rows = self._find(scope, memory_id)
chain = [rows[memory_id]]
while chain[0]['supersedes'] is not None:
chain.insert(0, rows[chain[0]['supersedes']])
while chain[-1]['superseded_by'] is not None:
chain.append(rows[chain[-1]['superseded_by']])
return [{key: row[key] for key in ('id', 'text', 'source', 'created_at', 'supersedes')} for row in chain]
def _closure(self, user: str, ids: list[int]) -> list[int]:
"""Every version of these memories and every memory retired in their favor, recursively.
Deleting a kept memory without the memories it retired would make them current again.
"""
found, pending = set(), list(ids)
while pending:
memory_id = pending.pop()
if memory_id in found:
continue
found.add(memory_id)
row = self.db.execute('SELECT supersedes FROM memories WHERE id = ? AND user_id = ?',
(memory_id, user)).fetchone()
if row and row[0] is not None:
pending.append(row[0])
pending += [r[0] for r in self.db.execute(
'SELECT id FROM memories WHERE supersedes = ? AND user_id = ?', (memory_id, user))]
pending += [r[0] for r in self.db.execute(
'SELECT retired_id FROM resolutions WHERE kept_id = ? AND user_id = ?', (memory_id, user))]
return sorted(found)
def _delete(self, user: str, ids: list[int], reason: str, at: str):
marks = ', '.join('?' * len(ids))
self.db.execute(f'DELETE FROM resolutions WHERE user_id = ? AND (kept_id IN ({marks}) OR retired_id IN ({marks}))',
(user, *ids, *ids))
for deleted in sorted(ids, reverse=True): # newest first: no remaining row refers to a deleted one
self.db.execute('DELETE FROM memories WHERE id = ? AND user_id = ?', (deleted, user))
self.db.execute('INSERT INTO deletions (user_id, memory_ids, reason, deleted_at) VALUES (?, ?, ?, ?)',
(user, json.dumps(ids), reason, at))
def forget(self, scope: Scope, memory_id: int, reason: str) -> list[int]:
"""Delete every version of a memory, and the memories it retired; record IDs, not text."""
_text(reason, 'reason')
ids = self._closure(scope.user, [row['id'] for row in self.history(scope, memory_id)])
with self.db:
self._delete(scope.user, ids, reason, self.clock.now())
marks = ', '.join('?' * len(ids))
if self.db.execute(f'SELECT count(*) FROM memories WHERE id IN ({marks})', ids).fetchone()[0]:
raise RuntimeError('a deleted memory is still stored')
return ids
def purge_expired(self) -> int:
"""Delete each memory whose newest version has expired, with its versions and the memories it retired."""
now = self.clock.peek()
expired = self.db.execute(
'SELECT user_id, id FROM memories WHERE expires_at IS NOT NULL AND expires_at <= ? '
'AND NOT EXISTS (SELECT 1 FROM memories AS newer WHERE newer.supersedes = memories.id) ORDER BY id',
(now,)).fetchall()
removed = set()
with self.db:
for user, newest in expired:
if newest in removed:
continue
ids = self._closure(user, [newest])
self._delete(user, ids, 'expired', now)
removed.update(ids)
return len(removed)
def current(self, scope: Scope, shadow: bool = True) -> list[dict]:
"""Visible memories that are not superseded, retired or expired, oldest first.
A task-level fact about an attribute overrides user-level facts about the
same attribute inside that task only.
"""
now = self.clock.peek()
rows = [row for row in self._rows(scope)
if row['superseded_by'] is None and row['retired_by'] is None
and not (row['expires_at'] and row['expires_at'] <= now)]
if not shadow:
return rows
task_attributes = {row['attribute'] for row in rows if row['task_id'] and row['attribute']}
return [row for row in rows if row['task_id'] or row['attribute'] not in task_attributes]
def resolve(self, scope: Scope, decisions: list[tuple[int, int, str]]) -> None:
"""Retire memories in favor of others, all in one transaction. Retired memories stay stored."""
rows = {row['id']: row for row in self.current(scope, shadow=False)}
for kept, retired, rule in decisions:
if kept not in rows or retired not in rows or kept == retired:
raise MemoryNotFound(f'no current memories {kept} and {retired} in this scope')
if rows[kept]['task_id'] != rows[retired]['task_id']:
raise MemoryInputError('only memories at the same level and task can be resolved')
_text(rule, 'rule')
at = self.clock.now()
with self.db:
for kept, retired, rule in decisions:
self.db.execute('INSERT INTO resolutions (user_id, kept_id, retired_id, rule, resolved_at) '
'VALUES (?, ?, ?, ?, ?)', (scope.user, kept, retired, rule, at))
def recall(self, scope: Scope, query: str, limit: int = 3) -> list[dict]:
"""Current memories only: not superseded and not expired at the current time."""
if type(limit) is not int or not 1 <= limit <= 20:
raise MemoryInputError('limit must be an integer from 1 to 20')
wanted = words(query)
scored = []
for row in self.current(scope):
score = len(wanted & words(row['text']))
if score:
scored.append({'id': row['id'], 'kind': row['kind'], 'level': 'task' if row['task_id'] else 'user',
'text': row['text'],
'source': row['source'], 'supersedes': row['supersedes'],
'expires_at': row['expires_at'], 'score': score})
scored.sort(key=lambda m: (-m['score'], -m['id']))
return scored[:limit]
def resolutions(self, scope: Scope) -> list[dict]:
cursor = self.db.execute('SELECT kept_id, retired_id, rule, resolved_at FROM resolutions '
'WHERE user_id = ? ORDER BY id', (scope.user,))
return [{'kept': kept, 'retired': retired, 'rule': rule, 'resolved_at': at} for kept, retired, rule, at in cursor]
def deletions(self, scope: Scope) -> list[dict]:
cursor = self.db.execute('SELECT memory_ids, reason, deleted_at FROM deletions WHERE user_id = ? ORDER BY id',
(scope.user,))
return [{'memory_ids': json.loads(ids), 'reason': reason, 'deleted_at': at} for ids, reason, at in cursor]Version 4 adds attribute and authority to memories, enforced by CHECK constraints, and a resolutions table whose retired_id is unique, so a memory can be retired once. Opening an older file runs the migration steps in one transaction, as before; existing facts get no attribute and the authority user.
_closure collects every version of a memory and every memory retired in its favor, repeatedly. forget and purge_expired delete that whole set, and the resolutions that refer to it, in one transaction.
Run the scenario
{
"user": "alice",
"memories": [
{"day": 0, "task": "trip-planning", "level": "user", "attribute": "diet", "authority": "user", "text": "Alice is vegetarian.", "source": "trip-planning/turn-3"},
{"day": 0, "task": "trip-planning", "level": "user", "attribute": "seat", "authority": "user", "text": "Alice prefers aisle seats.", "source": "trip-planning/turn-6"},
{"day": 0, "task": "trip-planning", "level": "user", "attribute": "home-airport", "authority": "user", "text": "Alice flies from Lisbon.", "source": "trip-planning/turn-7"},
{"day": 0, "task": "trip-planning", "level": "user", "text": "Alice dislikes noisy hotels.", "source": "trip-planning/turn-8"},
{"day": 1, "task": "expenses", "level": "user", "attribute": "diet", "authority": "inferred", "text": "Alice eats meat.", "source": "expenses/receipt-summary-2"},
{"day": 5, "task": "trip-planning", "level": "user", "attribute": "seat", "authority": "user", "text": "Alice prefers window seats now.", "source": "trip-planning/turn-31"},
{"day": 6, "task": "conference-trip", "level": "user", "attribute": "home-airport", "authority": "user", "text": "alice flies from Lisbon", "source": "conference-trip/turn-2"},
{"day": 6, "task": "conference-trip", "level": "user", "text": "Alice does not mind noise in hotels.", "source": "conference-trip/turn-4"},
{"day": 7, "task": "porto-trip", "level": "task", "attribute": "diet", "authority": "user", "text": "This week Alice is eating vegan.", "source": "porto-trip/turn-2"}
],
"views": [["alice", "porto-trip"], ["alice", "trip-planning"]]
}"""Store conflicting and duplicate facts, consolidate them, and show what each task sees."""
import json
from pathlib import Path
import tempfile
from consolidate import consolidate
from memory import Clock, MemoryStore, Scope
def facts(store: MemoryStore, scope: Scope) -> list[dict]:
return [{'id': r['id'], 'attribute': r['attribute'], 'level': 'task' if r['task_id'] else 'user',
'text': r['text']} for r in store.current(scope) if r['kind'] == 'semantic']
def run(scenario: dict) -> dict:
with tempfile.TemporaryDirectory() as folder:
clock = Clock()
store = MemoryStore(str(Path(folder) / 'memory.sqlite3'), clock)
day = 0
for item in scenario['memories']:
clock.advance(days=item['day'] - day)
day = item['day']
store.remember(Scope(scenario['user'], item['task']), item['level'], item['text'], item['source'],
attribute=item.get('attribute'), authority=item.get('authority', 'user'))
views = [Scope(*view) for view in scenario['views']]
before = {f'{v.user}/{v.task}': facts(store, v) for v in views}
report = consolidate(store, views[0])
again = consolidate(store, views[0])
after = {f'{v.user}/{v.task}': facts(store, v) for v in views}
resolutions = store.resolutions(views[0])
stored = store.db.execute('SELECT count(*) FROM memories').fetchone()[0]
store.close()
return {'before': before, 'report': report, 'second_run': again, 'after': after,
'resolutions': resolutions, 'memories_still_stored': stored}
if __name__ == '__main__':
scenario = json.loads(Path(__file__).with_name('scenario.json').read_text())
print(json.dumps(run(scenario), indent=2))From the directory with the files, run:
python3 demo.pyThe recorded consolidation report:
{
"resolved": [
{
"kept": 6,
"retired": 2,
"rule": "newer statement"
},
{
"kept": 1,
"retired": 5,
"rule": "user statement over inference"
},
{
"kept": 3,
"retired": 7,
"rule": "duplicate"
}
],
"facts_without_attribute": [
4,
8
]
}Inspect the complete recorded output
{
"before": {
"alice/porto-trip": [
{
"id": 2,
"attribute": "seat",
"level": "user",
"text": "Alice prefers aisle seats."
},
{
"id": 3,
"attribute": "home-airport",
"level": "user",
"text": "Alice flies from Lisbon."
},
{
"id": 4,
"attribute": null,
"level": "user",
"text": "Alice dislikes noisy hotels."
},
{
"id": 6,
"attribute": "seat",
"level": "user",
"text": "Alice prefers window seats now."
},
{
"id": 7,
"attribute": "home-airport",
"level": "user",
"text": "alice flies from Lisbon"
},
{
"id": 8,
"attribute": null,
"level": "user",
"text": "Alice does not mind noise in hotels."
},
{
"id": 9,
"attribute": "diet",
"level": "task",
"text": "This week Alice is eating vegan."
}
],
"alice/trip-planning": [
{
"id": 1,
"attribute": "diet",
"level": "user",
"text": "Alice is vegetarian."
},
{
"id": 2,
"attribute": "seat",
"level": "user",
"text": "Alice prefers aisle seats."
},
{
"id": 3,
"attribute": "home-airport",
"level": "user",
"text": "Alice flies from Lisbon."
},
{
"id": 4,
"attribute": null,
"level": "user",
"text": "Alice dislikes noisy hotels."
},
{
"id": 5,
"attribute": "diet",
"level": "user",
"text": "Alice eats meat."
},
{
"id": 6,
"attribute": "seat",
"level": "user",
"text": "Alice prefers window seats now."
},
{
"id": 7,
"attribute": "home-airport",
"level": "user",
"text": "alice flies from Lisbon"
},
{
"id": 8,
"attribute": null,
"level": "user",
"text": "Alice does not mind noise in hotels."
}
]
},
"report": {
"resolved": [
{
"kept": 6,
"retired": 2,
"rule": "newer statement"
},
{
"kept": 1,
"retired": 5,
"rule": "user statement over inference"
},
{
"kept": 3,
"retired": 7,
"rule": "duplicate"
}
],
"facts_without_attribute": [
4,
8
]
},
"second_run": {
"resolved": [],
"facts_without_attribute": [
4,
8
]
},
"after": {
"alice/porto-trip": [
{
"id": 3,
"attribute": "home-airport",
"level": "user",
"text": "Alice flies from Lisbon."
},
{
"id": 4,
"attribute": null,
"level": "user",
"text": "Alice dislikes noisy hotels."
},
{
"id": 6,
"attribute": "seat",
"level": "user",
"text": "Alice prefers window seats now."
},
{
"id": 8,
"attribute": null,
"level": "user",
"text": "Alice does not mind noise in hotels."
},
{
"id": 9,
"attribute": "diet",
"level": "task",
"text": "This week Alice is eating vegan."
}
],
"alice/trip-planning": [
{
"id": 1,
"attribute": "diet",
"level": "user",
"text": "Alice is vegetarian."
},
{
"id": 3,
"attribute": "home-airport",
"level": "user",
"text": "Alice flies from Lisbon."
},
{
"id": 4,
"attribute": null,
"level": "user",
"text": "Alice dislikes noisy hotels."
},
{
"id": 6,
"attribute": "seat",
"level": "user",
"text": "Alice prefers window seats now."
},
{
"id": 8,
"attribute": null,
"level": "user",
"text": "Alice does not mind noise in hotels."
}
]
},
"resolutions": [
{
"kept": 6,
"retired": 2,
"rule": "newer statement",
"resolved_at": "2026-10-27T09:09:00+00:00"
},
{
"kept": 1,
"retired": 5,
"rule": "user statement over inference",
"resolved_at": "2026-10-27T09:09:00+00:00"
},
{
"kept": 3,
"retired": 7,
"rule": "duplicate",
"resolved_at": "2026-10-27T09:09:00+00:00"
}
],
"memories_still_stored": 9
}Check your understanding
The inferred diet fact was stored on day 1, after Alice’s statement on day 0. Under newer statement alone, it would win. Which fact would the assistant then use when booking a restaurant, and what would a user see? Decide, then compare: the assistant would plan around meat for a vegetarian, and the only record of why would be the time order. The authority rule exists because the order in which memories arrive says nothing about how they were obtained.
Read the explanation
An override is not a conflict. Keeping levels apart lets one task change a fact without retiring the user's general fact.
Failure cases
Run the tests:
python3 -m unittest -v test_consolidation.pyThe suite has 15 tests. The first runs demo.py and compares its output with the recording. The others cover these cases:
| Case | Expected behavior |
|---|---|
| A newer inference contradicts an older statement | Keep the statement; retire the inference. |
| The user changes a preference | Keep the newer statement; the older one stays stored with a resolution. |
| The same fact is stored twice | Keep the older copy and its source. |
| An inference and the user’s statement say the same thing | Keep the statement, so a newer inference cannot outrank it. |
| The user restates an earlier value after changing it | The restated value is current; the oldest copy keeps its source. |
| Consolidation runs again | Nothing new is resolved. |
| Two facts without an attribute contradict each other | Not detected; both stay and are reported as unchecked. |
| A task-level fact and a user-level fact share an attribute | Not a conflict; the task fact applies only in its task. |
| One decision in a batch is invalid | Nothing is written; facts at different levels cannot be resolved together. |
| The kept fact is forgotten | The facts it retired are deleted too and do not become current. |
| The kept fact expires | Purging removes it and the facts it retired. |
| A corrected fact or another user’s fact | Not compared; only the current version and the same user count. |
| A version 3 file | Migrated to version 4; old facts get no attribute and the user authority. |
| An unknown authority, an invalid attribute, or an attribute on an episode | Reject before writing. |
To check that the tests catch real defects, we changed one rule at a time. Ignoring authority fails five tests, including the statement-over-inference test. Keeping the newer duplicate fails the duplicate test. Ignoring authority only when merging duplicates fails the test for an inference and a statement with the same text. Ranking kept copies by their own ID instead of their newest restatement fails the restatement test. Comparing across levels fails the override test. Leaving retired facts out of _closure fails both deletion tests. Removing the override fails the override test. Writing decisions before validating them fails the batch test. Treating retired facts as current fails five tests.
Inspect the failure tests
import json
from pathlib import Path
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from consolidate import consolidate, plan
from memory import Clock, MemoryInputError, MemoryNotFound, MemoryStore, Scope, STEPS
HERE = Path(__file__).parent
ALICE = Scope('alice', 'trip-planning')
class ConsolidationTest(unittest.TestCase):
def setUp(self):
folder = tempfile.TemporaryDirectory()
self.addCleanup(folder.cleanup)
self.path = str(Path(folder.name) / 'memory.sqlite3')
self.clock = Clock()
self.store = MemoryStore(self.path, self.clock)
self.addCleanup(self.store.close)
def fact(self, text, attribute=None, authority='user', scope=ALICE, level='user', **options):
return self.store.remember(scope, level, text, 'turn-1', attribute=attribute, authority=authority, **options)
def texts(self, scope=ALICE):
return [row['text'] for row in self.store.current(scope)]
def test_fixture_matches_recorded_result(self):
output = subprocess.run([sys.executable, 'demo.py'], cwd=HERE, check=True,
capture_output=True, text=True).stdout
self.assertEqual(json.loads(output), json.loads((HERE / 'consolidation-result.json').read_text()))
def test_user_statement_beats_a_newer_inference(self):
stated = self.fact('Alice is vegetarian.', 'diet')
inferred = self.fact('Alice eats meat.', 'diet', authority='inferred')
report = consolidate(self.store, ALICE)
self.assertEqual(report['resolved'], [{'kept': stated, 'retired': inferred,
'rule': 'user statement over inference'}])
self.assertEqual(self.texts(), ['Alice is vegetarian.'])
def test_duplicate_of_an_inference_keeps_the_users_statement(self):
inferred = self.fact('Alice is vegetarian.', 'diet', authority='inferred')
stated = self.fact('Alice is vegetarian.', 'diet')
newer_inference = self.fact('Alice eats fish.', 'diet', authority='inferred')
report = consolidate(self.store, ALICE)
self.assertEqual(report['resolved'], [
{'kept': stated, 'retired': inferred, 'rule': 'duplicate'},
{'kept': stated, 'retired': newer_inference, 'rule': 'user statement over inference'}])
self.assertEqual(self.texts(), ['Alice is vegetarian.'])
def test_restating_an_earlier_value_makes_it_current(self):
first = self.fact('Alice prefers aisle seats.', 'seat')
window = self.fact('Alice prefers window seats.', 'seat')
again = self.fact('Alice prefers aisle seats.', 'seat')
report = consolidate(self.store, ALICE)
self.assertEqual(report['resolved'], [
{'kept': first, 'retired': window, 'rule': 'newer statement'},
{'kept': first, 'retired': again, 'rule': 'duplicate'}])
self.assertEqual(self.texts(), ['Alice prefers aisle seats.'])
self.assertEqual([r['source'] for r in self.store.current(ALICE)], ['turn-1'])
def test_newer_statement_wins_and_the_older_stays_stored(self):
older = self.fact('Alice prefers aisle seats.', 'seat')
newer = self.fact('Alice prefers window seats now.', 'seat')
consolidate(self.store, ALICE)
self.assertEqual(self.texts(), ['Alice prefers window seats now.'])
self.assertEqual(self.store.resolutions(ALICE)[0]['retired'], older)
self.assertEqual(self.store.resolutions(ALICE)[0]['kept'], newer)
self.assertEqual(self.store.db.execute('SELECT count(*) FROM memories').fetchone()[0], 2)
def test_duplicate_keeps_the_first_source(self):
first = self.store.remember(ALICE, 'user', 'Alice flies from Lisbon.', 'turn-7', attribute='home-airport')
self.store.remember(ALICE, 'user', ' alice flies from LISBON ', 'turn-40', attribute='home-airport')
report = consolidate(self.store, ALICE)
self.assertEqual(report['resolved'][0]['rule'], 'duplicate')
self.assertEqual([r['source'] for r in self.store.current(ALICE)], ['turn-7'])
self.assertEqual(report['resolved'][0]['kept'], first)
def test_second_run_changes_nothing(self):
self.fact('Alice prefers aisle seats.', 'seat')
self.fact('Alice prefers window seats now.', 'seat')
consolidate(self.store, ALICE)
self.assertEqual(consolidate(self.store, ALICE)['resolved'], [])
self.assertEqual(len(self.store.resolutions(ALICE)), 1)
def test_free_text_contradiction_is_not_detected(self):
a = self.fact('Alice dislikes noisy hotels.')
b = self.fact('Alice does not mind noise in hotels.')
report = consolidate(self.store, ALICE)
self.assertEqual(report['resolved'], [])
self.assertEqual(report['facts_without_attribute'], [a, b])
self.assertEqual(len(self.texts()), 2)
def test_task_fact_overrides_only_inside_its_task(self):
self.fact('Alice is vegetarian.', 'diet')
porto = Scope('alice', 'porto-trip')
self.fact('This week Alice is eating vegan.', 'diet', scope=porto, level='task')
self.assertEqual(consolidate(self.store, porto)['resolved'], [])
self.assertEqual(self.texts(porto), ['This week Alice is eating vegan.'])
self.assertEqual(self.texts(ALICE), ['Alice is vegetarian.'])
def test_resolve_writes_all_or_nothing(self):
a = self.fact('Alice prefers aisle seats.', 'seat')
b = self.fact('Alice prefers window seats now.', 'seat')
with self.assertRaises(MemoryNotFound):
self.store.resolve(ALICE, [(b, a, 'newer statement'), (b, 999, 'newer statement')])
self.assertEqual(self.store.resolutions(ALICE), [])
porto = Scope('alice', 'porto-trip')
c = self.fact('Window seat for this trip.', 'seat', scope=porto, level='task')
with self.assertRaisesRegex(MemoryInputError, 'same level'):
self.store.resolve(porto, [(c, b, 'newer statement')])
def test_forgetting_a_kept_fact_does_not_revive_the_retired_one(self):
stated = self.fact('Alice is vegetarian.', 'diet')
inferred = self.fact('Alice eats meat.', 'diet', authority='inferred')
consolidate(self.store, ALICE)
self.assertEqual(self.store.forget(ALICE, stated, 'user request'), [stated, inferred])
self.assertEqual(self.texts(), [])
self.assertEqual(self.store.resolutions(ALICE), [])
def test_purging_an_expired_kept_fact_removes_what_it_retired(self):
self.fact('Alice prefers aisle seats.', 'seat')
self.fact('Alice prefers window seats this month.', 'seat', ttl_days=30)
consolidate(self.store, ALICE)
self.clock.advance(days=31)
self.assertEqual(self.store.purge_expired(), 2)
self.assertEqual(self.texts(), [])
def test_corrected_facts_and_other_users_are_not_compared(self):
old = self.fact('Alice is vegetarian.', 'diet')
new = self.store.correct(ALICE, old, 'Alice eats fish but no meat.', 'turn-12')
self.fact('Bob eats meat.', 'diet', scope=Scope('bob', 'trip-planning'))
inferred = self.fact('Alice eats meat.', 'diet', authority='inferred')
self.assertEqual(plan(self.store, ALICE), [(new, inferred, 'user statement over inference')])
self.assertEqual(plan(self.store, Scope('bob', 'trip-planning')), [])
def test_version_3_file_is_migrated(self):
path = Path(self.path).with_name('v3.sqlite3')
db = sqlite3.connect(path)
db.execute('BEGIN')
for step in (0, 1, 2):
for statement in STEPS[step]:
db.execute(statement)
db.execute("INSERT INTO memories (user_id, kind, text, source, created_at) "
"VALUES ('alice', 'semantic', 'Alice is vegetarian.', 'turn-3', '2026-10-01T09:00:00+00:00')")
db.execute('PRAGMA user_version = 3')
db.commit()
db.close()
store = MemoryStore(str(path), self.clock)
self.addCleanup(store.close)
row = store.current(ALICE)[0]
self.assertEqual((row['attribute'], row['authority'], row['source']), (None, 'user', 'turn-3'))
with self.assertRaises(sqlite3.IntegrityError):
with store.db:
store.db.execute("INSERT INTO memories (user_id, kind, attribute, text, source, created_at, "
"occurred_at) VALUES ('alice', 'episodic', 'diet', 'x', 's', 't', '2026-10-01')")
def test_invalid_authority_or_attribute_is_rejected(self):
for action in (lambda: self.fact('x', 'diet', authority='model'),
lambda: self.fact('x', 'Diet Type'),
lambda: self.store.remember(ALICE, 'user', 'x', 's', kind='episodic',
occurred_at='2026-10-01', attribute='diet')):
with self.assertRaises(MemoryInputError):
action()
self.assertEqual(self.texts(), [])
if __name__ == '__main__':
unittest.main()Deployment
When to consolidate
Run consolidation after writes rather than inside recall: after a conversation ends, or on a schedule per user. It reads one user’s facts and writes in one transaction, so it can run while that user is idle. Run it for every task the user has, because each run compares user-level facts and the facts of the given task.
Log the report: rule names and memory IDs, never memory text. A rise in user statement over inference means an inference step is often wrong; a high count of facts without an attribute means consolidation checks little.
When a conflict matters and both facts are plausible, ask the user instead of choosing. The plan step makes that possible: show the planned decision, and call resolve only after the user confirms.
Upgrade files to version 4 the same way as earlier versions. No server deployment was made for this build. The tests pass on Python 3.11 and 3.15.
Scaling
Where the work grows
plan reads a user’s current facts and groups them in memory, which costs time in proportion to the number of facts. Retired facts stay stored, so the table grows with every resolution even though the current set does not; _rows still scans them for that user. This build records no timing. For users with many retired facts, an index on resolutions (retired_id) and an archive policy for old retired rows are the first changes to measure.
Key takeaways
- Two current facts about the same thing make the model guess; consolidation chooses with rules you can name and test.
- An attribute makes a conflict detectable; facts without one are checked only for exact duplicates.
- A stated fact should beat an inference, whatever their order; between equals, the newer statement wins.
- Retire instead of deleting, and delete retired facts together with the fact that retired them.
Best practices
- Keep plan and resolve separate, so a decision can be reviewed or confirmed before it is written.
- Report how many facts have no attribute; that number is the part consolidation does not check.
- Keep task-level overrides out of conflict resolution.
- Make consolidation idempotent and atomic, and test both.
References
- Build working, semantic, and episodic memory retrieval: the version 3 store this build migrates.
- Build memory provenance and deletion: corrections, history and deletion records that consolidation builds on.
The implementation, scenario, recorded output and tests belong to this build. The resolution rules are this build’s policy; no external source validates them.