Skip to content
Skip to lesson
Back to Topics
LearnApplied LLM EngineeringHallucination Detection & Mitigation
🛡️MediumAlignment & Safety

Hallucination Detection & Mitigation

Build a structured grounding gate for incident updates, test its failure cases, and distinguish evidence support from consistency, freshness, and real-world truth.

30 min read
Learning path
Step 77 of 197 in the full curriculum

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

A fictional on-call engineer asks a simple question: is the API fully recovered? The runbook snapshot for INC-48291 says the database failover completed on May 26 at 08:14 UTC and the status is still investigating. It has no recovery estimate. Yet a hand-authored draft adds, "Full recovery is expected by 14:30 UTC." The sentence sounds reassuring, but the system has no source for that promise.

Would you serve that draft? An invented detail can cause engineers to stand down early or mislead readers of a status page. A fluent language model can't turn an absent field into evidence just because users want certainty.

Treat each factual clause as something the must justify. incident-answerer-v1 uses hand-authored structured claims so we can test verification, rendering, and without pretending to have solved arbitrary language understanding. For this product, absent evidence is enough reason to withhold a claim. It doesn't prove that claim is false in the wider world.

Why overlap and answer-level scores miss incident failures

Start with the smallest failure. Two status lines can share nearly every word while demanding opposite operational action.

Bilingual Evaluation Understudy (BLEU) and Recall-Oriented Understudy for Gisting Evaluation (ROUGE) compare surface overlap, not whether admitted evidence supports a claim.[1][2] "Database failover failed" and "Database failover succeeded" share most of their words, but the first calls for immediate escalation while the second reports progress.

An answer-level score has the opposite blind spot. A long incident summary can be mostly correct while one invented action, time, or status makes it unsafe. Split the answer into atomic claims and verify each one before computing a summary-level quality score.

That leaves an open question: if overlap isn't the contract, what relationship to the source is?

Taxonomy of hallucinations: Intrinsic, extrinsic, and world factuality

Before choosing a detector, define its scope. The intrinsic/extrinsic distinction comes from work on source-conditioned generation. Huang and colleagues review it and distinguish broader factuality and faithfulness failures in LLMs. It's a useful local taxonomy, not an exhaustive partition of every LLM failure.[3]

Intrinsic hallucinations directly contradict or misrepresent the supplied context. If a runbook says Redis uses port 6380 while the draft substitutes the common default 6379, or the status says investigating while the draft proclaims resolved, the model has contradicted its admitted evidence. The information was right there in the prompt, yet the model replaced it with an incompatible claim.

Extrinsic hallucinations introduce assertions that the supplied context can't verify or disprove. When the incident draft promises full recovery by 14:30 UTC, the admitted runbook doesn't say 14:30 is wrong. It simply contains no recovery timestamp at all. The model extrapolated beyond its evidence.

Separate groundedness (support from the admitted context) from world factuality (agreement with reality). They are different properties; calling them different doesn't assume statistical independence.

  1. Grounded and factually true: Suppose the draft matches the runbook's completed failover and the event actually happened. The claim meets both properties. Serving still requires the product's admission and routing checks.
  2. Grounded but factually false: Suppose a draft reports the current status as investigating from a delayed snapshot, although the incident has since resolved. It's faithful to that snapshot, but wrong about the present. "The snapshot recorded investigating at 10:00 UTC" would be a different, time-scoped claim.
  3. Ungrounded but factually true: Suppose the draft asserts recovery by 14:30 UTC without support, and recovery happens by that time. The predicted event occurred; the claim still lacked evidence when issued. That outcome doesn't establish that a recovery estimate was actually recorded or justified.
  4. Ungrounded and factually false: Suppose the draft asserts status: resolved, conflicting with the record, and the incident is still unresolved in reality. Both properties fail.

These are hypothetical world states. The lab checks a supplied snapshot; it doesn't observe a live incident. An unknown source establishes neither truth nor falsehood.

This product promises faithfulness to an admitted incident record. In an open-world setting, an unsupported recovery time might later turn out true. Here it's still an extrinsic hallucination and an unsafe serve because the product promised evidence-grounded updates.

A verifier compares each atomic claim with admitted evidence. A Natural Language Inference (NLI) classifier predicts whether a premise entails, contradicts, or is neutral toward a hypothesis. Those predictions are fallible evidence-support signals. Audit errors involving negation, entity identity, dates, and qualifiers before mapping them to a serving policy.[4]

VerdictMeaning in this productIncident-update exampleServing action
SupportedAdmitted source entails the fact"Failover completed on May 26."May be served
Not supportedExtrinsic: source is neutral or missing"Full recovery by 14:30 UTC."Remove or abstain
ContradictedIntrinsic: source contradicts the claim"Incident is resolved." while status is investigatingBlock and investigate
Diagram showing Incident question, Draft atomic claims, Verify vs admitted record, and Serve.
Incident question, Draft atomic claims, Verify vs admitted record, and Serve.

The policy is on facts: unresolved verification blocks a clause rather than just lowering its score.

Claim-to-evidence alignment for incident INC-48291 at runbook-feed/2026-05-27T10:00:00Z. Service, last_event, and last_event_at match the admitted fields and stay. recovery_eta 14:30 UTC has no source field, so it's dropped. status=resolved conflicts with investigating, so that path abstains.
The admitted record licenses service, failover, and event time. A missing recovery_eta drops 14:30 UTC. A resolved-status claim conflicts with investigating, so that path abstains.

Why fluent guesses survive training

A model learns a distribution over text, not a database of authenticated incident facts. Likely continuations can reflect stale information, common misconceptions, or patterns that don't identify this request's missing details. Learned knowledge and supplied context can conflict; context-aware decoding studies that problem experimentally.[5]

Kalai and colleagues' 2025 analysis connects generative errors to statistical difficulty in distinguishing valid and invalid responses. It also argues that accuracy-only evaluation rewards guessing: a lucky guess earns credit, while an abstention earns none. This is an analysis of training and evaluation pressures, not proof of a particular attention-head mechanism behind our fictional ETA.[6]

Training prefixes aren't guaranteed to be true, and RLHF or DPO can reduce hallucinations under suitable objectives. Neither method inherently rewards invented estimates. For this product, measure unsupported outputs, missed useful answers, and appropriate abstentions together. Don't optimize a scoreboard that makes guessing look harmless.

Put the evidence in code

The source ID and version identify a request-scoped evidence snapshot. Admission must separately check incident identity, caller permissions, provenance, and freshness. Logging a version isn't a freshness check. We copy the fixture's facts into a read-only mapping so changing an input dictionary can't silently change the same snapshot. Python containers and identifier strings aren't a production trust boundary.

Predict the first output: four draft claims, but no recovery_eta among the available fields.

versioned-incident-evidence.py
from collections import Counter
from collections.abc import Mapping
from dataclasses import dataclass, replace
from enum import Enum
from types import MappingProxyType

