Chapter 4 of 7 · 14 min read
One contradicting observation is evidence, not a correction
On this page
The machinery of the previous three chapters produces a matched pair and applies a policy to it, and the policy in every one of those chapters was a comparison: which record is newer, which came from a more trusted source, which the attribute's cardinality allows to survive. Every one of those comparisons treats the incoming observation as a claim about the attribute.
Most contradicting observations are not claims about the attribute. They are events, and an event is compatible with the stored value continuing to be true. Somebody who is vegetarian eats a chicken curry. Somebody punctual misses a meeting. Somebody whose preferred language is Portuguese sends one message in English because they were forwarding a colleague's mail. Feeding those to a policy built to compare two claims produces a correction every time, because the policy has no representation for "this is weak evidence rather than an assertion", and the code that implements it looks so obviously right: a new fact contradicts an old one, so replace the old one.
Not every contradicting observation is the same kind of thing
The chicken order and the sentence "actually, I stopped being vegetarian last month" are both contradictions of the stored value, and they warrant opposite responses. Sorting the input by what it actually is fixes most of the problem before any arithmetic is involved.
| Input | Example | What it is | Effect on the stored fact |
|---|---|---|---|
| Explicit self-correction | "I'm not vegetarian any more" | A statement about the attribute itself | Supersede on this one observation |
| Behavioural contradiction | One chicken order | Weak evidence about the attribute | Decrement confidence, no replacement |
| Explained exception | "I'll have the chicken, my sister is visiting" | An event with a stated external cause | No conflict at all; store as an event |
| Proxy action | "We'll take the chicken and the pasta" | An action taken for somebody else | Ignore for this attribute |
| Third-party report | A colleague says "she eats meat" | Low-trust hearsay | Decrement slightly, or ignore |
The third column is what the other two hang off, and the five values in it are not five degrees of strength. They are three different kinds of thing: a statement about the attribute, evidence bearing on the attribute, and an event that is not about the attribute at all. Rows one and two split on the first boundary; rows three and four sit on the far side of the second. Reading the table as a strength ordering — and then implementing it as five decrement sizes — collapses that structure and reintroduces the bug in a slower form.
The two rows below the explained exception are worth a sentence each, because both are cases where the observation is genuine and the subject is wrong. A proxy action — "we'll take the chicken and the pasta" — is a real order placed by this user for somebody else at the table, so it is evidence about a meal and no evidence at all about this user's diet, and the only correct handling is to keep it out of the slot. A third-party report is evidence about the attribute, from a source the trust ordering ranks lowest, which is why it decrements rather than replaces and why the decrement is small: the person reporting may be right, and they may also be describing a meal they saw at a table where a sister was visiting.
The first row is why evidence accumulation must not be applied uniformly. When a user says in words that a fact about them has changed, making them say it three times before the system believes them is its own failure, and an obnoxious one — the user experiences an assistant that argues with them about their own life. Explicit corrections should take effect on the first observation, and the machinery below exists for the other four rows.
The third row is the more interesting case and the one the chapter title points at. "I'll have the chicken, my sister is visiting" contains its own explanation, and the explanation attributes the behaviour to a circumstance outside the standing state. Follow what that does to the evidence. A behavioural contradiction is evidence about the attribute only because the behaviour needs explaining and a changed preference would explain it. Here the user has supplied a different explanation, one that is fully sufficient on its own, so the behaviour no longer requires a changed preference to make sense of it, and it stops being evidence for one. The right handling is therefore not a confidence decrement with a smaller step size; it is to recognise that the observation is about an event rather than about the attribute, and route it accordingly — a memory that the sister visited in November, with the short lifetime that event memories get, and no write to dietary_preference at all.
Whether the extraction step from 10.11 produces that routing depends on whether it was asked to distinguish stated preferences from episodic events, and this is where the failure usually originates rather than in the resolution policy. An extractor that emits one flat kind of record has to throw away the clause that carries the explanation, because there is no field for it. So if it was not asked, every explained exception arrives as a naked contradiction with the explanation stripped off, and the policy downstream is being handed a strictly worse input than the user provided.
Written out as records, the explained-exception case produces two writes and neither of them touches the preference slot:
[
{"attribute": "dietary_preference", "value": "vegetarian",
"action": "none", "confidence": 0.958},
{"attribute": "episode", "value": "sister visiting; ordered chicken",
"action": "insert", "observed_at": "2025-11-13T19:42:00Z",
"expires_at": "2025-12-13T19:42:00Z", "confidence": 0.88}
]The event memory has a life of its own and some later value — if the user mentions their sister next month, the assistant knows she visited in November — and it expires on its own schedule rather than sitting in the preference slot forever.
Accumulating evidence instead of overwriting
For behavioural contradictions, the stored fact needs a representation that can absorb an observation without flipping. Two decayed counters per fact do the job: the weight of observations supporting the value and the weight of observations against it, each discounted by age so that old evidence loosens its grip without vanishing.
from dataclasses import dataclass
from datetime import datetime, timedelta
from math import exp, log
PRIOR = 1.0 # pseudo-counts, keeps a single observation from reading as certainty
@dataclass
class Belief:
value: str
support: float # decayed count of confirming observations
contradiction: float # decayed count of disconfirming observations
last_update: datetime
half_life_days: float
def _decayed(self, now: datetime) -> tuple[float, float]:
age = (now - self.last_update).total_seconds() / 86_400
factor = exp(-log(2) * age / self.half_life_days)
return self.support * factor, self.contradiction * factor
def confidence(self, now: datetime) -> float:
s, c = self._decayed(now)
return (s + PRIOR) / (s + c + 2 * PRIOR)
def observe(belief: Belief, agrees: bool, weight: float, now: datetime) -> Belief:
s, c = belief._decayed(now)
if agrees:
s += weight
else:
c += weight
return Belief(belief.value, s, c, now, belief.half_life_days)Two lines of that class carry the behaviour and both are worth reading rather than trusting. The decay factor is exp(-log(2) * age / half_life), which is another way of writing two raised to the power of minus the age measured in half-lives, so at an age of exactly one half-life the factor is one half, at two half-lives a quarter, and so on. That is the whole meaning of the parameter: the interval over which an observation loses half of the weight it started with. And the confidence expression divides the support plus one pseudo-count by the total plus two, so a fact with no observations at all reports one half rather than zero or one, which is what stops a single first observation from reading as certainty.
The observe function is doing one more thing that is easy to read past. It decays both counters up to the present moment before adding the new observation's weight, and then records the present moment as the new last_update. So each observation enters at its full weight on the day it happens and starts ageing from there, rather than being discounted for time that passed before it existed. Adding first and decaying afterwards would penalise new evidence for the age of the old, which is the opposite of what the mechanism is for.
Weights come from the source trust ordering of chapter 1: an explicit UI selection counts 1.0, an inferred order counts 0.5, a third-party mention counts 0.2. Those weights are why a count of observations and a quantity of support are different numbers. A user with 47 observations behind a preference does not have forty-seven units of support, because the large majority of them are inferred orders carrying half a unit each, and the raw total before any decay is already well below the count.
With a 180-day half-life, the vegetarian fact's 47 observations spread over eight months decay to an effective support of about 21.6, giving a confidence of 22.6 / 23.6 = 0.958. The chicken order adds 0.5 to the contradiction side and takes confidence to 22.6 / 24.1 = 0.938. The value does not change, retrieval keeps returning "vegetarian", and the anniversary steakhouse never gets suggested.
That is the correct behaviour for one meal, and the formula also says exactly how much it would take to get a different one. Ask when confidence falls below one half. That happens when the support plus one pseudo-count is less than half of the support plus contradiction plus two pseudo-counts, and multiplying both sides by two turns it into support plus two being less than support plus contradiction plus two. The support and the pseudo-counts cancel from both sides, leaving the condition that contradiction exceeds support. So the half-way point of this scale is exactly the point where the two counters are equal, whatever the prior is set to and whatever the absolute quantities are.
Working backwards from the formula exposes the honest limitation of pure counting, which is that it is far too sticky when the change is real. Driving confidence below 0.5 from 21.6 units of support requires about 21.6 units of contradiction, which at 0.5 per order is more than forty meat dishes. A user who genuinely stopped being vegetarian in January would be arguing with the assistant until roughly the following autumn.
Two things bound how literally to take that figure. It holds the support fixed while the contradictions accumulate, whereas every new observation re-decays both counters, so a run of contradictions spread over months faces a support that has shrunk in the meantime and crosses over sooner. And crossing one half is not the first thing a user would notice: the read-time bands below stop the fact being asserted plainly well before the value is in question, at around ten contradicting orders rather than forty. The value flipping and the agent going quiet about it are different events, and the formula puts them a long way apart.
The resolution is that a sustained pattern should produce a third outcome rather than a slower version of the same two. Counting handles the isolated event; a trend — several contradictions inside a short window with no supporting observations between them — is a signal that the standing value is in question, and the useful response there is to ask.
def review_action(belief: Belief, recent: list[Observation], now: datetime) -> str:
window = [o for o in recent if now - o.at <= timedelta(days=30)]
against = [o for o in window if not o.agrees]
for_ = [o for o in window if o.agrees]
if len(against) >= 3 and not for_:
return "ASK" # surface the fact for confirmation next turn
if belief.confidence(now) < 0.35:
return "DEMOTE" # stop injecting it into the prompt
return "HOLD"The and not for_ is the clause that makes this a trend detector rather than a counter. Three contradictions with a supporting observation between them is a person who eats meat sometimes, which is a fact about the schema. Three contradictions with nothing supporting in between is a person whose habits have changed, which is a fact about the user. The same three events mean different things depending on what sits between them, and only a rule that looks at the window rather than at the total can tell them apart.
Asking costs one turn — "I've had you down as vegetarian since March; is that still right?" — and resolves in a single exchange what months of behavioural inference cannot. It has to be rationed, because an assistant that interrogates the user about their stored profile is worse than one that is occasionally out of date. A workable budget is at most one confirmation question per user per week, spent on the fact with the highest product of staleness and impact, where impact is something you assign per attribute rather than infer. A wrong dietary preference produces a bad restaurant suggestion, a wrong allergy produces a hospital visit, and those should not compete for the same slot on equal terms.
Ordering the queue by the product of staleness and impact rather than by either alone follows from what a question buys. Asking about a fact that is probably still right buys almost nothing however important it is, and asking about a fact that is probably wrong buys almost nothing if being wrong about it costs nothing. The value of the question is the chance it changes something multiplied by what the change is worth, and a product is the only ordering that will not spend the week's single question on a very stale note about a preferred greeting.
A confidence number is only worth maintaining if the read path changes behaviour across its range, and three bands are enough. Above roughly 0.8 the fact is injected as a plain assertion, so the prompt carries "the user is vegetarian" and the model treats it as given. Between 0.35 and 0.8 it is injected with its provenance attached — "the user has ordered vegetarian consistently since March, though not always" — which lets the model hedge or check rather than assert. Below 0.35 it stops being injected at all while remaining in the store, so a later confirming observation can revive it without the fact having to be rediscovered from scratch. Those bands are also what make the arithmetic above visible in production: the vegetarian fact moving from 0.958 to 0.938 changes nothing the user sees, which is the intended result, whereas a fact drifting across 0.8 changes the tone of every answer that uses it.
Where evidence accumulation is the wrong tool
Administrative facts should not go through any of this. When the billing system reports that a subscription tier changed, or the user updates their shipping address in a form, or a name correction arrives, there is one authoritative observation and the correct number of confirmations required is one. Running those through a decayed counter delays a change the user explicitly made and produces the same argues-with-you behaviour as making them repeat themselves. The dividing line is whether the attribute has a system of record: where one exists, memory is a cache of it and the cache should follow; where the user's own statements are the only source, evidence accumulation applies.
The scheme also assumes the attribute is genuinely single-valued, which is a claim worth re-examining whenever contradictions keep arriving. If a user orders vegetarian four days in five, the honest model may be dietary_preference = mostly vegetarian or a multi-valued slot with weights, rather than a single value under constant attack. A steady stream of contradictions on a cardinality-1 attribute is evidence about the schema as much as about the user, and the oscillation visibility that chapter 3's supersession chain preserves is how you notice. This is the second time the same diagnostic has answered a different question, which is a reason to build it early: the same reversal count that calibrates the replacement rule also nominates the attributes whose cardinality was declared wrong.
Choosing the half-life is the part with no principled answer. It should reflect how fast that class of fact actually churns, and almost nobody has labelled data on churn rates when they first ship, so the initial values are guesses — 180 days for stated preferences, 30 days for project context, no decay at all for identity facts — tuned afterwards by watching which classes generate complaints and which generate confirmation questions nobody needed to be asked. The two failure directions are asymmetric in how they announce themselves. Getting it wrong in the sticky direction produces an assistant that is slow to update, which users report. Getting it wrong in the twitchy direction reproduces the steakhouse, which users do not report; they leave.
What none of this addresses is the fact that quietly stops being true with no contradicting observation of any kind. The user's employer changes and they never mention it, the hotel booking passes its end date, the "currently learning Rust" note ages into irrelevance. There is no chicken order to count, no candidate write to match, and confidence built from evidence stays exactly where it was — high, supported, and wrong. Every mechanism in this chapter is driven by an arriving observation, and the whole class of failure below is defined by nothing arriving.
Memory — conflict and decay — read the current version at https://daviddominguez.dev/learn/en/memory-conflict-and-decay/one-contradicting-observation-is-evidence-not-a-correction