Skip to content

16 min read

A contradiction is only visible when memories share a key

On this page

Everything the previous chapter described — source trust, two timestamps, a confidence score, a declared cardinality — operates on a pair of records. It says which of two rows wins. It has nothing to say about how the two rows came to be standing next to each other, and in a store of free-text sentences they never do.

That step is matching: given an incoming observation and a store of existing records, return the record that occupies the same slot, or nothing. A slot here is the pairing of a subject with a property of that subject, so that two records fill the same slot when they are answering the same question about the same person. Matching is where the design decisions in conflict handling actually live, because it is the only stage whose errors are invisible from both sides — a match that never happened leaves no trace, and a match that should not have happened leaves a resolution that looks entirely normal.

Three ways to find the row you would contradict

Three mechanisms are in general use, and production systems usually run more than one.

Where the store is schematised, the key is structural: subject plus attribute, resolved by an index lookup. The candidate arrives already parsed into subject_id, attribute and value by the extraction step, and finding the incumbent is a query.

sql
select id, value, source, observed_at, confidence
from memory_fact
where subject_id = $1
  and attribute  = $2
  and superseded_by is null;

That is exact, costs about 0.4 ms on an indexed table, and never matches the wrong row. The exactness is not a property of the database; it is a property of having agreed in advance what the slots are, so that two records fill the same slot when and only when they carry the same attribute name. Its weakness sits one step earlier, in the agreeing. The extractor has to map an utterance onto an attribute name from a fixed vocabulary, and every fact whose attribute is not in the vocabulary either gets dropped or lands in a catch-all bucket where the key stops discriminating — a catch-all matches everything in it against everything else, which is the same as matching nothing. A schema of thirty attributes covers the facts a product actually reasons about and leaves a long tail of everything else uncovered.

Where the store is free text, matching is nearest-neighbour search over embeddings with a similarity floor.

sql
select id, text, 1 - (embedding <=> $2) as cosine
from memory
where subject_id = $1
order by embedding <=> $2
limit 5;

The floor is the whole design, and it is a recall-precision dial — recall being the share of genuine conflicts the matcher manages to find, precision the share of the pairs it flags that are genuine — with no setting that makes both acceptable. Measured on 500 hand-labelled pairs from that same store, using text-embedding-3-large:

Cosine floorRecall of true conflictsPrecisionEffect at 432k writes/day
0.700.940.31~104,000 false pairs sent to resolution
0.800.810.66~24,000 false pairs
0.860.620.88~3,300 false pairs, ~18,000 conflicts missed
0.920.290.97near-duplicates only

The right-hand column is arithmetic on the first three rather than a separate measurement, and reconstructing one entry shows how to read the rest. With genuine conflicts arriving on about eleven in every hundred of the 432,000 daily writes, a recall of 0.62 leaves thirty-eight in every hundred of them unfound, which is the ~18,000 missed conflicts on the third row. The false-pair figures come from the same two numbers run through the precision column, and they are order-of-magnitude rather than exact.

Read the recall and precision columns as gaps rather than as levels, because the levels depend on this store and the gaps describe the dial. Raising the floor from 0.70 to 0.80 costs thirteen points of recall and buys thirty-five points of precision. Raising it again to 0.86 costs nineteen points and buys twenty-two. Raising it to 0.92 costs thirty-three points and buys nine. The trade is strongly in your favour on the first step, roughly even on the second, and badly against you on the third, so whatever else is true, the usable region of this dial is between the second and third rows and there is no reason to be outside it.

The shape of that curve comes from what embeddings encode, and two examples from the same store show why no setting of the dial escapes it. Cosine similarity measures topical relatedness, and contradiction is a topical relationship, so contradictory statements score high — but so do statements that merely share a subject. "User lives in Lisbon." and "User visited Lisbon in March." sit around 0.89, and they are not in conflict at all. Meanwhile "User is a backend engineer at Datadog." and "I start at Stripe on Monday." sit around 0.71, and they contradict directly.

Put those two pairs side by side and the floor is finished as an idea. To catch the Datadog pair the floor must sit below 0.71. Every floor below 0.71 also fires on the Lisbon pair at 0.89. The compatible pair scores higher than the contradictory one, so the ordering the threshold depends on is inverted for this pair, and no choice of a single number can separate them. What the dial adjusts is which errors you get, not how many.

The third mechanism spends a model call to ask the question directly: given this candidate and these five neighbours, which one, if any, occupies the same slot? A small model handles it well because the task is classification against a short list rather than open-ended reasoning.

python
SLOT_MATCH = """Candidate observation about user {subject}:
  "{candidate}"

Existing memories:
{numbered_neighbours}

Which existing memory, if any, describes the SAME property of this user as the
candidate — such that both cannot be simultaneously true unless the property
allows multiple values? Reply with the memory number, or NONE.
Reply with NONE if they are merely about related topics."""