ALLOWED_FIELDS = frozenset({
    "service", "status", "last_event", "last_event_at", "recovery_eta",
})
CANONICAL_STATUSES = frozenset({"investigating", "mitigated", "resolved"})

def require_text(value: object, name: str) -> None:
    if type(value) is not str or not value.strip():
        raise ValueError(f"{name} must be a nonempty string")

@dataclass(frozen=True)
class Evidence:
    source_id: str
    version: str
    facts: Mapping[str, str]

    def __post_init__(self) -> None:
        require_text(self.source_id, "source_id")
        require_text(self.version, "version")
        if not isinstance(self.facts, Mapping):
            raise ValueError("facts must be a field mapping")
        copied = dict(self.facts)
        if not copied or not set(copied) <= ALLOWED_FIELDS:
            raise ValueError("facts must use the declared incident schema")
        for value in copied.values():
            require_text(value, "fact value")
        if "status" in copied and copied["status"] not in CANONICAL_STATUSES:
            raise ValueError("unknown canonical incident status")
        object.__setattr__(self, "facts", MappingProxyType(copied))

@dataclass(frozen=True)
class Claim:
    claim_id: str
    text: str
    field: str
    value: str
    citation_id: str
    citation_version: str

    def __post_init__(self) -> None:
        for name in ("claim_id", "text", "field", "value", "citation_id", "citation_version"):
            require_text(getattr(self, name), name)
        if self.field not in ALLOWED_FIELDS:
            raise ValueError("claim field is outside the declared incident schema")

incident = Evidence(
    source_id="incident-INC-48291",
    version="runbook-feed/2026-05-27T10:00:00Z",
    facts={
        "service": "embedding-api",
        "status": "investigating",
        "last_event": "database failover completed",
        "last_event_at": "May 26 at 08:14 UTC",
    },
)
sources = MappingProxyType({incident.source_id: incident})

draft_claims = [
    Claim("service", "Service: embedding-api.", "service", "embedding-api", incident.source_id, incident.version),
    Claim("event", "Last event: database failover completed.", "last_event", "database failover completed", incident.source_id, incident.version),
    Claim("event_time", "Event time: May 26 at 08:14 UTC.", "last_event_at", "May 26 at 08:14 UTC", incident.source_id, incident.version),
    Claim("eta", "Full recovery is expected by 14:30 UTC.", "recovery_eta", "14:30 UTC", incident.source_id, incident.version),
]

print(f"Evidence version: {incident.version}")
print(f"Available facts: {sorted(incident.facts)}")
print(f"Draft factual claims: {len(draft_claims)}")
Output
Evidence version: runbook-feed/2026-05-27T10:00:00Z
Available facts: ['last_event', 'last_event_at', 'service', 'status']
Draft factual claims: 4

An LLM can write the recovery-time sentence because it has seen incident-update patterns. That doesn't make it admissible. The admitted evidence has no recovery_eta field, so the next check should reject that claim while keeping the three claims backed by fields.

Verify each atomic claim

FActScore makes the key move explicit: decompose a long generation into atomic facts, then measure how many a knowledge source supports. Its original Wikipedia evaluation used Supported, Not-supported, and Irrelevant labels.[7]

Current Ragas Faithfulness applies the same decompose-and-check idea to retrieved context: extract statements, then ask whether each one can be inferred from that context. Its model-generated statements and support judgments need their own error audit.[8]

The verifier below checks a much narrower contract than FActScore or Ragas: equality against fields in one structured record. It doesn't extract claims from prose or run NLI. Predict the verdicts: three matching fields pass, the missing estimated time of arrival (ETA) is not_supported, and resolved contradicts the canonical status investigating. An unknown source or mismatched version must fail before field comparison.

claim-verdicts.py
class Verdict(str, Enum):
    SUPPORTED = "supported"
    NOT_SUPPORTED = "not_supported"
    CONTRADICTED = "contradicted"
    NO_SOURCE = "no_source"
    VERSION_MISMATCH = "version_mismatch"

@dataclass(frozen=True)
class Verification:
    claim: Claim
    verdict: Verdict
    evidence_version: str | None

def verify_claim(claim: Claim) -> Verification:
    if type(claim) is not Claim:
        raise ValueError("verification requires a validated Claim")
    source = sources.get(claim.citation_id)
    if source is None:
        return Verification(claim, Verdict.NO_SOURCE, None)
    if claim.citation_version != source.version:
        return Verification(claim, Verdict.VERSION_MISMATCH, source.version)
    expected = source.facts.get(claim.field)
    if expected is None:
        return Verification(claim, Verdict.NOT_SUPPORTED, source.version)
    if expected != claim.value:
        # Different canonical, mutually exclusive statuses contradict this snapshot.
        # Other string differences may just be paraphrases or alternate formatting.
        verdict = (
            Verdict.CONTRADICTED
            if claim.field == "status" and claim.value in CANONICAL_STATUSES
            else Verdict.NOT_SUPPORTED
        )
        return Verification(claim, verdict, source.version)
    return Verification(claim, Verdict.SUPPORTED, source.version)

draft_verdicts = [verify_claim(claim) for claim in draft_claims]
resolved_claim = Claim(
    "resolved",
    "Incident INC-48291 is resolved.",
    "status",
    "resolved",
    incident.source_id,
    incident.version,
)

assert [item.verdict for item in draft_verdicts] == [
    Verdict.SUPPORTED,
    Verdict.SUPPORTED,
    Verdict.SUPPORTED,
    Verdict.NOT_SUPPORTED,
]
assert verify_claim(resolved_claim).verdict == Verdict.CONTRADICTED

for result in draft_verdicts + [verify_claim(resolved_claim)]:
    print(f"{result.claim.claim_id:10} {result.verdict.value:15} {result.claim.text}")
Output
service    supported       Service: embedding-api.
event      supported       Last event: database failover completed.
event_time supported       Event time: May 26 at 08:14 UTC.
eta        not_supported   Full recovery is expected by 14:30 UTC.
resolved   contradicted    Incident INC-48291 is resolved.

Recovery time isn't proven false; it's absent from this source. A different canonical status conflicts with investigating at this snapshot. For free text, unequal strings don't establish contradiction: "failover finished" could mean the same thing as "failover completed." Normalize typed values or send unfamiliar wording to semantic review. This exact-match lab conservatively marks it unsupported.

⚠️ Common mistake: A supported verdict is faithfulness to the admitted version, not a freshness check. If this runbook is an hour stale and the live service already recovered, the gate can still pass status=investigating. That's a source-admission problem, not a license to invent a new ETA.

Route the response as well as the score

The verifier checked field and value, not the draft's text. Serving that unchecked text would leave a bypass: a claim could have field="service", value="embedding-api", and text that also invents an ETA. For this small schema, render new sentences from verified fields instead. Never append the original draft afterward.

