Skip to content

15 min read

Write, retrieval and use are three failures with three fixes

On this page

An escalation arrives: "I told it three weeks ago that we always report net of tax. It just produced the board pack in gross." The first suggestion in an incident channel is to raise k from 3 to 8 so the preference has a better chance of being retrieved, and someone else proposes adding a reranker, a second-pass model that reads each candidate alongside the query and rescores it rather than trusting the vector distance. Both are changes to the retrieval stage, and neither is supported by anything anyone has looked at, because the only evidence on the table is the wrong output.

That reflex is worth naming before it is corrected, since it is not carelessness. Retrieval is the stage with knobs on it. It has a threshold, a k, a scoring function and a ranker, all of which can be changed this afternoon, and a wrong answer that involves a missing memory does look like a retrieval problem. The write policy, by contrast, has no dial — it has a prompt and a gate, and a fact that was never written leaves no trace anywhere in the retrieval logs at all. So the stage that is easiest to adjust becomes the stage that gets blamed, and the stage that leaves no evidence becomes the stage nobody checks.

The ten-second check nobody ran

The cheapest check available takes about ten seconds and asks whether the record exists.

sql
SELECT id, kind, content, occurred_at, source_session, confidence
FROM memory_records
WHERE workspace_id = 'ws_8841'
  AND content ILIKE '%tax%'
ORDER BY occurred_at DESC;

-- 0 rows

The record does not exist. Three weeks of retrieval tuning would have improved nothing, because there was nothing to retrieve.

Both proposals from the channel are now checkable against that result, and both fail for the same reason at different points. Raising k from 3 to 8 changes how many of the ranked candidates are injected; with no matching record anywhere in the store, the ranked candidate list does not contain the preference at any k, so the change injects five more irrelevant records and nothing else. Adding a reranker changes the order of the candidates the first pass returned; a reranker cannot promote a record that was never a candidate. Neither proposal is silly, and neither could have worked here, which is what makes the ten-second query worth running before the afternoon of tuning rather than after it.

Pulling the session from three weeks ago shows why the record is absent: the user phrased the instruction as a question — "can you do these net of tax going forward?" — and the extractor's instructions told it to store user assertions, so it classified an interrogative sentence as a query rather than a preference and wrote nothing. The fix is one line in the extractor prompt plus three cases added to the write suite, and it lives nowhere near the ranker.

Three stages and one output

Memory is a three-stage pipeline whose stages fail independently and present identically. A fact has to be extracted from a conversation and written; a later query has to surface it among everything else in the store; and the model has to act on it once it is in the context.

That this is hard to debug follows from counting, rather than from anything subtle. The observation available at the end is one bit: the task passed or it did not. The question being asked has at least three answers — write, retrieval, use. No function of one bit can name one of three things, because a bit distinguishes two cases and three cases need more than two labels. So no amount of staring at pass rates will attribute a failure, and the only remedy is to widen the observation until it carries at least as much information as the question needs. What the identity licenses is a decision made before any work starts: instrument the hops, and stop proposing analyses of the outcome column. Its residual is that widening the observation is necessary and not sufficient — adding fields that all move together still leaves the stages entangled, which is why the fields below are chosen to be produced by different subsystems.

The same shape shows up elsewhere, and one comparison is close enough to be worth offering as an analogy. Debugging this is like debugging a slow response in a service with a cache, a database and an application tier, where the only symptom visible from outside is latency: three components, one number, and no way to divide it up without timing each hop. The analogy holds for the structure and breaks on the timescale. A slow request's three hops all happen inside one request, so a tracing library can time them together; memory's three stages are separated by weeks, and the write that failed here happened in a session that ended long before the request that exposed it. The trace has to be assembled across sessions rather than within one, which is why it belongs to the harness rather than to a middleware.

What that means concretely is one structured record per evaluated task, written by the harness rather than reconstructed afterwards from application logs. The distinction is not fussiness about tooling. Application logs are emitted per service and per request, and the three stages here belong to three requests in two different sessions weeks apart, so joining them after the fact needs a correlation identifier that the earlier session had no reason to carry. The harness is the only component that knows all three requests belong to one task, because it is the thing that issued them, and a fact known only at issue time cannot be recovered downstream.

