Skip to content

14 min read

Sentinel values in supplied documents

On this page

Chapter 4 ended one honest objection short of a diagnosis. A paired prompt can show that a supplied document failed to change an answer, and it cannot show that the document arrived in a form the model could read — the chunk might have been truncated, mangled by the template, or placed where the model treats text as boilerplate. Both arms of a paired prompt are consistent with a model that reads nothing and answers from memory.

The manipulation that closes the objection is to change what the document says. Supply a value the model has no possible prior for, keep everything else identical, and the answer becomes a controlled experiment rather than an interpretation of wording.

What one run eliminates

Change one string in the staging copy of the pricing document. Where it says $24 / seat / month, write $1,337.42 / seat / month. Leave everything else — the chunking config, the retriever, the prompt template, the pinned revision, the system prompt, the user question — exactly as it was. Ask the same question again.

text
> What does the Team plan cost per seat?

The Team plan is $1,337.42 per seat per month. Annual billing is
available at a discount. Let me know if you'd like the enterprise
tiers as well.

That number is not in the weights, has never appeared in any crawl in connection with this product, and cannot be arrived at by arithmetic over anything the model remembers. Its presence in the output has one explanation: the model read the supplied document, located the value, and used it.

The claim that there is only one explanation rests on the mechanism chapter 2 set out, and it is worth walking rather than assuming, because every conclusion below depends on it. What a training run leaves behind is an accumulated tilt: a fact nudges the weights once for every document that stated it, and recall is that accumulation read back out. A value that appeared in no document produced no nudges, so there is no tilt it could be recalled from — not a faint one, none. The model can produce a plausible price by completing the pattern that prices follow, and it can produce this particular string of digits only by copying it from something sitting in front of it.

It is worth listing what that single run eliminates, because each of these was a live hypothesis in the previous chapter and each would have consumed days. The chunk is not being dropped by the template. It is not being truncated below the line where the value sits. The retriever is not returning a stale copy from an index that was never rebuilt. The model is not treating the document block as boilerplate to be skipped. The value is not landing in a position the model ignores. None of those survive an answer containing a number that exists nowhere except in the document you just edited.

Those five hypotheses look different and are structurally the same hypothesis, which is why one observation retires all of them together. Each is a claim that the string never reached the model in a form it could read — dropped, truncated, stale, skipped, or sitting where nothing attends to it — and every claim of that shape makes the same prediction, which is that the answer cannot contain a value existing only in the supplied text. The observation contradicts the prediction, so the whole family goes at once. That is a stronger position than the paired prompt could reach, because a paired prompt varies whether a document is present and leaves its readability untested in both arms, while a sentinel varies what the document says and a changed answer is available only to a model that read the change.

The same manipulation run against the refund-window question produced the opposite result. The staging document said refund window: 411 days; the assistant answered "30 days", which is both the real policy and the number that appears throughout the model's training data. The document was retrieved at rank one, the string 411 was present in the assembled prompt, and the answer did not contain it.

Two questions, two sentinels

Those two runs test different things, and conflating them is the easy mistake. Pricing had a strong competing memory and moved; refunds had a strong competing memory and did not. What separates a useful experiment from a confusing one is deciding in advance which question a given sentinel answers.

A zero-prior sentinel is a value the model has no opinion about at all — the overage rate on a tier that was never publicly documented, an internal SKU code, a field that exists only in your own documents. Its job is to test the plumbing. If a zero-prior sentinel does not appear in the answer, the context is not reaching the model in a usable form and you have an assembly bug, not a knowledge conflict. This is the control that stops you from diagnosing parametric override when the real problem is that the template silently truncates chunks over 512 tokens.

Finding a genuinely zero-prior slot takes a little thought, because most of the interesting fields in a business document have a plausible range the model knows. The reliable candidates are values that are structurally arbitrary: an internal SKU code, a support queue name, a ticket-routing threshold that only exists in your system, the name of a policy document. Ask "which queue handles Team plan billing escalations?" against a document that says queue: BILL-ESC-2, and there is no competing memory of any kind — the model has never seen your queue names. If that answer comes back with the queue name, the plumbing carries arbitrary strings from the document to the output, and every subsequent failure is about something other than delivery.

Structurally arbitrary is a stronger condition than the model not happening to know the value, and the two are easy to confuse while only one of them is any use. A refund window measured in days is a field the model has read a very large number of instances of, so it carries a tilt toward the range such windows occupy even where it carries none toward your particular number, and a supplied value inside that range competes with the range. A support queue name has no range: nothing in any crawl constrains what a queue at your company is called, so there is no tendency of any kind for the supplied string to compete with. Zero prior means no competing tendency, not merely no memory of this exact fact.

A competing sentinel is a value that occupies a slot the model has a strong memory for — the price, the refund window, the trial length. Its job is to test conflict resolution. Once the zero-prior sentinel has shown the plumbing works, a competing sentinel that fails to move is direct evidence that recall is beating the supplied text, and that the fault sits in the generation step.

The order those two run in is not a matter of convenience. The zero-prior run is a control in the strict sense, establishing that supplied text reaches the model in usable form, and until it has passed, no result from the second run carries any information — a competing sentinel that fails to appear is equally consistent with conflict resolution favouring memory and with the document never having arrived. Running the competing sentinel first and reading its failure as parametric override is the reliable way to produce a confident wrong diagnosis. The observation is real, the inference is unlicensed, and the remedy work that follows is aimed at the generation step while the fault sits in assembly.

Flowchart: Assert sentinel string is in the assembled prompt → Fix assembly: truncation, template, chunking (absent); Assert sentinel string is in the assembled prompt → Zero-prior sentinel in the answer? (present); Zero-prior sentinel in the answer? → Fix assembly: truncation, template, chunking (no); Zero-prior sentinel in the answer? → Competing sentinel in the answer? (yes); Competing sentinel in the answer? → Context wins on this fact. Look at freshness of the index and the source of truth (yes); Competing sentinel in the answer? → Parametric override. Work the generation step, not the retriever (no, old value returned); Competing sentinel in the answer? → Confounded: rerun with a less extreme sentinel (no, model objects to the value).

The first box is not decoration. Assert on the literal bytes you send, because every hypothesis downstream depends on the sentinel actually being there, and prompt templates lose things.

Designing a sentinel that tests what you think it tests

$1,337.42 is a good price sentinel and a terrible refund-window sentinel. The properties that matter are narrow.

It must be type-correct and format-plausible. A price sentinel is a currency amount with two decimal places; a duration sentinel is an integer with a unit. Putting the string banana where a number belongs tests whether the model will repeat nonsense, which is a different and less interesting question. It must be unique in the corpus you are searching, so grep the whole staging index before committing to a value — a sentinel that also appears in an unrelated document gives you an ambiguous positive.

And it must not be so extreme that its extremity becomes the explanation. This is the confound that ruins otherwise clean runs. 411 days is an absurd refund window, and a model that answers "30 days" might be preferring its memory, or might be treating an obviously wrong value as a typo in the source. Those are different diagnoses with different fixes, and a single sentinel cannot distinguish them.

The reason it cannot is general, and it is worth stating on its own because the same trap is waiting in every experiment in this topic. Two hypotheses that predict the same observation are not separated by making that observation, however carefully it is made and however many times it is repeated. Preferring memory and rejecting the source both predict an answer of 30 days when the document says 411, so a run at 411 has no power to choose between them. They come apart elsewhere: a preference for memory is a competition between two candidate values and has no reason to weaken as the supplied one becomes stranger, while a judgement that the source is corrupt is triggered by strangeness and should strengthen with it. The experiment that discriminates is therefore one that varies strangeness deliberately and reads the trend.

The way out is a ladder: run the same question at three or four sentinel values of increasing distance from both the truth and the plausible range, and read the shape of the response curve rather than a single outcome.

python
SENTINELS = [
    ("control", "30 days"),
    ("near", "45 days"),
    ("far", "90 days"),
    ("absurd", "411 days"),
]

QUESTION = "How long is the refund window on the Team plan?"
RUNS = 20


def run_ladder(doc: str, true_value: str) -> list[dict]:
    rows = []
    for label, sentinel in SENTINELS:
        patched = doc.replace(true_value, sentinel)
        prompt = assemble_prompt(patched, QUESTION)

        # The experiment is void if the sentinel never reached the model.
        assert sentinel in prompt, f"{sentinel!r} missing from assembled prompt"

        answers = [call_model(**FIXED, user=prompt) for _ in range(RUNS)]
        rows.append({
            "label": label,
            "sentinel": sentinel,
            "used_sentinel": sum(sentinel in a for a in answers),
            "used_memory": sum(true_value in a and sentinel not in a
                               for a in answers),
            "objected": sum(("appears to be" in a or "likely a typo" in a)
                            for a in answers),
            "runs": RUNS,
        })
    return rows

The objected counter is the difference between a harness that produces a diagnosis and one that produces an argument. Without it, a rejection and an override both land in used_memory, and the ladder loses the distinction it exists to make. The collapse happens because the two outcomes put the same string in the answer: a model preferring its memory writes 30 days, and a model treating the document as corrupt also writes 30 days. What differs is the prose around the value, where the second one says something about the supplied figure looking wrong. A scorer that inspects only the number is structurally unable to separate them, and a scorer that also reads the sentence can.

Reading the resulting rows takes one further idea, because the counts rarely come back clean and somebody expecting a verdict will treat the interesting rows as failed measurements. Conflict resolution is not a switch the model throws the same way every time; it is the outcome of a process with sampling in it, so a sentinel that wins on some runs and loses on others is the ordinary result. Twenty out of twenty and zero out of twenty are the two ends of a scale, and the informative rows are usually the ones in between, because a rate is what moves when a remedy is applied and an outcome is not. Rerunning an intermediate row until it resolves one way discards the measurement the ladder was built to produce.

Keeping the harness after the argument ends

A harness like this is usually built to settle one dispute and then abandoned, which wastes most of its value. The same ladder, run at the near sentinel over a fixed set of a dozen high-stakes facts, is a regression test for a property nothing else tests: that a change to the current document actually changes the answer.

It catches things that are otherwise invisible until a customer finds them. A prompt-template edit that moves the retrieved documents below a long instruction block, a chunking change that splits a table across boundaries, a model upgrade whose measured context-wins rate is worse on your fact classes, a caching layer that starts serving a response keyed on the question alone — all of them show up as a drop in the sentinel-used count, and none of them shows up in a retrieval metric. Run it nightly against staging rather than on every commit, since twenty runs across a dozen facts across four ladder rungs is close to a thousand model calls, and record the counts as a time series. The absolute number matters less than the day it moves.

The one operational rule is that the sentinel documents live in their own index, built from a copy of the real documents with the values patched at build time, and that the code path which patches values cannot execute against a production connection. The fixture that makes this test possible is a set of deliberate falsehoods, and deliberate falsehoods are exactly the thing you do not want one misconfigured environment variable away from the customer-facing index. The obvious version of that mistake is running the ladder against the real index "just for one query" and forgetting to revert; the less obvious version is a staging index sharing a backing store with production under a different namespace.

The belief a green result licenses is narrower than the belief it invites, and the invitation is a strong one. A single run retired five live hypotheses at once, which is more than most experiments in this topic manage, and the sentence that suggests itself next is that the system reads its documents. That sentence is about the system while the evidence is about one fact. Every hypothesis the run eliminated was eliminated for the value under test, in the prompt that carried it, at the settings used, and nothing in the run speaks to a different fact carried by a different chunk sitting in a different place.

Two limits are worth stating before anyone reports a green result as a clean bill of health. A passing sentinel says the context was read on the fact you tested, at the settings you tested, in the prompt shape you tested — and chapter 3's per-fact table already showed that context-wins rates vary enormously across facts within one system, so a single green result generalises poorly. Run the ladder over the handful of facts that matter, the ones where being wrong costs money or trust, rather than over a random sample.

And a sentinel says nothing about whether the fact belonged in a document in the first place. Everything here assumes retrieval is the right mechanism for the value under test, and for a refund window that changes when someone edits a policy page, it is. It is not the right mechanism for a customer's account balance, which changes between the moment the index was built and the moment the question is asked, and which no amount of correct chunking and correct conflict resolution will make current. Deciding which facts belong in the weights, which belong in retrieved documents, and which belong to a live call is the next question, and getting it wrong produces stale answers that pass every test in this chapter.

Knowledge cutoff — read the current version at https://daviddominguez.dev/learn/en/knowledge-cutoff/sentinel-values-in-supplied-documents

Back to top