The policy keeps supported clauses only when the remaining failures are missing details. A contradictory status, unknown source, or mismatched version withholds the whole response for review. An empty claim list also abstains; zero checked claims isn't evidence of a safe answer.

safe-answer-route.py
def checked_claims(claims: list[Claim] | tuple[Claim, ...]) -> tuple[Claim, ...]:
    if type(claims) not in (list, tuple) or any(type(item) is not Claim for item in claims):
        raise ValueError("claims must be a sequence of validated Claim records")
    copied = tuple(claims)
    if len({item.claim_id for item in copied}) != len(copied):
        raise ValueError("claim IDs must be unique")
    if len({item.field for item in copied}) != len(copied):
        raise ValueError("this single-record grammar permits one claim per field")
    return copied

TEMPLATES = MappingProxyType({
    "service": "Service: {value}.",
    "status": "Recorded status: {value}.",
    "last_event": "Last event: {value}.",
    "last_event_at": "Event time: {value}.",
    "recovery_eta": "Recorded recovery estimate: {value}.",
})

def render_supported(claim: Claim) -> str:
    if verify_claim(claim).verdict != Verdict.SUPPORTED:
        raise ValueError("only verified fields may be rendered")
    return TEMPLATES[claim.field].format(value=claim.value)

def bounded_fallback(failures: list[Verification]) -> str:
    if any(item.verdict == Verdict.CONTRADICTED for item in failures):
        return "The draft conflicts with the incident record. Please review the latest runbook state."
    if any(item.verdict == Verdict.NO_SOURCE for item in failures):
        return "Incident details are unavailable right now."
    if any(item.verdict == Verdict.VERSION_MISMATCH for item in failures):
        return "The cited version is not the admitted snapshot."
    if all(item.claim.field == "recovery_eta" for item in failures):
        return "The admitted record provides no support for that recovery estimate."
    return "Some requested details are not supported by the admitted record."

def safe_answer(claims: list[Claim] | tuple[Claim, ...]) -> dict[str, object]:
    claims = checked_claims(claims)
    if not claims:
        return {"route": "abstain", "answer": "No verifiable claims were supplied.", "blocked_claims": []}
    verdicts = [verify_claim(claim) for claim in claims]
    failures = [item for item in verdicts if item.verdict != Verdict.SUPPORTED]
    if any(item.verdict != Verdict.NOT_SUPPORTED for item in failures):
        return {
            "route": "abstain", "answer": bounded_fallback(failures),
            "blocked_claims": [item.claim.claim_id for item in failures],
        }
    supported_text = " ".join(
        render_supported(item.claim) for item in verdicts if item.verdict == Verdict.SUPPORTED
    )
    if failures:
        return {
            "route": "shorten" if supported_text else "abstain",
            "answer": " ".join(part for part in [supported_text, bounded_fallback(failures)] if part),
            "blocked_claims": [item.claim.claim_id for item in failures],
        }
    return {
        "route": "serve",
        "answer": supported_text,
        "blocked_claims": [],
    }

decision = safe_answer(draft_claims)
conflict_decision = safe_answer([resolved_claim])

assert decision["route"] == "shorten"
assert decision["blocked_claims"] == ["eta"]
assert "14:30" not in str(decision["answer"])
assert conflict_decision["route"] == "abstain"
assert "resolved" not in str(conflict_decision["answer"])

print(f"Route: {decision['route']}")
print(f"Blocked claims: {decision['blocked_claims']}")
print(f"Served answer: {decision['answer']}")
print(f"Contradicted-only route: {conflict_decision['route']}")
Output
Route: shorten
Blocked claims: ['eta']
Served answer: Service: embedding-api. Last event: database failover completed. Event time: May 26 at 08:14 UTC. The admitted record provides no support for that recovery estimate.
Contradicted-only route: abstain

This output preserves supported details, but it still doesn't directly answer whether the API is fully recovered. Faithfulness isn't completeness. A production answer should include the recorded status and its observation time, and avoid treating a completed failover as proof of recovery. Evaluate answer usefulness alongside unsupported-claim risk.

Before trusting the renderer, try a hidden extra sentence, an old citation version, a valid paraphrase outside this schema, and an empty draft:

test-the-grounding-boundary.py
smuggled = replace(draft_claims[0], text="Service: embedding-api. Recovery by 14:30 UTC.")
old_version = replace(draft_claims[0], citation_version="runbook-feed/old")
paraphrase = replace(draft_claims[1], value="database failover finished")

print("Smuggled text rendered:", safe_answer([smuggled])["answer"])
print("Old citation:", verify_claim(old_version).verdict.value)
print("Paraphrase:", verify_claim(paraphrase).verdict.value)
print("Empty draft:", safe_answer([])["route"])
print("Mixed contradiction:", safe_answer([draft_claims[0], resolved_claim])["route"])
assert "14:30" not in safe_answer([smuggled])["answer"]
assert verify_claim(old_version).verdict == Verdict.VERSION_MISMATCH
assert verify_claim(paraphrase).verdict == Verdict.NOT_SUPPORTED
assert safe_answer([])["route"] == "abstain"
assert safe_answer([draft_claims[0], resolved_claim])["route"] == "abstain"
Output
Smuggled text rendered: Service: embedding-api.
Old citation: version_mismatch
Paraphrase: not_supported
Empty draft: abstain
Mixed contradiction: abstain

The paraphrase is a deliberate false rejection, not proof that the wording is false. Free-form answers need a claim extractor checked for omissions and scope errors, plus a verifier checked for both false acceptance and false rejection. Negation, dates, entity identity, units, and qualifiers are useful probes. If a rewrite uses an LLM, re-extract and verify the rewritten text too.

Measure failure before choosing mitigation

A single blocked recovery time gives one regression case, not release evidence. Before expanding it, predict what a useful suite must separate: a clean update, an unsupported estimate, a contradiction, and a missing source. Each failure should lead to a different route or repair.

Four hypothetical world states separate source support from truth. A supported failover claim could be true. A prediction absent from the source could happen to come true. A supported current-status claim could be stale. A contradicted resolved claim could also be false. No live incident was observed; an unadmitted source leaves truth unknown.
The two dimensions describe hypothetical world states. The following regression suite measures support against a fixture, not truth in a live incident.
grounding-regression-suite.py
@dataclass(frozen=True)
class AnswerCase:
    case_id: str
    claims: tuple[Claim, ...]

    def __post_init__(self) -> None:
        require_text(self.case_id, "case_id")
        object.__setattr__(self, "claims", checked_claims(self.claims))

cases = [
    AnswerCase("clean_update", draft_claims[:3]),
    AnswerCase("invented_eta", draft_claims),
    AnswerCase("wrong_status", [resolved_claim]),
    AnswerCase(
        "unadmitted_source",
        [Claim("service", "Service: embedding-api.", "service", "embedding-api", "missing-feed", incident.version)],
    ),
]

CASE_MANIFEST = MappingProxyType({case.case_id: case for case in cases})
assert len(CASE_MANIFEST) == len(cases)
cases = tuple(cases)

