Module 4: Memory & Context Engineering · Lesson 4 of 5 · 40 min

The Write Path & the Read Path

Between 'candidate fact' and 'stored fact' sits a gauntlet: dedupe, contradiction check, provenance gate. Between 'stored fact' and 'in the prompt' sits another: relevance + recency + importance scoring, with a stingy top-k. Both gauntlets exist because recalled junk is context poisoning.

A memory system is two pipelines. The write path decides what becomes a memory; the read path decides what a given session gets to see. Most memory failures are gate failures: a write path that stores everything breeds a landfill; a read path that recalls eagerly shovels the landfill into the prompt. Discipline at both gates is the entire game.

The write path

  1. Extract candidate facts from the session (Lesson 3's forced structured call).
  2. Screen provenance: facts stated directly by the user pass; "facts" originating in content the agent merely read (files, web pages, tool output) are quarantined for review — this is the injection gate, detailed in Lesson 5.
  3. Deduplicate: embedding similarity against existing memories. Near-identical → skip (optionally refresh the timestamp).
  4. Contradiction check: same topic, incompatible content — similar-but-not-identical embeddings plus an LLM judgment. On contradiction: keep both, timestamped, mark the old one superseded, prefer the newer at recall, and flag the conflict.
  5. Store with provenance, timestamp, importance.
write path: dedupe + contradiction resolution
# Colab cell 1 — run once. Set your key in the 🔑 panel (name it
# ANTHROPIC_API_KEY) or just paste it when prompted.
!pip install -q anthropic sentence-transformers

import os
try:
    from google.colab import userdata
    os.environ["ANTHROPIC_API_KEY"] = userdata.get("ANTHROPIC_API_KEY")
except Exception:
    from getpass import getpass
    os.environ.setdefault("ANTHROPIC_API_KEY", getpass("Anthropic API key: "))

import sqlite3, json, time
import numpy as np
import anthropic
from sentence_transformers import SentenceTransformer

client = anthropic.Anthropic()
encoder = SentenceTransformer("all-MiniLM-L6-v2")

# Lesson 3's MemoryStore, unchanged, so this notebook stands alone.
SCHEMA = """
CREATE TABLE IF NOT EXISTS memories (
    id            INTEGER PRIMARY KEY,
    fact          TEXT NOT NULL,
    provenance    TEXT NOT NULL,
    created_at    REAL NOT NULL,
    importance    REAL NOT NULL DEFAULT 0.5,
    superseded_by INTEGER,
    embedding     TEXT NOT NULL
);
"""

class MemoryStore:
    def __init__(self, path: str = "memory.db"):
        self.db = sqlite3.connect(path)
        self.db.executescript(SCHEMA)

    def add(self, fact: str, provenance: dict, importance: float = 0.5) -> int:
        vec = encoder.encode(fact, normalize_embeddings=True)
        cur = self.db.execute(
            "INSERT INTO memories (fact, provenance, created_at, importance, embedding) "
            "VALUES (?, ?, ?, ?, ?)",
            (fact, json.dumps(provenance), time.time(), importance,
             json.dumps(vec.tolist())),
        )
        self.db.commit()
        return cur.lastrowid

    def all_active(self) -> list[dict]:
        rows = self.db.execute(
            "SELECT id, fact, provenance, created_at, importance, embedding "
            "FROM memories WHERE superseded_by IS NULL").fetchall()
        return [{"id": r[0], "fact": r[1], "provenance": json.loads(r[2]),
                 "created_at": r[3], "importance": r[4],
                 "vec": np.array(json.loads(r[5]))} for r in rows]

def log_conflict(old: dict, new_id: int) -> None:
    print(f"[conflict] memory #{old['id']} superseded by #{new_id}")

DUP_THRESHOLD = 0.90      # near-identical: skip
TOPIC_THRESHOLD = 0.70    # same topic: check for contradiction

def judge_contradiction(new_fact: str, old_fact: str) -> str:
    resp = client.messages.create(
        model="claude-sonnet-5", max_tokens=10,
        messages=[{"role": "user", "content":
            "Do these two statements contradict each other? "
            "Answer only CONTRADICTS or COMPATIBLE.\n"
            f"A: {old_fact}\nB: {new_fact}"}],
    )
    verdict = next(b.text for b in resp.content if b.type == "text")
    return verdict.strip().upper()

def write_fact(store: MemoryStore, candidate: dict) -> str:
    vec = encoder.encode(candidate["fact"], normalize_embeddings=True)
    for mem in store.all_active():
        sim = float(vec @ mem["vec"])
        if sim >= DUP_THRESHOLD:
            return f"skipped duplicate of #{mem['id']}"
        if sim >= TOPIC_THRESHOLD:
            if judge_contradiction(candidate["fact"], mem["fact"]) == "CONTRADICTS":
                new_id = store.add(candidate["fact"], candidate["provenance"],
                                   candidate["importance"])
                store.db.execute(
                    "UPDATE memories SET superseded_by = ? WHERE id = ?",
                    (new_id, mem["id"]))
                store.db.commit()
                log_conflict(old=mem, new_id=new_id)   # surface, don't hide
                return f"stored #{new_id}, superseded #{mem['id']} (conflict flagged)"
    new_id = store.add(candidate["fact"], candidate["provenance"],
                       candidate["importance"])
    return f"stored #{new_id}"

# demo: the three zones — store, skip-duplicate, supersede-on-contradiction
store = MemoryStore(":memory:")
prov = {"type": "session_extraction", "source_type": "user_direct", "session": "demo"}
for fact in ["User deploys to production on Fridays.",
             "User deploys to production on Fridays.",
             "User no longer deploys on Fridays; deploys moved to Wednesdays."]:
    print(write_fact(store, {"fact": fact, "provenance": prov, "importance": 0.7}))
The two thresholds carve embedding space into three zones: duplicate (skip), same-topic (escalate to the LLM judge — cosine similarity alone cannot tell "deploys on Fridays" from "no longer deploys on Fridays"; they embed close), and unrelated (store). Superseding rather than deleting preserves history: if the resolution was wrong, the evidence still exists. The store and encoder at the top repeat lesson 3's fixture so this notebook stands alone, and the in-memory demo walks all three zones in order.
Resolution optionWhen it's rightRisk
Update in place (overwrite)Pure corrections of transient values where history is worthlessDestroys evidence; wrong for anything ambiguous
Version both, prefer newer (default)Preference/state changes over time — the usual caseRecall must consistently pick the winner
Ask the userHigh-stakes facts (billing, permissions, contact info)Interrupts; save it for what matters
Expire/decayFacts with natural shelf life ("working on the Q3 launch")Choosing honest TTLs is guesswork
Hiring signal
An agent with persistent long-term memory — including conflict resolution between contradicting facts — is a specifically-cited high-value portfolio project in 2026 hiring guides (the other half, defense against memory-injection attacks, is Lesson 5). The part reviewers actually inspect is this lesson's write-path gauntlet: a resolution-options table like the one above, a superseded_by chain they can query, and a demo where a fact changes and the agent visibly prefers the newer version. Hiring managers look at GitHub before the résumé, and 2–3 deep, evaluated projects beat a pile of shallow demos — Lab 04 packages exactly this.
Delete is the lifecycle's third verb
Everything above versions rather than deletes — by design, superseding preserves the evidence a wrong resolution needs. But "never truly delete" collides with a real requirement: GDPR-style right-to-erasure means a user (or a legal request) can demand a fact be actually gone, not superseded-and-retained. Treat these as two different operations with two different triggers: supersede is a business-logic event (a fact changed) and stays the default; hard delete is a compliance event (this specific data must not exist anymore) and must cascade — the row, its embedding, any log line that echoed the fact verbatim, and any backup that hasn't rolled off retention. A memory system that can version but can't truly erase isn't finished; Lesson 5 picks this up as a security and retention concern, not just a data-modeling one.
Spot the bug
Two overlapping sessions for the same user run concurrently against the same MemoryStore. Both extract the candidate fact "user deploys on Fridays" within the same second and both call write_fact. Walk through what happens, and name the bug.

Two ways to trigger the write: explicit tool vs background job

This lesson's write path runs as a background extraction job: at session end, a forced tool call distills the whole transcript into candidates, uniformly, whether or not the model itself noticed anything worth remembering. There's a second shape worth knowing for interviews: an explicit memory tool the model calls during the session — "I should remember this" becomes a tool_use block the moment the model decides it matters, the same pattern a client-side memory tool exposes as a directory of files the model reads and writes with ordinary file operations. The two aren't just implementation variants; they trade different things.

Write triggerWhat you gainWhat you risk
Explicit tool (model calls it mid-session)Agency and visibility — the write is a plain tool_use block in the trace, timed exactly when the model judged something durable; no separate extraction pass or session-end latencyCoverage — a fact the model doesn't think to flag never gets written; quality depends on the model's in-the-moment judgment, which is inconsistent across sessions and models
Background extraction job (this lesson)Coverage and consistency — every session gets the same uniform pass, independent of whether the model happened to notice anything; easy to route through a single write-path gauntletDelay and opacity — nothing is written until the job runs, and it's a second LLM call reasoning about a transcript it didn't generate live, with less context than the model had in the moment

Production systems increasingly run both: an explicit tool for high-confidence, in-the-moment saves the model is confident about, and a background extraction pass as a safety net that catches what the model didn't think to flag. Whichever you pick, the write-path gauntlet from this lesson — provenance screen, dedupe, contradiction check — applies identically; the trigger changes when a candidate is proposed, not what happens to it once it is.

The read path

At session start (or before a task), score every active memory against the current context and inject only the top few. Pure embedding relevance isn't enough: a highly similar but two-year-old fact may be stale, and a modestly similar but critical constraint ("never email the client directly") must surface anyway. The standard recipe is a weighted blend of relevance (embedding similarity), recency (exponential decay), and importance (assigned at write time) — the scoring popularized by the generative-agents line of work.

read path: blended recall scoring, stingy top-k
# Colab cell 2 — run cell 1 first (it defines store, encoder, and the
# demo's three writes — recall below draws from that store).
W_RELEVANCE, W_RECENCY, W_IMPORTANCE = 0.60, 0.25, 0.15
HALF_LIFE_DAYS = 30.0

def recall(store: MemoryStore, query_text: str, k: int = 5,
           min_score: float = 0.35) -> list[dict]:
    q = encoder.encode(query_text, normalize_embeddings=True)
    now = time.time()
    scored = []
    for mem in store.all_active():
        relevance = float(q @ mem["vec"])                     # [-1, 1]
        age_days = (now - mem["created_at"]) / 86_400
        recency = 0.5 ** (age_days / HALF_LIFE_DAYS)          # (0, 1]
        score = (W_RELEVANCE * relevance
                 + W_RECENCY * recency
                 + W_IMPORTANCE * mem["importance"])
        scored.append((score, mem))
    scored.sort(key=lambda pair: pair[0], reverse=True)
    return [m for s, m in scored[:k] if s >= min_score]       # floor matters

# injected via assemble_window() from Lesson 1 — fenced, labeled untrusted
memories = [m["fact"] for m in recall(store, "plan this week's production deploy")]
print(memories)   # the Wednesday fact wins; the superseded Friday fact never appears
Two safety valves beyond the blend: a hard top-k cap (five facts, not fifty) and a minimum-score floor — if nothing clears the bar, inject nothing. An empty memory block is strictly better than a misleading one. Tune the weights against your Lab 04 demo script, and log every recall decision: 'why did the agent bring that up?' should always be answerable from logs.

Two read-path shapes: injection vs retrieval-as-a-tool

This lesson's recall() runs at session start and injects the result into the system prompt — the model never has to ask for its own memories, they're just there. The alternative is retrieval-as-a-tool: expose a search_memory(query) tool and let the model call it when it judges memory might help, the same shape as any other retrieval tool from Module 3. The choice is really about scale and certainty, not correctness.

Read shapeWhen it winsCost
Injection at session start (this lesson)Small, cheap-to-score memory sets per user (tens to low hundreds of facts) where "probably relevant to any task" is a safe betPays the retrieval cost on every session even when memory turns out irrelevant that turn; doesn't scale to memory stores too large to score cheaply up front
Retrieval-as-a-toolLarge or fast-growing memory stores where scoring everything up front is expensive, or where relevance genuinely depends on the specific task at handCoverage risk — a model that doesn't think to search never recalls anything; adds a round-trip's latency exactly when it's used

The same relevance/recency/importance scoring from this lesson applies either way — injection scores everything and takes the top-k up front, retrieval-as-a-tool scores against the query the model actually issues. A hybrid is common in practice: inject a handful of the highest-importance standing facts (the procedural "always lints before committing" kind), and expose the rest via a tool for anything more specific.

Context poisoning: the self-inflicted wound
Context poisoning is bad content in the window steering generation — and over-eager recall is its most common self-inflicted form. Every recalled memory arrives with the implicit authority of "known background fact"; an irrelevant, stale, or wrong memory doesn't just waste tokens, it actively tilts answers. Symptoms: the agent keeps bringing up an old project, applies last month's constraint to this month's task, addresses the user by a stale detail. Treat recall like seasoning — the dish should work with none.

Whiteboard drills

Check yourself
Drill: "Why not just let the model call a save_memory tool whenever it wants, instead of this whole background extraction pipeline?"
Check yourself
Drill: "Your memory store just grew from 200 facts per user to 200,000 total across all users. What changes about your read path?"
Key takeaways
  • Write path gauntlet: extract → provenance screen → dedupe (≥0.90 sim) → contradiction check (same-topic zone + LLM judge) → store.
  • Contradictions: version both with timestamps, supersede the old, prefer newer at recall, flag the conflict — overwrite only trivia, ask only for high-stakes facts.
  • Recall score = 0.6·relevance + 0.25·recency (exponential decay) + 0.15·importance — then a stingy top-k AND a minimum-score floor.
  • Nothing clearing the bar → inject nothing. Empty beats misleading.
  • Over-eager recall is self-inflicted context poisoning; log every recall decision.
  • Write triggers are a spectrum: an explicit memory tool trades coverage for agency and visibility; a background extraction job trades agency for uniform coverage — production systems often run both through the same gauntlet.
  • Read shapes are a spectrum too: inject for small, cheap-to-score memory sets; retrieve-as-a-tool once scale or task-specificity makes scoring everything upfront wasteful.
  • Supersede is the default for contradictions; hard delete is a separate, compliance-driven operation that must cascade to embeddings, logs, and backups — versioning isn't a substitute for actual erasure.