aiml
SearchMy learning
Menu

Lesson 8 of 10Module 2

Build memory consolidation with conflict tests

Add memory consolidation and conflict-resolution tests

Merge duplicate memories and resolve conflicts using source and recency rules. Retain superseded facts with records of the consolidation decision.

Prerequisites

Complete Build working, semantic, and episodic memory retrieval: this build migrates its version 3 file and changes which facts are current. Know what a database transaction is.

By the end, you can

  • Detect duplicate and conflicting facts by attribute, and resolve them with ordered, named rules.
  • Retire a losing fact with a resolution record, and keep task-level overrides separate from conflicts.
  • Test that consolidation is atomic and repeatable, that forgetting cannot revive retired facts, and where detection stops.
Runtime
Python 3.11+
Dependencies
Standard library only

Lab code, tests and fixtures (ZIP, 13 KB)Quiz check your understanding

Progress is saved in this browser.

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

Memory consolidation · plan, then resolve in one transaction · no model
scenario.json9 facts · aliceJSONone Python processMemoryInputErroror SchemaErrorbad authority orattribute; otherschema versiondemo.pyremember(scope, level,text, source, attribute,authority)INSERT9 rowsrun twiceconsolidate(store, scope)plan(store, scope)decides; writes nothingrules, in order1 duplicatesame text · keep stated, then older2 user statement over inferencesame attribute · keep the stated fact3 newer statementsame authority · keep the newerno attribute: exact duplicates onlyother level or task: never comparedcurrentfactsdecisionsresolve(scope, decisions)check each: both current, samelevel and task; then one transactionINSERT INTO resolutionsin one transactionMemoryNotFound · MemoryInputErrorone decision fails its check:nothing is writtenmemory.sqlite3 · user_version 4memoriesid · task_id · kind · textsource · created_at+ attribute+ authority: user | inferredresolutionskept_id · retired_id UNIQUErule · resolved_atretired rows stay in memoriesscenario.json9 facts · aliceJSONdemo.pyremember(scope, level,text, source,attribute, authority)INSERT 9 rowsMemoryInputErroror SchemaErrorbad authority orattribute; otherschema versionmemory.sqlite3memoriesid · task_id · kindtext · source+ attribute+ authority: user | inferredresolutionskept_idretired_id UNIQUErule · resolved_atuser_version 4run twiceconsolidate()plan(store, scope)writes nothingrules, in order1 duplicatesame normalized text:keep stated, then older2 user statement over inferencesame attribute:keep the stated fact3 newer statementsame authority:keep the newerno attribute: duplicates onlynever compared across levelsdecisionsresolve(scope, decisions)check each decision,then one transactioncurrent factsINSERTresolutionsMemoryNotFound orMemoryInputErrornothing is written
Consolidation plans decisions for conflicting facts, then writes them together. Retired facts remain stored with resolution provenance. Authority comes from the supplied fixture labels; this lab does not infer it with a model.

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.

Prepare the local labTerminal · bash
python3.11 -m venv .venv
source .venv/bin/activate

The lab uses the Python standard library; there are no packages to install.

The consolidation pass

consolidate.pySource · pythonDownload
"""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.pySource · pythonDownload
"""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

scenario.jsonSource · jsonDownload
{
  "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"]]
}
demo.pySource · pythonDownload
"""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:

Terminal commandsTerminal · bash
python3 demo.py

The recorded consolidation report:

consolidation-result.json · reportRecorded output · jsonDownload
{
  "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
consolidation-result.jsonRecorded output · jsonDownload
{
  "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.

In porto-trip, Alice has the task-level fact 'This week Alice is eating vegan.' and the user-level fact 'Alice is vegetarian.', both with the attribute diet. What does consolidation do with them?

Choose one answer, then check your reasoning.

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:

Terminal commandsTerminal · bash
python3 -m unittest -v test_consolidation.py

The 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
test_consolidation.pySource · pythonDownload
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

The implementation, scenario, recorded output and tests belong to this build. The resolution rules are this build’s policy; no external source validates them.

Written by Arjun Prabhulal.

Module 2

Store, retrieve and maintain memory