def has_unsafe_claim(case: AnswerCase) -> bool:
    return any(verify_claim(claim).verdict != Verdict.SUPPORTED for claim in case.claims)

baseline_served_unsafe = sum(has_unsafe_claim(case) for case in cases)
verdict_counts = Counter(
    verify_claim(claim).verdict.value
    for case in cases
    for claim in case.claims
)
claim_support_rate = verdict_counts["supported"] / sum(verdict_counts.values())

print(f"Baseline unsafe serves if all drafts ship: {baseline_served_unsafe}/{len(cases)}")
print(f"Claim verdict counts: {dict(verdict_counts)}")
print(f"Claim support rate: {claim_support_rate:.1%}")
assert baseline_served_unsafe == 3
assert claim_support_rate == 2 / 3
Output
Baseline unsafe serves if all drafts ship: 3/4
Claim verdict counts: {'supported': 6, 'not_supported': 1, 'contradicted': 1, 'no_source': 1}
Claim support rate: 66.7%

The suite separates claim counts from answer counts. For a grounded incident-status product, track:

MetricCalculationRelease meaning
Claim support rateSupported factual claims / all factual claimsHow much draft content evidence admits
Unsafe serve rateServed answers containing any failed factual claim / served answersWhether bad claims reach readers
Abstention rateFully withheld answers / total requestsHow often the system doesn't answer
Response coverageSubstantive full or shortened answers / total requestsHow often the system provides information

Track shortening separately from complete abstention. If no substantive answer is served, unsafe-serve rate is undefined, not zero. Claim support can improve while unsafe serves remain unacceptable: one contradicted incident status can cause harm even when the other clauses pass.

Choose a detection signal for the failure

A token's log probability, log⁡p(yt∣y<t,x)\log p(y_t \mid y_{<t}, x), measures how likely that token is under the model and decoding setup. A probability of 0.98 for one token isn't a 98% probability that the entire factual claim is correct. Token-level confidence can be a useful risk feature, but requires task-specific evaluation.

Predictive token entropy measures dispersion over the next-token vocabulary VV:

H(Yt∣y<t,x)=−∑w∈Vp(w∣y<t,x)ln⁡p(w∣y<t,x)H(Y_t \mid y_{<t}, x) = -\sum_{w \in V} p(w \mid y_{<t}, x)\ln p(w \mid y_{<t}, x)

The formula needs the full distribution. A few returned top-token probabilities don't determine it exactly. Several plausible phrasings can increase token uncertainty without changing a fact. Low uncertainty can accompany a repeated misconception; neither effect is guaranteed by a particular sentence.[9]

Two sampling methods ask a different question:

MethodSignalLimit
Semantic entropySample answers, cluster their meanings using bidirectional entailment, then estimate uncertainty over meaningsClustering can fail; stable false beliefs can have low uncertainty
SelfCheckGPTCompare draft sentences with sampled responses using a consistency scorerSamples from the same model are not independent factual sources

SelfCheckGPT evaluates several scorer variants, including BERTScore, QA, n-grams, NLI, and prompted checks. Its NLI variant averages a contradiction score normalized over entailment and contradiction, omitting the neutral class; its prompted variant asks whether a sentence is supported. It is not one universal three-way cross-examination procedure.[4]

An evidence-based NLI checker instead compares admitted source text with the claim. A dedicated model may reduce inference cost, but processing long evidence can require multiple windows and aggregation. Neither a single model pass nor an entailment label guarantees accurate verification.

Consistency is an alarm, not evidence

Suppose four samples all repeat "Full recovery is expected by 14:30 UTC." Does zero disagreement make that sentence safe? No. Consistency probes check for variance, not ground truth.

Semantic entropy groups outputs by meaning rather than exact wording, using entailment predictions conditioned on the question and context. With four samples assigned to three meaning clusters of sizes 2, 1, and 1, the empirical probabilities are 0.5, 0.25, and 0.25. Their entropy is about 1.040 nats. If all four express one meaning, this empirical estimate is zero. Four samples don't establish that the underlying distribution has only one meaning:

SE(x)=−∑c∈Cp(c∣x)ln⁡p(c∣x)SE(x) = -\sum_{c \in C} p(c\mid x) \ln p(c\mid x)

Here x is the question and context, C is the set of meaning clusters, and p(c|x) is a cluster's probability. Farquhar et al. distinguish an estimator using sequence probabilities from a discrete approximation using sampled cluster frequencies. Their target is confabulation, arbitrary incorrect answers that vary across samples, not every systematic false belief.[9]

Use consistency probes to prioritize verification, including when evidence is unavailable. They don't authorize a recovery-time claim. A model can repeat the same unsupported estimate across samples. Lowering doesn't supply missing evidence and can make such agreement less informative.

Evidence-first routing tree. A draft with any failed claim shortens when supported clauses remain and the only failure is not_supported, or abstains on contradiction or a missing source. After every claim is supported, disagreement above 0.25 or missing samples sends the answer to review; otherwise it serves. Repeated 14:30 UTC guesses still can't authorize a missing recovery_eta.
Evidence decides first. A mixed draft shortens. A fully supported answer then uses disagreement to split serve from review; missing samples also require review.

The next cell uses prewritten samples, not live model calls. Its disagreement score is 1 - modal frequency, not semantic entropy or a full SelfCheckGPT implementation. For these already normalized times, exact strings identify the meanings. Predict which signal should decide the repeated unsupported ETA.

consistency-is-not-truth.py
from math import log

def disagreement_rate(values: list[str]) -> float:
    if type(values) not in (list, tuple) or not values:
        raise ValueError("at least one sample is required")
    for value in values:
        require_text(value, "sample")
    most_common_count = Counter(values).most_common(1)[0][1]
    return 1 - most_common_count / len(values)

def empirical_entropy(cluster_labels: list[str]) -> float:
    if type(cluster_labels) not in (list, tuple) or not cluster_labels:
        raise ValueError("at least one clustered sample is required")
    for label in cluster_labels:
        require_text(label, "cluster label")
    probabilities = [count / len(cluster_labels) for count in Counter(cluster_labels).values()]
    return -sum(p * log(p) for p in probabilities)

unstable_eta_samples = ["14:30 UTC", "14:45 UTC", "14:30 UTC", "15:00 UTC"]
stable_unsupported_eta_samples = ["14:30 UTC", "14:30 UTC", "14:30 UTC", "14:30 UTC"]
eta_verdict = verify_claim(draft_claims[-1]).verdict