json
{
  "task_id": "t_0417",
  "arm": "memory_on",
  "writes": [
    {"session": "s_1", "record_id": "m_9931", "kind": "preference",
     "content": "revenue reported net of tax", "gate": "accepted",
     "extractor_confidence": 0.81, "indexed_at": "2026-03-02T09:14:11Z"}
  ],
  "retrieval": {
    "query": "Q3 revenue by region",
    "filters": {"workspace_id": "ws_8841", "kind": ["preference", "fact"]},
    "k": 3,
    "candidates": [
      {"rank": 1, "record_id": "m_4402", "score": 0.71, "injected": true,
       "content": "prefers bar charts over tables"},
      {"rank": 2, "record_id": "m_7715", "score": 0.68, "injected": true,
       "content": "EMEA accounts are their focus"},
      {"rank": 3, "record_id": "m_2210", "score": 0.66, "injected": true,
       "content": "fiscal year starts in April"},
      {"rank": 7, "record_id": "m_9931", "score": 0.54, "injected": false,
       "content": "revenue reported net of tax"}
    ]
  },
  "use": {"cited_record_ids": ["m_2210"], "counterfactual_flip": false},
  "success": false
}

The single most important field in that record is rank: 7 on a record that exists, and it is worth spelling out what that one number rules in and out. The record appears in the candidate list at all, so the extractor wrote it and the gate accepted it: the write stage worked. It came back from the index under the query's filters, so the index holds it and the namespace and kind filters admitted it: the retrieval index and its filters worked. And it sits below three records the query matched more strongly, which is a statement about the scoring function and the query embedding and about nothing else. One field converts "memory did not work" into a defect with an owner, and the defect is testable without touching the extractor.

Logging candidates below the injection cut-off is what makes this visible, and the reason is that the injected set is by definition the top k. A record at rank 7 with k=3 never enters it. So harnesses that log only the injected set produce byte-identical output for a record that ranked seventh and a record that was never written, and cannot distinguish the two cases at all.

Deciding which stage owns a failure

With that record in hand, attribution is mechanical.

Evidence in the traceClassWhere the fix lives
No matching record in the storeWriteExtractor coverage, gate conditions, confidence threshold
Record exists, absent from candidates entirelyRetrieval — index or filterIndexing lag, namespace or kind filter, deleted-flag handling
Record exists, present at rank > kRetrieval — rankingQuery construction, embedding model, k, reranker
Record injected, not cited, counterfactual flat — removing it changes nothingUsePrompt position, instruction precedence, context length
Record injected and cited, answer still wrongDownstreamTool call, SQL generation, formatting — not memory at all

Some tasks fail at more than one stage, and the table assigns each one to the earliest stage that failed. The convention is not arbitrary. A stage can only be shown to have failed if it was given the chance to succeed, and a task whose record was never written never presented the ranker with anything to rank. Counting it in the retrieval bucket as well would double-count it and inflate the apparent size of a problem that has not been demonstrated, and a bucket whose count is not a count of demonstrated failures cannot be used to decide where to spend. When the earliest failure is fixed, those tasks return to the suite and some of them fail again further down — which is the point of the convention rather than a flaw in it, since the second failure is now a demonstrated one.

Write failures deserve their own small labelled set once you know how large a share of the total they are. Take 120 real sessions, have two people annotate what should have been written from each — which is slower than it sounds, because annotators disagree about whether an offhand remark is a durable preference — and score the extractor against that as precision, the share of what it wrote that should have been written, and recall, the share of what should have been written that it wrote. This extractor scored 0.91 precision and 0.58 recall.

That pair describes a shape, and reading it is worth doing slowly. Precision near nine in ten says that when the extractor writes something, it is almost always something a person would also have written down. Recall near six in ten says that of everything a person would have written down, the extractor caught under two-thirds and missed the rest — two facts in five, gone with no record anywhere that they were ever proposed. The gate is conservative: what it stores is usually right and there is not enough of it.

The obvious repair is loosening the gate, and the cost of doing so lands somewhere the write-suite numbers cannot see. Recall and precision pull in opposite directions, so admitting the missed facts also admits candidates the gate was right to reject. Every one of those is another record in the store, and the previous chapter established what an extra record does: it competes for a place in the top k against every query it lands near. Raising recall therefore grows the haystack that the ranking misses are already struggling with, which means a write-policy change is also a read-policy change, and the two have to be measured together.

The same trace record is worth emitting in production at a low sampling rate, say one task in fifty, with content fields hashed or truncated. Without it, every production escalation restarts the ten-second store query from scratch and the ones that arrive a month later cannot be diagnosed at all, because the store has moved on. The volume is modest — at 1.2 million tasks a month, a 2% sample is 24,000 records of a few kilobytes each — and it is the difference between an escalation that resolves in an afternoon and one that becomes a standing suspicion that memory is flaky.

Establishing that the model used what it was given

The use stage is the hardest of the three to measure, because "the model took this record into account" is not directly observable. Three methods are available and they trade differently.

MethodHow it worksCost per taskFailure mode
CitationPrompt requires the model to list the memory ids it relied on~40 output tokensModels cite records they ignored, and omit ones they used
Counterfactual re-runRe-run with that one record removed, compare outputsOne extra full task runDoubles eval cost; noisy when outputs vary
GraderA model reads answer plus injected records, judges incorporationOne short judge callAdds the judge variance measured in chapter 2

Counterfactual re-running is the closest thing to ground truth available, and the argument for it is short. The model's output is produced from its context, and the two runs' contexts differ by exactly one record, so anything that differs between the outputs has to be traceable to that record. Removing a record and getting the same output therefore means the record was not doing any work.

The residual on that argument is what the failure-mode column is reporting. A model's output is a sample from a distribution rather than a function of the context, so two runs can differ with the record left in place. A difference is therefore evidence that the record mattered only up to the sampling variance the second chapter measured, and a single counterfactual run on a high-temperature task can mislead in both directions.

Citation is cheap and the least trustworthy on its own; measured against counterfactual re-runs on 200 tasks, citations agreed with the counterfactual on 78% of them, with most disagreements being records cited but demonstrably unused. The practical arrangement is to log citations on every task and run counterfactuals only on the failures.

Counterfactual re-runs also produce a result worth watching in the other direction: records whose removal changes the output on tasks that succeeded. Twenty-two of the passing tasks flipped to failing when one injected record was removed, which confirms those records were load-bearing, and nine passing tasks flipped to passing differently — same verdict, different report — which is the sort of instability that shows up later as user-visible inconsistency.

A use failure is not always a defect. When the store holds "revenue net of tax" and the user's current request says "gross revenue, calendar quarters", the correct behaviour is to ignore the stored preference, and a trace showing an injected-but-uncited record on a successful task is the system working. Attribution therefore needs one more judgement on top of the mechanical classification: should this record have been used at all. Answering it requires reading traces, which is why the human review loop described in the Label Studio explainer on evaluating agent memory is worth building as a routine rather than an incident response — a weekly hour spent on twenty sampled traces catches the cases where the taxonomy above assigns a class confidently and wrongly.

The failures attribution cannot see

Attribution explains individual failures and says nothing about whether the feature is worth having. A memory system can be perfectly diagnosed, with every write, retrieval and use failure catalogued and fixed, and still fail the comparison from the first chapter, because being right more often about what to retrieve does not establish that retrieving anything helps. The two measurements answer different questions and the diagnostic one is seductive because it always produces work to do.

It also has a bias that follows from how the classification is run rather than from how it is designed. The classification is applied to tasks that failed, so the set it operates on is conditioned on failure. Now consider retrievals that are irrelevant, get injected, get ignored by the model, and cost tokens on every single task. Its output is a correct answer. Conditioning on failure excludes it by construction, and no amount of care in the taxonomy will recover something the selection removed before the taxonomy ran.

This produces the same shape the first chapter warned about, one level down. The bucket counts can hold perfectly steady while the volume of wasted injection doubles, because wasted injection never produces a failing task to be counted. Seeing it at all requires measuring on the passing tasks: on this system the median retrieving task injected 3.2 records and the counterfactual analysis found 1.4 of them load-bearing, so roughly half the injected context was paid for and unused.

That number is the seam between diagnosis and the outcome measurement. Failures attributed one at a time never add up to a verdict on the feature, and the whole cost of the feature is not visible in them either. The shape that a net-negative memory system makes in the aggregate numbers — success flat, corrections rising, tokens and latency up on every task that retrieves — is visible only when you stop looking at individual traces and look at the distribution of per-task differences.

Memory — evaluation — read the current version at https://daviddominguez.dev/learn/en/memory-evaluation/write-retrieval-and-use-are-three-failures-with-three-fixes

Back to top