The prompt is doing one thing the threshold cannot, and it is worth naming. A cosine score answers "how related are these", and the question that matters is "do these fill the same slot". The second question has a correct answer that a reader can check, and the first does not — which is why the last line of the prompt has to say so explicitly, since relatedness is exactly what a model will otherwise fall back on.

Run as a re-ranker — a second, more expensive pass that reorders and filters the candidates a cheap first pass proposed — over the top five vector neighbours with the floor dropped to 0.70, this recovered recall 0.91 at precision 0.84 on the same labelled set. That combination is outside the whole curve above: the best single-threshold row reaches 0.88 precision at 0.62 recall, and this reaches nearly the same precision with half again as much recall.

It costs roughly 320 ms and about $0.0003 per write with a small model, which at 432,000 writes a day is $130 a day and a latency addition that is tolerable only because the write path runs asynchronously after the turn has been answered. Doing it inline would put a third of a second onto every response.

The cascade, and where each stage gives up

Neither exactness nor coverage is available alone, so the working arrangement is a cascade: cheap and exact first, expensive and fuzzy for the tail.

Flowchart: Candidate write → Extractor mapped it to a known attribute?; Extractor mapped it to a known attribute? → Index lookup on subject + attribute (yes); Index lookup on subject + attribute → Conflict pair (hit); Index lookup on subject + attribute → Insert as first value (miss); Extractor mapped it to a known attribute? → Vector search, floor 0.70, top 5 neighbours (no); Vector search, floor 0.70, top 5 neighbours → Insert as first value (no neighbours); Vector search, floor 0.70, top 5 neighbours → Model: same slot? (candidates); Model: same slot? → Insert as first value (NONE); Model: same slot? → Conflict pair (match); Conflict pair → Resolution policy.

The ordering of the cascade is not a preference for cheap things first; it is forced by the error profiles. The exact path never produces a false match, so anything it decides is safe to accept without a second opinion, and running it first means the expensive path only ever sees candidates the schema could not place. Reversing the order would put the model call in front of facts whose slot is already known, paying a third of a second and an error rate to re-derive something an index lookup answers exactly. The general rule is that a stage with no false positives belongs above a stage with some, because the second stage can only add errors to what the first got right.

The exact path handles the thirty attributes the product depends on at 0.4 ms and zero error. The fuzzy path handles the rest at 320 ms and a measurable error rate in both directions, and it is where the design decisions live, because both errors are silent and they are not symmetric in cost.

A missed match inserts a second row for a slot that admits one value, and nothing complains. Follow that through: the store now holds two incompatible facts with equal standing, no resolution ever ran, and the first moment anybody could notice is a retrieval that happens to return the older one. So the failure surfaces at read time, in whichever conversation happens to retrieve the older one, which may be weeks later and will look like the agent being wrong rather than like a matcher having failed. This is the failure that produced the Berlin shipment, and it is the failure that dominates when the floor is set high — which is the setting most teams reach for, because a high floor produces few flagged pairs and few flagged pairs look like a clean store.

A false match sends the resolution policy a pair that was never in conflict, and the policy does what it was told: the newer value supersedes the older one. "User visited Lisbon in March." arrives, matches "User lives in Lisbon." at 0.89, and the home city record is retired in favour of a trip. Where the store updates rows in place, the old value is gone and there is no trace that anything happened. This is the failure that dominates when the floor is set low, and it is worse per occurrence, because a missed match leaves both truths available while a false match destroys one of them. That asymmetry is why the model call earns its cost: it converts a threshold guess into a question that has a right answer.

MechanismMatches onFalse negativeFalse positiveLatencyCost per write
Subject + attribute keyDeclared schema slotAnything outside the vocabularyEffectively none~0.4 ms~0
Vector floorTopical similarityParaphrase with low overlapRelated-but-compatible facts~9 ms~$0.000002
Model slot matchStated semanticsAmbiguous or implicit slotsOver-eager on near-topics~320 ms~$0.0003

The asynchrony that makes the model call affordable deserves a caveat, because it introduces a window of its own. If the write path runs after the turn has been answered, then between the utterance and the write there is an interval during which the store still holds the old value, and a second turn arriving inside that interval reads it. For a support conversation that window is invisible, because the current transcript carries the new fact anyway. For anything that fans out into parallel sessions sharing one store, it is not, and the resolution has to be forced synchronously or the sessions have to be prevented from racing.

The practical arrangement follows from the cost asymmetry rather than from a preference for schemas. The rule is to schematise the attributes whose conflicts hurt — identity, contact details, plan and entitlements, standing preferences, anything that drives an action — and leave the rest to the vector-plus-model path with a bias toward not matching. The tail is mostly facts nobody will act on, so an unreconciled duplicate there costs little; the schematised head is where a wrong value books a flight to the wrong city. The Field Guide to AI Memory makes the same split under the names write-time and read-time resolution, and it is worth reading for how it handles the ambiguous middle.