print(f"Unstable recovery estimate disagreement: {disagreement_rate(unstable_eta_samples):.2f}")
print(f"Repeated recovery estimate disagreement: {disagreement_rate(stable_unsupported_eta_samples):.2f}")
print(f"Repeated recovery estimate evidence verdict: {eta_verdict.value}")
print(f"Three-cluster empirical entropy: {empirical_entropy(unstable_eta_samples):.3f} nats")
# These two surface forms express the same time; cluster them manually here.
surface_forms = ["14:30 UTC", "2:30 PM UTC"] * 2
meaning_labels = ["14:30 UTC"] * 4
print(f"Paraphrase string disagreement: {disagreement_rate(surface_forms):.2f}")
print(f"Paraphrase meaning entropy: {abs(empirical_entropy(meaning_labels)):.3f} nats")

assert disagreement_rate(stable_unsupported_eta_samples) == 0.0
assert eta_verdict == Verdict.NOT_SUPPORTED
assert empirical_entropy(meaning_labels) == 0.0
Output
Unstable recovery estimate disagreement: 0.50
Repeated recovery estimate disagreement: 0.00
Repeated recovery estimate evidence verdict: not_supported
Three-cluster empirical entropy: 1.040 nats
Paraphrase string disagreement: 0.50
Paraphrase meaning entropy: 0.000 nats

Low disagreement means the model repeats itself, not that the fact is true. Automatic semantic clustering can also misgroup answers, so evaluate that component before using entropy as a risk feature. Record the sampling model, prompt, temperature, and sample count; more samples cost additional inference.

Combine signals with the right authority

Evidence wins even when samples agree. After the selected draft passes its evidence checks, inconsistent alternative status answers can still trigger review. The 0.25 cutoff below is an illustrative policy, not a published detector threshold. Tune it on development data and evaluate false alarms, missed failures, and response coverage on held-out cases.

Predict the three routes before running it: clean stable should serve, clean unstable should go to review, and a repeated unsupported ETA should still shorten.

evidence-first-routing.py
def route_with_signals(case: AnswerCase, sampled_statuses: list[str]) -> str:
    evidence_route = safe_answer(case.claims)["route"]
    if evidence_route == "abstain":
        return "abstain_evidence_failure"
    if evidence_route == "shorten":
        return "shorten_unsupported_detail"
    if not sampled_statuses:
        return "review_unstable_generation"
    if disagreement_rate(sampled_statuses) > 0.25:
        return "review_unstable_generation"
    return "serve_supported_answer"

clean_case = cases[0]
eta_case = cases[1]

routes = {
    "clean_stable": route_with_signals(clean_case, ["investigating"] * 4),
    "clean_unstable": route_with_signals(
        clean_case, ["investigating", "investigating", "mitigated", "resolved"]
    ),
    "eta_repeated": route_with_signals(eta_case, stable_unsupported_eta_samples),
}

for name, route_name in routes.items():
    print(f"{name:14} -> {route_name}")

assert routes["clean_stable"] == "serve_supported_answer"
assert routes["clean_unstable"] == "review_unstable_generation"
assert routes["eta_repeated"] == "shorten_unsupported_detail"
Output
clean_stable   -> serve_supported_answer
clean_unstable -> review_unstable_generation
eta_repeated   -> shorten_unsupported_detail

Citations must be checked, not decorated

A response that prints [incident-INC-48291] isn't necessarily grounded. The citation must resolve to the admitted version and support the nearby claim. Otherwise a model can attach a real-looking source marker to an invented recovery promise.

citation-faithfulness.py
def cited_sentence(claim: Claim) -> str:
    result = verify_claim(claim)
    if result.verdict != Verdict.SUPPORTED:
        raise ValueError(f"cannot cite {claim.claim_id}: {result.verdict.value}")
    return f"{render_supported(claim)} [{claim.citation_id}@{result.evidence_version}]"

served_sentences = [cited_sentence(claim) for claim in draft_claims[:3]]
served_answer = " ".join(served_sentences)

try:
    cited_sentence(draft_claims[-1])
except ValueError as error:
    blocked_citation = str(error)

print(served_answer)
print(blocked_citation)
assert "14:30" not in served_answer
assert "not_supported" in blocked_citation
Output
Service: embedding-api. [incident-INC-48291@runbook-feed/2026-05-27T10:00:00Z] Last event: database failover completed. [incident-INC-48291@runbook-feed/2026-05-27T10:00:00Z] Event time: May 26 at 08:14 UTC. [incident-INC-48291@runbook-feed/2026-05-27T10:00:00Z]
cannot cite eta: not_supported

The citation is attached to the rendered field, never the unchecked draft text. Retain for failed and passed claims. This extends evaluation by separating source identity, version agreement, and claim support. A source can pass all three and still be stale or factually wrong.

What decoding controls can change

DoLa and context-aware decoding alter token selection in different ways. Both require access to model distributions; DoLa also needs intermediate-layer outputs. They aren't generic prompt instructions or features every hosted API exposes.

DoLa (Decoding by Contrasting Layers) contrasts final-layer probabilities qNq_N with those from a selected earlier layer qMq_M. Within a plausibility set, its score is:

s(w)=log⁡qN(w)−log⁡qM(w),Vhead={w:qN(w)≥βmax⁡vqN(v)}s(w)=\log q_N(w)-\log q_M(w),\qquad \mathcal V_{\mathrm{head}}=\{w:q_N(w)\geq\beta\max_v q_N(v)\}

Tokens outside that set receive zero probability after masking and softmax normalization. The dynamic variant selects an earlier layer by maximum Jensen-Shannon divergence from the final distribution among candidate layers. The paper motivates this with observations of how predictions change across LLaMA layers; it doesn't establish a universal syntax-versus-truth partition of every transformer.[10]

Context-aware decoding (CAD) compares the same model with and without the supplied evidence. The question and generated prefix remain in both inputs. With logits zcz_c and z0z_0:

p~(w)=softmax⁡((1+α)zc−αz0)w\widetilde p(w)=\operatorname{softmax}\big((1+\alpha)z_c-\alpha z_0\big)_w

Here α≥0\alpha\geq0 controls the contrast; α=0\alpha=0 recovers ordinary context-conditioned decoding. This is equivalent to normalizing pc(w)1+α/p0(w)αp_c(w)^{1+\alpha}/p_0(w)^\alpha, not subtracting probabilities. The paper reports improved faithfulness in its tested summarization and knowledge-conflict setups.[5]

Try three toy next-token choices. The supplied context favors investigating; without it, the model favors resolved. Predict why the raw probability difference cannot be used as a distribution:

context-aware-decoding-arithmetic.py
from math import exp, isclose

choices = ("investigating", "resolved", "unknown")
with_context = (0.70, 0.20, 0.10)
without_context = (0.10, 0.80, 0.10)
raw_difference = [pc - p0 for pc, p0 in zip(with_context, without_context)]

# Log probabilities are valid logits up to an additive normalization constant.
alpha = 1.0
contrast_scores = [
    (1 + alpha) * log(pc) - alpha * log(p0)
    for pc, p0 in zip(with_context, without_context)
]
weights = [exp(score - max(contrast_scores)) for score in contrast_scores]
cad_probabilities = [weight / sum(weights) for weight in weights]

print("Raw difference:", [round(value, 2) for value in raw_difference])
for choice, probability in zip(choices, cad_probabilities):
    print(f"CAD {choice:13} {probability:.3f}")
print(f"Normalized mass: {sum(cad_probabilities):.3f}")
assert any(value < 0 for value in raw_difference)
assert isclose(sum(cad_probabilities), 1.0)
assert cad_probabilities[0] > with_context[0]
Output
Raw difference: [0.6, -0.6, 0.0]
CAD investigating 0.970
CAD resolved      0.010
CAD unknown       0.020
Normalized mass: 1.000

These distributions were supplied by hand. The arithmetic demonstrates the decoder, not improved factuality of a model. CAD can amplify bad evidence too; neither method supplies missing incident facts or certifies the final answer.

Abstention needs a measured trade-off

A semantic-entropy cutoff is a policy threshold, not automatically a calibrated probability of error. Choose thresholds on development data, then measure error and useful coverage on held-out requests. For example, two fictional strategies on ten independently labeled questions could look like this:

StrategyCorrect / wrong / abstainedCorrect / all questionsAnswer coverageWrong / answered
Always answer8 / 2 / 080%100%20%
Withhold selected answers7 / 0 / 370%70%0%

An accuracy-only scoreboard prefers the first, while a product that strongly penalizes false claims may prefer the second. The withheld correct answer still costs usefulness. The 2025 statistical analysis highlights this incentive problem; the table is teaching arithmetic, not its measured model results.[6]

For this incident gate, failed evidence checks take precedence over a confidence threshold. Remove unverified details or abstain, bind surviving claims to admitted versions, and verify any LLM rewrite again.

Attribute failures before adding complexity

The same blocked answer can have different causes. Before adding another mitigation, locate the first failed stage:

First failed stageSymptomAppropriate next action
Evidence admissionNo usable incident record was admittedInspect retrieval, permissions, provenance, freshness, and tool results
Claim generationSource exists, but answer adds unsupported recovery timeTighten generation and the post-generation claim gate
Consistency onlyAlternative answers disagree after the selected draft passesInspect sampled claims and verifier errors as well as generation

Chain-of-Verification (CoVe) is a generator-side control: draft an answer, plan verification questions, answer them separately, and revise the response. Separating checks aims to reduce conditioning on earlier mistaken answers; it doesn't make the model's answers independent factual evidence. The paper reports fewer hallucinations on its list questions, closed-book QA, and long-form tasks.[11]

CoVe can tighten generation, but it doesn't replace an authoritative incident record or the final claim gate in this product. If the source is missing or stale, a more careful draft still needs the same boundary.

Read the traces before running the attribution cell. req-eta should fail during claim generation, req-missing during evidence admission, and req-vary during generation stability.

first-failure-attribution.py
@dataclass(frozen=True)
class RunTrace:
    request_id: str
    case: AnswerCase
    evidence_version: str | None
    sampled_statuses: tuple[str, ...]

    def __post_init__(self) -> None:
        require_text(self.request_id, "request_id")
        if type(self.case) is not AnswerCase:
            raise ValueError("trace requires a validated answer case")
        if self.evidence_version is not None:
            require_text(self.evidence_version, "trace evidence version")
            if self.evidence_version != incident.version:
                raise ValueError("trace version must match this lab's host snapshot")
        if type(self.sampled_statuses) not in (list, tuple):
            raise ValueError("sampled outputs must be a sequence")
        for value in self.sampled_statuses:
            require_text(value, "sampled output")
        object.__setattr__(self, "sampled_statuses", tuple(self.sampled_statuses))

def trace_verifications(trace: RunTrace) -> list[Verification]:
    if trace.evidence_version is None:
        return [Verification(claim, Verdict.NO_SOURCE, None) for claim in trace.case.claims]
    return [verify_claim(claim) for claim in trace.case.claims]

def first_failed_stage(trace: RunTrace) -> str:
    if trace.evidence_version is None:
        return "evidence_admission"
    if any(item.verdict != Verdict.SUPPORTED for item in trace_verifications(trace)):
        return "claim_generation"
    if not trace.case.claims:
        return "claim_generation"
    if not trace.sampled_statuses:
        return "stability_check_incomplete"
    if disagreement_rate(trace.sampled_statuses) > 0.25:
        return "generation_stability"
    return "passed"

traces = [
    RunTrace("req-clean", cases[0], incident.version, ["investigating"] * 4),
    RunTrace("req-eta", cases[1], incident.version, stable_unsupported_eta_samples),
    RunTrace("req-missing", cases[3], None, ["unknown"] * 4),
    RunTrace("req-vary", cases[0], incident.version, ["investigating", "resolved", "investigating", "mitigated"]),
]

for trace in traces:
    print(f"{trace.request_id:11} -> {first_failed_stage(trace)}")

assert [first_failed_stage(trace) for trace in traces] == [
    "passed",
    "claim_generation",
    "evidence_admission",
    "generation_stability",
]
Output
req-clean   -> passed
req-eta     -> claim_generation
req-missing -> evidence_admission
req-vary    -> generation_stability

Evaluate a candidate gate

These trace labels locate a failed check, not a complete causal diagnosis. A missing source could reflect permissions, a feed outage, or a fabricated citation; inspect the underlying trace before changing retrieval. Research methods add breadth, but don't exercise this incident feed, renderer, or serving policy:

ArtifactTeaches or testsPlace in this workflow
SelfCheckGPT[4]Sample consistency without external factsTriage signal when evidence is absent or costly
Semantic entropy[9]Meaning-level uncertainty over samplesEscalation feature for confabulations, not repeated false beliefs
FActScore[7]Atomic factual precision against a knowledge sourceDesign model for claim-level verification
Ragas Faithfulness[12]Statement support against retrieved contextClosest published analog of this product's claim checks
Incident regression Exact runbook facts and response policyRelease gate for incident-answerer-v1
Candidate regression results: zero unsafe answers among two substantive responses, one of one clean cases answered, and two of four requests answered. Holdout evaluation with an audited grader and a monitoring alert owner are missing, so promotion stays blocked.
Zero unsafe answers among two served responses repairs known fixtures. It doesn't measure production risk. Holdout evaluation and alert ownership are still missing.

Before reading the candidate metrics, predict the trade-off: the baseline serves all four cases and is unsafe on three, while the candidate should remove unsafe serves without losing the clean update.

candidate-gate-metrics.py
def baseline_router(case: AnswerCase) -> dict[str, object]:
    return {"route": "serve", "answer": " ".join(claim.text for claim in case.claims)}

def product_router(case: AnswerCase) -> dict[str, object]:
    return safe_answer(case.claims)

