AI agents

Testing a Memory Write Policy: Replay, Fixtures and Drift Checks

Antonio Evans8 min read

A flat technical diagram shows conversation cards passing through a policy box into a memory table with insert, update and delete rows highlighted, plus a side-by-side snapshot diff panel.
Key points

Key points

  1. 1A memory write policy is only trustworthy when a replay harness can show, turn by turn, which rows it inserts, updates and deletes.
  2. 2A small fixture set of hand-built conversations with fixed metadata catches more real drift than a large scrape of production logs.
  3. 3Storing expected memory rows as snapshots turns every policy change into a readable diff, so intent is reviewed rather than guessed at.
  4. 4Run the replay on every policy change and on every model swap, because a different extractor will change what gets written even when your rules are untouched.

Why the write side needs its own test suite

Most teams test the read path: does the agent retrieve the right memory and answer correctly? Far fewer test the write path, which is where permanent damage happens. A bad insert is invisible until retrieval surfaces a stale or contradictory fact weeks later. The memory layer is what separates a stateless model call from an agent that carries state, persistence and selection across sessions, and that layer is a piece of software that needs regression tests like any other per Mem0's architecture guide.

The write policy is the decision function that answers three questions per turn: is there anything worth remembering, is it new, and does it contradict something already stored? Long-running designs explicitly separate these operations. MemGPT's runtime exposes store, retrieve, summarize and update as explicit primitives, with paging thresholds that trigger eviction and archiving as described in the paper summary. Each primitive is a candidate for a test assertion: a store becomes an insert, an update becomes a row mutation, an eviction becomes a delete or a status change.

The failure modes are also specific. Append-only storage lets a new observation contradict an old one while both remain retrievable, which researchers call memory conflict, alongside fragmentation when evidence about one user spreads across many tiny records per the Infini Memory paper. A replay test with expected rows catches exactly these: a duplicate row is fragmentation, two live rows for the same fact are conflict.

There is a cost argument too. A store-backed memory layer that retrieves only relevant memories is reported to cut token cost by up to 90% versus replaying raw history, based on Mem0 benchmarks cited in Valkey's implementation writeup. But that saving assumes the stored rows are correct. One bad compaction rule that overwrites a preference with a stale value costs more than the tokens it saved, and nobody notices until a user does.

Designing the fixture set

A fixture is a conversation plus the metadata the policy will see: user id, tenant, session id, timestamp, channel, and any scope tags your policy reads. Keep it as plain data in the repo, not in a database, so a reviewer can read it in a pull request.

fixtures/
  pref_update/
    meta.json          # user, tenant, session, tz
    turns.jsonl        # one turn per line
    expected.json      # snapshot of memory rows after replay
  correction/
  contradiction/
  pii_rejection/
  chit_chat_no_write/

What to include matters more than how much. Six fixture families cover most write-policy behavior:

Fixture familyWhat it provesExample turn
First writeInsert with correct scope and source"I always deploy on Thursdays"
UpdateSame fact, new value, one row not two"Actually make that Fridays"
ContradictionOld row retired or superseded, not duplicated"I moved to Berlin" after "I live in Lisbon"
RejectionPolicy refuses to store"My card number is 4111..."
No-opNothing worth remembering"Thanks, that worked"
Scope isolationTenant or user separation holdsSame fact stated by two users

Aim for roughly 8 to 15 fixtures with 3 to 8 turns each. That is small enough to run in seconds and large enough that a careless change to a similarity threshold or a dedup rule will break something.

Be deliberate about turn metadata. If your policy uses recency to decide which of two conflicting facts wins, put explicit timestamps in the fixture rather than relying on wall-clock time during replay. Fix the clock in the harness. Otherwise the same fixture passes on Monday and fails on Friday, and you will spend an afternoon debugging the calendar.

Write the expected rows by hand for the first pass. Do not generate them from the current policy, because whatever bug exists will be baked into the snapshot. Hand-writing five fixtures forces you to state what the policy should do, which is the whole point of the exercise. As one engineering guide puts it, memory is a pipeline covering what to remember, how to inject it, and how to merge session notes without stale data, and evaluation and safety is an explicit layer of that pipeline per this framework-agnostic memory guide.

