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
- Extract candidate facts from the session (Lesson 3's forced structured call).
- 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.
- Deduplicate: embedding similarity against existing memories. Near-identical → skip (optionally refresh the timestamp).
- 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.
- Store with provenance, timestamp, importance.
# 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}))| Resolution option | When it's right | Risk |
|---|---|---|
| Update in place (overwrite) | Pure corrections of transient values where history is worthless | Destroys evidence; wrong for anything ambiguous |
| Version both, prefer newer (default) | Preference/state changes over time — the usual case | Recall must consistently pick the winner |
| Ask the user | High-stakes facts (billing, permissions, contact info) | Interrupts; save it for what matters |
| Expire/decay | Facts with natural shelf life ("working on the Q3 launch") | Choosing honest TTLs is guesswork |
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.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 trigger | What you gain | What 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 latency | Coverage — 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 gauntlet | Delay 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.
# 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 appearsTwo 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 shape | When it wins | Cost |
|---|---|---|
| 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 bet | Pays 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-tool | Large or fast-growing memory stores where scoring everything up front is expensive, or where relevance genuinely depends on the specific task at hand | Coverage 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.
Whiteboard drills
save_memory tool whenever it wants, instead of this whole background extraction pipeline?"- ▸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.