# Hand-authored accepted payloads for this grammar, not an annotation study.
GOLD_UPDATE = (
    "Service: embedding-api. Last event: database failover completed. "
    "Event time: May 26 at 08:14 UTC."
)
GOLD_ANSWERS = MappingProxyType({
    "clean_update": frozenset({GOLD_UPDATE}),
    "invented_eta": frozenset({
        GOLD_UPDATE + " The admitted record provides no support for that recovery estimate."
    }),
    "wrong_status": frozenset(),
    "unadmitted_source": frozenset(),
})
CLEAN_CASE_IDS = frozenset({"clean_update"})
ABSTENTION_TEXTS = frozenset({
    "No verifiable claims were supplied.",
    "The draft conflicts with the incident record. Please review the latest runbook state.",
    "Incident details are unavailable right now.",
    "The cited version is not the admitted snapshot.",
    "The admitted record provides no support for that recovery estimate.",
    "Some requested details are not supported by the admitted record.",
    "Unable to provide a grounded update.",
})

def score_router(router) -> dict[str, float | int | None]:
    # Evaluate every declared case, not a caller-filtered subset.
    suite_cases = tuple(CASE_MANIFEST.values())
    responses = [(case, router(case)) for case in suite_cases]
    for _, response in responses:
        if type(response) is not dict or not {"route", "answer"} <= set(response):
            raise ValueError("router must return route and answer")
        if type(response["answer"]) is not str:
            raise ValueError("answer payload must be a string")
        if type(response["route"]) is not str or response["route"] not in {
            "serve", "shorten", "abstain", "review",
        }:
            raise ValueError("unknown response route")
    # Grade the actual payload. Merely labeling a leaked answer "shorten" is not safe.
    served = [(case, response) for case, response in responses
              if response["answer"] and response["answer"] not in ABSTENTION_TEXTS]
    unsafe_serves = sum(response["answer"] not in GOLD_ANSWERS[case.case_id]
                        for case, response in served)
    supported_serves = sum(case.case_id in CLEAN_CASE_IDS
                           and response["answer"] in GOLD_ANSWERS[case.case_id]
                           for case, response in served)
    return {
        "served": len(served),
        "unsafe_serves": unsafe_serves,
        "unsafe_serve_rate": unsafe_serves / len(served) if served else None,
        "supported_coverage": supported_serves / len(CLEAN_CASE_IDS),
        "response_coverage": len(served) / len(suite_cases),
        "abstentions": len(suite_cases) - len(served),
        "shortens": sum(response["route"] == "shorten" for _, response in responses),
    }

baseline_metrics = score_router(baseline_router)
candidate_metrics = score_router(product_router)

for name, metrics in [("baseline", baseline_metrics), ("candidate", candidate_metrics)]:
    print(
        f"{name:9} unsafe_rate={metrics['unsafe_serve_rate']:.1%} "
        f"clean_coverage={metrics['supported_coverage']:.1%} "
        f"response_coverage={metrics['response_coverage']:.1%} "
        f"shortens={metrics['shortens']} abstentions={metrics['abstentions']}"
    )

assert baseline_metrics["unsafe_serve_rate"] == 0.75
assert candidate_metrics["unsafe_serve_rate"] == 0.0
assert candidate_metrics["supported_coverage"] == 1.0
assert candidate_metrics["shortens"] == 1
assert candidate_metrics["abstentions"] == 2

# Adversarial checks on the scorer itself.
leaky_metrics = score_router(lambda case: {**baseline_router(case), "route": "shorten"})
silent_metrics = score_router(lambda case: {
    "route": "abstain", "answer": "Unable to provide a grounded update."
})
print(f"leaky shorten unsafe_rate={leaky_metrics['unsafe_serve_rate']:.1%}")
print(f"abstain-all unsafe_rate={silent_metrics['unsafe_serve_rate']} (undefined), coverage=0%")
assert leaky_metrics["unsafe_serve_rate"] == 0.75
assert silent_metrics["unsafe_serve_rate"] is None
assert silent_metrics["response_coverage"] == 0
Output
baseline  unsafe_rate=75.0% clean_coverage=100.0% response_coverage=100.0% shortens=0 abstentions=0
candidate unsafe_rate=0.0% clean_coverage=100.0% response_coverage=50.0% shortens=1 abstentions=2
leaky shorten unsafe_rate=75.0%
abstain-all unsafe_rate=None (undefined), coverage=0%

The candidate is a useful repair on four deliberately small cases, not production factuality evidence. It matches the product router taught earlier: the clean update serves, the invented ETA shortens (supported clauses stay, failed claim drops), and the contradicted status plus missing source abstain.

The unsafe-serve denominator changes from four substantive baseline answers to two candidate answers: 3/4 versus 0/2, not 0/4. Clean-case coverage is just 1/1; overall response coverage falls from 4/4 to 2/4. This failure-heavy suite can't estimate production rates.

The exact-answer grader recognizes the host's complete fallback strings as withholding an update. Any other nonempty payload is graded, even when labeled abstain or shorten. It catches leakage in this fixed grammar but rejects harmless paraphrases. A broader evaluator needs independently labeled claims, request usefulness labels, and an audit of its own errors.

Keep the mitigation stack small and testable

For this workflow, the draft adds a fact absent from the admitted snapshot. Start with controls you can test directly against that source, then add complexity when evidence of another failure justifies it:

LayerInvariantFailure it prevents
Admit evidenceCheck identity, authorization, freshness; retain source versionUntrusted or stale evidence entering the answer
Generate conservativelyAsk only for claims licensed by source fieldsAn intervention to test, not an enforced guarantee
Verify and renderCheck each structured value; render only verified fieldsUnchecked draft text bypassing verification
Route safelyFailed checks trigger removal, abstention, or reviewServing claims that the verifier rejected
Measure after launchAggregate verdicts, versions, routes, and usefulness; assign a response ownerHelps detect recurrence and guide investigation

Hand the next lesson a trace

The next chapter can't monitor a property that the trace never records. This minimal event supports counters and links to a detailed trace by request ID. Counts and version strings alone can't reconstruct an answer. Keep protected, access-controlled records of admitted snapshots, claim/verdict pairs, actual payloads, admission results, extractor/verifier/policy versions, and sampling settings when needed for debugging. Define retention around incident sensitivity.

Predict the promotion result from the contract: known regressions should pass, but missing holdout coverage and alert ownership must keep the candidate blocked.

release-trace-contract.py
@dataclass(frozen=True)
class MonitorEvent:
    request_id: str
    case_id: str
    evidence_version: str | None
    route: str
    first_failed_stage: str
    verdict_counts: Mapping[str, int]
    claim_verdicts: tuple[tuple[str, str], ...]

def trace_route(trace: RunTrace) -> str:
    if trace.evidence_version is None:
        return "abstain"
    signal_route = route_with_signals(trace.case, trace.sampled_statuses)
    return {
        "abstain_evidence_failure": "abstain",
        "shorten_unsupported_detail": "shorten",
        "review_unstable_generation": "review",
        "serve_supported_answer": "serve",
    }[signal_route]

def monitor_event(trace: RunTrace) -> MonitorEvent:
    results = trace_verifications(trace)
    counts = Counter(item.verdict.value for item in results)
    return MonitorEvent(
        request_id=trace.request_id,
        case_id=trace.case.case_id,
        evidence_version=trace.evidence_version,
        route=trace_route(trace),
        first_failed_stage=first_failed_stage(trace),
        verdict_counts=MappingProxyType(dict(counts)),
        claim_verdicts=tuple((item.claim.claim_id, item.verdict.value) for item in results),
    )

REQUIRED_REQUIREMENTS = (
    "known_unsafe_claims_blocked_and_clean_case_kept",
    "source_version_logged_when_admitted",
    "representative_holdout_evaluated_with_audited_grader",
    "monitoring_alert_owner_assigned",
)

def promotion_decision(records: dict[str, bool]) -> tuple[str, list[str]]:
    if type(records) is not dict or set(records) != set(REQUIRED_REQUIREMENTS):
        raise ValueError("every declared release requirement must be present")
    if any(type(value) is not bool for value in records.values()):
        raise ValueError("release results must be booleans")
    failures = [name for name in REQUIRED_REQUIREMENTS if not records[name]]
    return ("BLOCKED" if failures else "APPROVED"), failures

events = [monitor_event(trace) for trace in traces]
requirements = {
    "known_unsafe_claims_blocked_and_clean_case_kept": (
        candidate_metrics["unsafe_serves"] == 0
        and candidate_metrics["supported_coverage"] == 1.0
    ),
    "source_version_logged_when_admitted": all(
        event.evidence_version is not None
        for event in events
        if event.first_failed_stage != "evidence_admission"
    ),
    "representative_holdout_evaluated_with_audited_grader": False,
    "monitoring_alert_owner_assigned": False,
}
decision, failed_requirements = promotion_decision(requirements)

print(f"Candidate promotion: {decision}")
for name in failed_requirements:
    print(f"  missing: {name}")
print(f"Example failed stage: {events[1].first_failed_stage}")
print(f"Example review route: {events[3].route}")

assert decision == "BLOCKED"
assert [event.route for event in events] == ["serve", "shorten", "abstain", "review"]
assert [event.evidence_version for event in events] == [
    incident.version,
    incident.version,
    None,
    incident.version,
]
Output
Candidate promotion: BLOCKED
  missing: representative_holdout_evaluated_with_audited_grader
  missing: monitoring_alert_owner_assigned
Example failed stage: claim_generation
Example review route: review

The candidate passes the known fixture checks, but promotion remains blocked. It still needs representative held-out evaluation, an audited grader, and an operational owner for alerts. The release helper rejects omitted requirements and truthy substitutes such as "false"; a production gate must obtain approvals and results from trusted records.

Set acceptance thresholds on development data, then measure false acceptance, false rejection, completeness, coverage, and cost on held-out cases. Trace versions and claim identifiers aid reconstruction when the underlying artifacts are retained; they don't establish freshness or world truth. These are properties an system should distinguish.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.An admitted record for incident-INC-48291 has status=investigating, last_event=database failover completed, and last_event_at=May 26 at 08:14 UTC, but no recovery_eta. What verdicts fit recovery_eta=14:30 UTC from that record, status=resolved from that record, and service=embedding-api from missing-feed?
2.A draft says Service: embedding-api. Status: resolved. Last event: database failover completed on May 26 at 08:14 UTC. Which claim representation gives the router the right granularity?
3.A candidate contains supported service, event, and event-time claims plus an unsupported recovery ETA. What should the safe-answer route return?
4.Four sampled generations repeat Full recovery by 14:30 UTC, giving disagreement 0.0, while the admitted record still lacks recovery_eta. The mixed draft also has supported service and event claims. Which route follows the evidence-first router?
5.A response attaches incident-INC-48291@runbook-feed/2026-05-27T10:00:00Z to Full recovery is expected by 14:30 UTC, but that cited version has no recovery_eta. What must citation validation do?
6.A regression suite records supported=6, not_supported=1, contradicted=1, and no_source=1 across four cases. Three cases contain a failed claim, and a baseline serves every case. What are claim support rate and unsafe serve rate?
7.The correct incident record was admitted, but generation adds recovery_eta=14:30 UTC, a field absent from that record. Which first failed stage and repair fit the trace?
8.A claim has verified field=service and value=embedding-api, but its text also promises recovery by 14:30 UTC. What closes the bypass in this structured lab?
9.A candidate serves two of four fixtures with zero unsafe answers and keeps the only clean case. It has no evaluated representative holdout or monitoring alert owner. What is the release decision?
10.The admitted record says status=investigating and the draft repeats it, so the claim is supported. A live dashboard says the incident recovered an hour ago. What did this verdict establish?

10 questions remaining.

References

BLEU: a Method for Automatic Evaluation of Machine Translation.

Papineni, K., et al. · 2002 · ACL 2002

https://aclanthology.org/P02-1040

ROUGE: A Package for Automatic Evaluation of Summaries.

Lin, C.-Y. · 2004 · Text Summarization Branches Out, ACL 2004

https://aclanthology.org/W04-1013/

A Survey on Hallucination in Large Language Models: Principles, Taxonomy, Challenges, and Open Questions

Huang et al. · 2023

https://arxiv.org/abs/2311.05232

SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models.

Manakul, P., et al. · 2023 · EMNLP 2023

https://arxiv.org/abs/2303.08896

Trusting Your Evidence: Hallucinate Less with Context-aware Decoding

Shi, W., Han, X., Lewis, M., Tsvetkov, Y., Zettlemoyer, L., & Yih, W.-t. · 2024 · NAACL 2024

https://aclanthology.org/2024.naacl-short.69/

Why Language Models Hallucinate

Kalai, A. T., Nachum, O., Vempala, S. S., & Zhang, E. · 2025

https://arxiv.org/abs/2509.04664

FActScore: Fine-grained Atomic Evaluation of Factual Precision in Long Form Text Generation.

Min, S., et al. · 2023 · EMNLP 2023

https://arxiv.org/abs/2305.14251

Ragas: Faithfulness

Ragas maintainers · 2026

https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/faithfulness/

Detecting Hallucinations in Large Language Models Using Semantic Entropy

Farquhar, S., et al. · 2024 · Nature

https://www.nature.com/articles/s41586-024-07421-0

DoLa: Decoding by Contrasting Layers Improves Factuality in Large Language Models

Chuang, Y.-S., Xie, Y., Luo, H., Kim, Y., Glass, J., & He, P. · 2024 · ICLR 2024

https://arxiv.org/abs/2309.03883

Chain-of-Verification Reduces Hallucination in Large Language Models

Dhuliawala, S., et al. · 2024 · Findings of ACL 2024

https://aclanthology.org/2024.findings-acl.212/

RAGAS: Automated Evaluation of Retrieval Augmented Generation.

Es, S., et al. · 2023 · arXiv preprint

https://arxiv.org/abs/2309.15217

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.