Building the replay harness

The harness feeds turns one at a time, in order, into the real write policy against a throwaway store, then dumps the resulting rows. The policy function should take a single turn and the current memory state and return a list of operations. That signature is what makes it testable.

def replay(fixture, policy, store):
    meta = fixture["meta"]           # frozen clock, user, tenant
    ops = []
    for turn in fixture["turns"]:
        result = policy.decide(turn=turn, meta=meta, memory=store.snapshot())
        for op in result.operations:
            store.apply(op)          # insert | update | delete
            ops.append(op)
    return ops, store.snapshot()

Use an in-memory store that implements the same interface as production: insert, update, delete, and a scoped read. If your production store is a vector database with metadata filters, the fake should enforce the same filter semantics, or your scope-isolation fixture will pass for the wrong reason. A practical rule: for anything a user might later retrieve on, memory should be a durable record chosen deliberately, not a transcript as JetBrains' guide frames it.

Determinism is the hard part. If your policy calls an LLM to decide what to store, that call is a source of nondeterminism. Three options, in order of preference for testing:

  1. Record and replay model responses. Capture the extractor output once per fixture turn, commit it as a JSON file, and have the harness return the recorded response instead of calling the model. The policy logic is then fully deterministic.
  2. Stub the extractor. Replace the model call with a hand-written extraction for each fixture turn. Fastest, but it stops testing the prompt.
  3. Allow limited live calls with a temperature of zero and a strict response schema. Keep this for a nightly job, not the pull-request gate.

