← all posts

Fact-Based Memory: Decay, Abstraction, and Contradiction Versioning

Most agent memory systems accumulate everything until the model context overflows. A fact-based approach with decay, contradiction versioning, and deterministic abstraction is a different architecture.

Part of my ongoing work building agents, harnesses, and local AI infrastructure. The memory system lives in palOMine, a personal agent in Rust.

Most agent memory systems accumulate everything.

A fact gets remembered. Then another. Then another. They stack in the context window until the model runs out of room, and the agent either truncates or the conversation stalls. The system has no mechanism for knowing which facts still matter and which are noise.

This is what happens when memory is treated as append-only storage: you get a database, not a memory system.

The agent experiments I've been running produced a different approach. palOMine's memory is fact-based: facts consolidate when reinforced, decay when neglected, version on contradiction, abstract into hierarchies, and keep provenance back to evidence. Forgetting means leaving the active set — facts are archived or superseded, never hard-deleted.

The system lives in a single Rust crate under crates/palomine-memory/, backed by SQLite, and it does the heavy lifting without calling an LLM. The LLM only enters during the abstraction pass, and even then it's a single deterministic call per cluster, temperature zero, bounded to a fixed JSON schema.

Here is how the architecture actually works.

The problem was always accumulation

The default model of memory treats facts as permanent. You say something about yourself — a preference, a tool you use, a habit you have — and the system stores it. It stores it again when you say it in the next conversation. And again. And again. The database grows. The context window fills. Nothing ever leaves.

This is not a bug in the storage layer. It's a design choice that reflects a deeper assumption: that more memory is better memory. That's true of a filing cabinet. It's false of a system that has to reason with what it remembers.

The alternative is a memory model where facts have a lifecycle. A fact that stops mattering should stop occupying space. A fact that conflicts with new evidence should not overwrite the old one silently. A cluster of related facts should eventually consolidate into a single higher-level generalization.

This is not a retrieval problem. Retrieval is just searching a larger dataset. This is an architecture problem: how do you build a system that remembers without accumulating?

The fact-based approach answers with three mechanisms: decay, contradiction versioning, and abstraction. Each one is deterministic. Each one operates on a different axis of the same problem.

Decay: when facts are neglected

The most common memory failure mode is not forgetting everything. It's forgetting the right things.

Episodic facts — observations about what happened in a specific session — should fade quickly. Preferences should linger. Derived facts — generalizations produced by the abstraction pass — should persist at the same rate as the preferences they describe. A single decay rate for all facts is a category error.

palOMine models this with four fact kinds, each with its own decay rate and initial confidence:

/// Per-day decay rate λ. Episodic facts fade quickly; preferences linger.
pub fn decay_rate(&self) -> f64 {
    match self {
        FactKind::Explicit => 0.05,
        FactKind::UserPreference => 0.02,
        FactKind::Episodic => 0.30,
        FactKind::Derived => 0.02,
    }
}

Initial confidence is also per-kind:

pub fn initial_confidence(&self) -> f64 {
    match self {
        FactKind::Explicit => 0.4,
        FactKind::UserPreference => 0.5,
        FactKind::Episodic => 0.4,
        FactKind::Derived => 0.5,
    }
}

The decay model is exponential: effective_confidence = initial_confidence × exp(-λ × days_since_reinforcement). A fact with λ = 0.30 drops to roughly 50% of its initial confidence after about two days without reinforcement. A preference with λ = 0.02 takes about 35 days to reach the same point.

This is not theoretical. The system computes effective confidence on read, using the stored decay rate and the number of days between the current time and the last reinforcement timestamp. There is no background process cleaning up facts. Decay is lazy, computed at query time. A fact's effective confidence is determined by its actual age, not by how recently the system ran a cleanup pass.

Here is the core function:

pub fn effective_confidence(confidence: f64, decay_rate: f64, age_days: f64) -> f64 {
    confidence * (-decay_rate * age_days.max(0.0)).exp()
}

When effective confidence falls below 0.05, a consolidate pass archives the fact. The fact is not deleted. It is moved out of the active set and into a dormant state where it can still be recalled for auditing or debugging. The system remembers that it forgot.

Reinforcement works the same way: each time a fact is re-remembered with the same (subject, predicate, object) triple, its confidence is updated with a bounded gain:

// Reinforcement gain: c := min(0.99, c + GAIN * (1 - c))
pub const REINFORCE_GAIN: f64 = 0.3;

The 1 - confidence term means the gain shrinks as confidence approaches the ceiling. A fact at 0.99 confidence gets almost no boost from reinforcement. A fact at 0.4 gets a significant one. This prevents runaway confidence from facts that have been reinforced many times — the system caps all confidence at 0.99 regardless of how often the fact is repeated.

The result is a memory system that forgets the right things at the right rate. Episodic observations fade. Preferences persist. Derived facts outlast their children. The archive threshold at 0.05 effective confidence acts as a pressure release: facts below this level are removed from the active set before they crowd out newer evidence, and they can still be recalled for lineage.

This is not a feature you can bolt on to an append-only store. It's an architecture where decay is baked into the data model — every fact carries its own decay rate, its own initial confidence, and its own last-reinforced timestamp. The system computes everything on read. There is no background scheduler. There is no TTL. There is no cron job that runs cleanup.

The memory decays naturally, the same way a memory system should.

Contradiction versioning: when facts conflict

The second failure mode is worse than accumulation. It's silent overwriting.

You say something about yourself. A week later you say the opposite. An overwrite system updates the row and discards the old value. You have lost the history of what you used to believe, and the system has no way to tell you that this fact has changed.

Contradiction versioning preserves lineage. When a new fact conflicts with an existing one, the old fact is marked superseded and the new fact is active, with a supersedes_id foreign key that chains the two together. You can follow the chain backward through all versions of a fact.

Here is how the remember_with_evidence function handles it:

pub fn remember_with_evidence(
    &self,
    subject: &str,
    predicate: &str,
    object: &str,
    kind: FactKind,
    evidence: Option<Evidence>,
) -> Result<u64, MemoryError> {

    // 1. identical active fact -> reinforce
    if let Some(row) = conn.query_row(...).optional()? {
        let (id, confidence, count) = row;
        let new_conf = (confidence + REINFORCE_GAIN * (1.0 - confidence)).min(0.99);
        // update confidence, increment reinforcement_count
        return Ok(id as u64);
    }

    // 2. conflicting active fact (same subject+predicate, different object)
    let conflict = conn.query_row(
        "SELECT id FROM memory_facts
         WHERE status = 'active' AND subject = ?1 AND predicate = ?2 AND object <> ?3
         ORDER BY last_reinforced DESC, id DESC LIMIT 1",
        params![subject, predicate, object],
    ).optional()?;

    // 3. insert new fact (optionally superseding a conflict)
    let initial = kind.initial_confidence();
    conn.execute("INSERT INTO memory_facts
     (subject, predicate, object, kind, confidence, reinforcement_count,
      first_seen, last_reinforced, decay_rate, status, supersedes_id)
     VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?6, ?7, 'active', ?8)",
        params![subject, predicate, object, kind.as_str(), initial, now, kind.decay_rate(), conflict],
    )?;
    let id = conn.last_insert_rowid();
    if let Some(conflict_id) = conflict {
        conn.execute(
            "UPDATE memory_facts SET status = 'superseded' WHERE id = ?1",
            params![conflict_id],
        )?;
    }
    Ok(id as u64)
}

The logic is simple but the implication is significant: the system distinguishes between reinforcement (the same fact, confirmed again) and contradiction (a different fact, replacing an old one). Reinforcement updates confidence. Contradiction marks the old as superseded and inserts a new active fact with a supersedes_id backpointer.

From a database perspective, this is a soft-delete with versioning. The old fact remains in the table with status superseded. The new fact has status active and carries the supersedes_id. You can trace the full history of any fact by following the chain.

From an agent perspective, this means the system knows when a fact has changed. It can tell you that you used to prefer one thing and now prefer another. It can show you the timeline of your preferences. It cannot do this with an overwrite model, because the overwrite model has no memory of what was overwritten.

This is the difference between a database and a memory system. A database stores values. A memory system stores the history of values.

The schema enforces this at the database level:

CREATE TABLE IF NOT EXISTS memory_facts (
    id                  INTEGER PRIMARY KEY,
    subject             TEXT NOT NULL,
    predicate           TEXT NOT NULL,
    object              TEXT NOT NULL,
    note                TEXT,
    kind                TEXT NOT NULL,
    confidence          REAL NOT NULL,
    reinforcement_count INTEGER NOT NULL,
    first_seen          INTEGER NOT NULL,
    last_reinforced     INTEGER NOT NULL,
    decay_rate          REAL NOT NULL,
    status              TEXT NOT NULL,
    supersedes_id       INTEGER
);

The supersedes_id column is the key. It links each fact to the one it replaced. The status column tracks lifecycle: active, archived (by decay), or superseded (by contradiction). Every fact carries its own provenance — evidence rows that link back to the session and message where it was first observed.

This is not a feature you add later. It is the foundation of the system. Without it, the agent has no way to know that a fact it retrieved is stale, or that a preference has changed, or that two facts in its memory are contradictory. With it, the system can reason about its own memory — which facts are current, which have been replaced, which are fading.

Contradiction versioning is the difference between a fact store and a memory system. It treats facts as versions, not values. Each version carries a timestamp, a status, and a lineage back to the version it replaced.

Abstraction: when facts become patterns

The third failure mode is fragmentation. A system that remembers everything eventually becomes a list of individual facts with no higher-level structure. The agent can recall that you prefer Rust, that you work in a Linux environment, that you use SQLite for storage, and that you write tests before code. But it cannot tell you what kind of engineer you are.

Abstraction solves this by clustering facts deterministically and then calling an LLM once per cluster to produce higher-level generalizations. The clusters are keyed by subject. If multiple facts share the same subject — same entity, same domain — they are grouped together. The LLM receives the full cluster and returns at most two higher-level facts that are genuinely supported by the evidence.

Here is the core function:

pub async fn abstract_facts(
    &self,
    provider: &Arc<dyn Provider>,
    model: &str,
    min_cluster: u32,
    prune: bool,
) -> Result<AbstractResult, MemoryError> {

    // 1. load active, non-derived facts and cluster by subject.
    let clusters = {
        let conn = self.lock()?;
        let mut stmt = conn.prepare(
            "SELECT id, subject, predicate, object, note
             FROM memory_facts
             WHERE status = 'active' AND kind != 'derived'",
        )?;
        let iter = stmt.query_map([], |row| {
            Ok(ClusterRow { /* ... */ })
        })?;
        let mut map: BTreeMap<String, Vec<ClusterRow>> = BTreeMap::new();
        for row in iter.flatten() {
            map.entry(row.subject.clone()).or_default().push(row);
        }
        map
    };

    // 2. one LLM call per eligible cluster.
    for cluster in eligible {
        match self.abstraction_llm_call(provider, model, &cluster).await {
            Ok(abstracts) => {
                for a in abstracts {
                    self.insert_derived(a, &cluster)?;
                }
            }
            Err(_) => {} // cluster skipped
        }
    }
}

The LLM prompt is strict:

"You are a memory consolidation engine. Given observed facts about one entity,
 produce AT MOST {max_facts} higher-level generalizations that are genuinely
 supported by the evidence. Do not invent. Return a JSON array of objects with
 keys subject, predicate, object, and optional note. If nothing is safely
 abstractable, return []."

Temperature is zero. Output is JSON only. The system tolerates markdown code fences but rejects prose. If the LLM returns garbage, the cluster is skipped. There is no fallback. There is no retry.

The derived facts are linked back to their cluster members through a memory_fact_links table. Each derived fact carries a parent_id and its children are the individual facts it was abstracted from. This is the provenance chain: every derived fact can be traced back to the evidence that justified it.

CREATE TABLE IF NOT EXISTS memory_fact_links (
    parent_id   INTEGER NOT NULL REFERENCES memory_facts(id) ON DELETE CASCADE,
    child_id    INTEGER NOT NULL REFERENCES memory_facts(id) ON DELETE CASCADE,
    created_at  INTEGER NOT NULL,
    PRIMARY KEY (parent_id, child_id)
);

The abstraction pass is idempotent. Running it twice with the same data produces the same result. Derived facts are linked to their children. When a derived fact is reinforced at least twice, its children are pruned (archived) because the high-level generalization has been validated enough times that the low-level facts are no longer needed in the active set.

/// Archive children of derived facts reinforced at least twice.
fn prune_children(&self) -> Result<usize, MemoryError> {
    let derived: Vec<i64> = {
        let mut stmt = conn.prepare(
            "SELECT id FROM memory_facts
             WHERE kind = 'derived' AND status = 'active' AND reinforcement_count >= 2",
        )?;
        iter.collect()
    };
    for parent in derived {
        let children: Vec<i64> = conn.query_map(...).collect();
        for child in children {
            conn.execute(
                "UPDATE memory_facts SET status = 'archived' WHERE id = ?1",
                params![child],
            )?;
        }
    }
}

This is a pruning mechanism. When a derived fact has been reinforced at least twice — meaning the system has seen enough evidence to validate it independently — its children can be archived. The high-level generalization has absorbed the low-level facts. They are no longer needed in the active set.

The abstraction pass never fails hard. A failed cluster is skipped and counted. The system produces a result with created, skipped, and failed counters so you know exactly what happened. If the LLM is down, the pass completes with zero created and all skipped. No data is lost. No facts are corrupted.

The result is a memory system that can reason about itself. It knows which facts are high-level generalizations, which are individual observations, which have been validated enough to prune their children, and which are fading out of the active set. It can answer questions like: what are the main patterns in my preferences? what has changed over time? what facts support this generalization?

None of this requires a second model. None of it requires a background process. It is all deterministic, bounded by the facts the system has actually observed, and tied to evidence that can be verified.

The one-line version

Memory is not a database. Accumulation is not retention. A fact-based system with decay, contradiction versioning, and deterministic abstraction is the difference between storing everything and remembering the right things.