Every number in the two tables above came from one artefact, and building it is the first piece of work rather than the last. The labelled set was assembled by sampling 500 pairs from the store, weighted toward pairs with cosine between 0.60 and 0.95 so that the sample was not swamped by obviously unrelated rows, and asking two reviewers to mark each pair as same-slot or not.

That weighting is the part worth copying and the part worth being careful about. Most pairs of rows in a store are unrelated, so a uniform sample is almost entirely unrelated pairs, which are easy to label and settle nothing — the decisions all happen in the band where the threshold might plausibly be set. Sampling from that band gives you a set that discriminates between floors. What it also does is make the raw counts in the sample unrepresentative of the store, so the set is for comparing mechanisms and not for estimating how often conflicts occur.

Disagreements between the two reviewers ran at about 9%, mostly on facts where the slot itself was underspecified — whether "works remotely" and "based in Lisbon" describe the same property depends on a schema decision nobody had made. That residue is worth reading as a message about the schema rather than about the reviewers: a pair two careful humans cannot agree on is a pair no matcher can be expected to get right either, and the fix is upstream. Once the set exists, changing the floor, swapping the embedding model or adding the model call is a measurement rather than an argument, and the argument is what teams otherwise have instead. Without it there is no way to know that a matcher firing on 0.31% of writes is finding a small fraction of what is there, because the only number available describes the matcher and the number you want describes the store.

Keys are what give an attribute a cardinality

Chapter 1 leaned on cardinality as the property that decides whether a contradicting value is a correction or an addition. That property belongs to an attribute, and an attribute exists only where something named it. In a free-text store there are no attribute names, so there is no place to record that date_of_birth admits one value and active_project admits many, and consequently no way to distinguish the two situations at resolution time. Whatever the vector matcher returns is treated identically in both cases, because from where the code stands both cases are a pair of similar strings.

This is the sense in which keying comes first, and the dependency runs in one direction only. The extraction step's choice of vocabulary determines which facts have attributes; attributes carry cardinality; cardinality determines what conflict means. A team that skips the schema and reaches straight for embeddings has not chosen a simpler design — they have chosen one where the resolution policy of chapter 1 has nothing to consult and degrades to newest-wins on everything the matcher happens to fire on, which is the policy that shipped the unit to Berlin.

Adding attributes later is not particularly hard, since the extractor is a prompt and the store gains two columns. Backfilling the existing rows is the expensive part: 2.1 million free-text memories, a model call each to assign a slot, roughly $600 and eleven hours of batch time in the case above, plus a manual review of the 4% the model marked ambiguous. Two of those figures are worth reading together. The compute cost is the same per-call price as the slot matcher above, applied once per existing row, which is why it lands where it does. The manual review is four in every hundred of two million rows, which is a queue of tens of thousands of items and by far the larger cost, and it does not appear in the dollar figure at all. That number is worth knowing before choosing the free-text design, because it is the price of the migration you will probably run.

Where matching stops

Matching is not entity resolution. It assumes the subject is already known — that the subject_id on the candidate is right — and it only asks which property of that subject the observation is about. Deciding that the "Sarah" in this message is the same Sarah from a conversation last April is a separate problem with its own failure modes, and conflating the two produces a matcher that occasionally attaches one person's facts to another, which is the one error in this chapter that a user experiences as a privacy incident rather than as a mistake.

It is also distinct from the deduplication that 10.11 does at write time, and the distinction is not academic. Duplicate detection asks whether this observation adds anything; slot matching asks which existing record this observation is about. They use similar machinery and answer different questions, and a system that runs only the first will happily store a contradiction, since a contradiction is by definition not a duplicate — the text differs, so the dedup check passes and the row goes in. A team that has built dedup and believes it has built conflict detection has built the one check that a contradiction is guaranteed to pass.

Nor does matching say anything about whether the incumbent is still true. It answers a structural question — these two records are about the same property — and a store where every slot holds exactly one well-matched value can still be entirely wrong about a user whose circumstances changed without them ever mentioning it. That failure has nothing to contradict, so no candidate write ever arrives to trigger a match, and it needs the machinery of chapters 5 and 7 instead.

One more thing matching does not do is decide the vocabulary it depends on. The thirty attributes on the exact path were chosen by somebody, and the choice is a claim about which properties of a user this product will ever reason about. Every fact outside that list is routed to the expensive path by construction, so the schema is not only a performance decision but the thing that determines where the error rate lives. Reviewing it is worth doing on the same cadence as reviewing the floor, and for the same reason: both are settings, and only one of them looks like a setting.

What matching produces is a pair: an incumbent record and a candidate, both carrying source, timestamps, confidence, and an attribute with a declared cardinality. The question that pair raises is what to write. Replacing the incumbent leaves one clean value and no record of the change; keeping both leaves the store honest and hands the problem to whoever reads it next.

Memory — conflict and decay — read the current version at https://daviddominguez.dev/learn/en/memory-conflict-and-decay/a-contradiction-is-only-visible-when-memories-share-a-key

Back to top