A mix works well: recorded responses in CI, a nightly run against live models to catch extractor drift. When you need a live model for the nightly job, routing text and embedding calls through one OpenAI-compatible endpoint keeps the harness code short; Quanta Inference (https://quantaintelligence.ai/inference) exposes text, embedding and other model types behind a single key, which means the same harness can swap extractor models without touching its transport layer.

Record the operation log alongside the final rows. Two policies can produce the same final state through very different paths, and a delete-then-insert is not the same as an update when history matters. Assert on the final rows by default and on the operation log for fixtures where the path is the point, such as the contradiction case.

Snapshots and readable diffs

Store expected.json as a normalized list of rows sorted by a stable key, with volatile fields excluded: created timestamps, row ids, and embedding vectors. Compare on meaning, not on bytes.

[
  {
    "scope": "user:42",
    "kind": "preference",
    "key": "deploy_day",
    "value": "Friday",
    "status": "active",
    "source_turn": 3
  }
]

Two rules keep snapshots useful. First, drop anything the policy is allowed to change freely (vector values, TTL counters) from the snapshot, or every run will fail. Second, keep a source_turn field so a reviewer can see which message produced the row. When a diff appears, that field answers the first question anyone asks.

Treat a snapshot failure as a review prompt, not an automatic pass. The workflow is: the test fails, you read the diff, you decide whether the new rows are better or worse, and only then do you update the snapshot. If you regenerate snapshots without reading, you have built a test that documents behavior rather than constraining it. A useful habit is to require snapshot updates in a separate commit with a one-line justification, so the decision is visible in the history.

Normalize aggressively for order and format, not for content. Sort keys, sort rows by scope and key, round any numeric scores to a fixed precision, and strip whitespace. Never strip a value field, even if it looks trivial: the trivial field is usually the one that changes when someone edits the extraction prompt.

Drift checks over time

Snapshots catch changes you made. Drift checks catch changes you did not make: a model upstream of your policy gets updated, an embedding model is swapped, a tokenizer changes, and the extractor starts emitting slightly different facts. The write policy is unchanged and the rows are not.

Two schedules handle this. On pull requests, run the full replay with recorded responses. On a nightly or weekly schedule, run the same fixtures against the live extractor and compare its output to the recorded responses before the policy even runs. That truncates the debugging problem: you learn whether the drift is in extraction or in policy logic, which are different teams and different fixes.

Measure drift as a number, not a feeling. For each fixture, compute the share of turns where the extracted candidate facts differ from the recording (exact match on key and normalized value), then report a single percentage across the suite. If extraction agreement drops from 100% to, say, 80% overnight, you have a concrete trigger to investigate. The threshold is yours to set per fixture family, and rejection fixtures should be held to a stricter bar, since a false positive there is a compliance problem.

Also check the retrieval side after write changes. A fixture that writes the correct row can still break the product if the new row no longer matches a query the agent relies on, which is the classic evidence-aggregation problem where related observations end up scattered across records per the Infini Memory discussion of fragmentation. Keep a small set of retrieval assertions (query plus expected top row) in the same suite, so a write change that breaks recall fails in the same run.

Decision rule: if a proposed policy change improves a fixture family you care about but breaks any rejection or scope-isolation fixture, it does not merge. Correctness on refusals and isolation outranks coverage of new memory.

What to assert beyond the rows

Rows are necessary but not sufficient. Add these assertions to the harness, each cheap to compute:

  • Idempotence. Replay the same turn twice. The second pass should produce no new rows or at most a bumped counter. Duplicate rows from a retried request are one of the most common production bugs in memory systems.
  • Scope isolation. A fact written under user A must never appear in a read scoped to user B, even when the text is identical.
  • Deletion completeness. When a user requests deletion, every row tagged with that user goes, including summarized or consolidated rows. Tie this to your retention rules.
  • Latency budget. Time the replay. A policy that makes three sequential model calls per turn may be correct and too slow.
  • Token cost per turn. Estimate tokens for the extraction prompt and the candidate facts. It is the number that decides whether you can afford to run the extractor on every message.

On that last point, write-level tests are also where you find waste. If chit-chat fixtures trigger an extraction call that returns nothing, that is a signal to add a cheap pre-filter. A keyword or embedding-similarity screen before the extractor can cut calls substantially with no change to stored rows, and the replay proves it.

Keep the suite honest by pruning. Every quarter, delete fixtures that no longer reflect real usage and add ones drawn from recent incidents. A thousand fixtures nobody reads is worse than twelve that map to bugs you actually shipped. And keep the suite runnable by a single command with no network access, so it can sit in the pull-request gate where it does the most good.

FAQ

FAQ

How many fixtures do I need before the suite is useful?

Around eight to fifteen hand-built conversations covering first write, update, contradiction, rejection, no-op and scope isolation are enough to catch the common regressions. The goal is coverage of policy decisions, not volume of data. Grow the set by adding a fixture every time a production incident traces back to a wrong memory row.

How do I test a write policy that calls an LLM to decide what to store?

Record the model's responses once per fixture turn, commit them as JSON, and have the harness replay those recordings in CI. That makes the policy logic deterministic while still exercising the prompt format. Run a separate scheduled job against the live model to detect extraction drift, and report the share of turns where the live output differs from the recording.

Should the snapshot include embeddings and timestamps?

No. Exclude embedding vectors, row ids and creation timestamps from the snapshot, because they change for reasons unrelated to policy behavior. Do keep a source_turn field and the scope fields, since those are exactly what a reviewer needs when reading a diff and deciding whether a change is intended.

What is the difference between a snapshot test and a drift check?

A snapshot test fails when your code changes the rows, and you fix it by reviewing the diff and updating the expected file. A drift check fails when something outside your code changes the rows, such as an updated extractor model, and you fix it by deciding whether to adapt the policy or pin the model. Run the first on every pull request and the second on a schedule.

How do I keep the replay harness from slowing down every commit?

Run the recorded-response suite in the pull-request gate, where it should finish in seconds, and move live-model checks, retrieval assertions and latency measurements to a nightly job. If the recorded suite exceeds a couple of minutes, split it by fixture family and run families in parallel, since each one is independent.

Sources

Sources

  1. AI Agent Memory: Complete Guide & Architecture - Mem0
  2. MemGPT: Hierarchical Memory Management - Emergent Mind
  3. Maintainable Topic Documents for Long-Term LLM Agent ...
  4. Reduce Token Cost for LLMs: AI Agent Memory with ...
  5. Agent Memory Systems: A Complete Engineering Guide
  6. What Is Long-Term Memory in AI Agents? A Practical Guide
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles