Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
The fairness lesson blocked a soft evaluator when it could route equivalent user requests differently. Factual reliability needs an equally strict boundary: a fluent large language model (LLM) answer can't invent an incident event just because the on-call engineer wants certainty.
The next incident question asks, "Is the embedding API fully recovered?" The admitted runbook update says the database failover completed on May 26 at 08:14 UTC and service remains investigating. It contains no recovery time. A draft answer that adds "Full recovery is expected by 14:30 UTC" may sound helpful, but the system has no source for that promise.
incident-answerer-v1 is a small serving gate. It turns an answer into atomic claims, checks each claim against versioned evidence, routes unsupported details to abstention, and records why release remains blocked. For a grounded incident answer, "not present in admitted evidence" is enough reason not to serve a factual claim. It doesn't prove the claim is false in the wider world.
Why overlap and answer-level scores miss incident failures
Sequence-overlap metrics and one answer-level judgment don't establish claim support:
- Lexical overlap misses operational polarity: BLEU and ROUGE compare surface overlap rather than whether admitted evidence supports a claim.[1] "Database failover failed" and "Database failover succeeded" share most words but imply opposite actions.
- One score hides a critical false claim: 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.
Parametric override and the role of NLI verifiers
A generative model may ignore retrieved context and fall back to a stronger parametric association. For example, a log can state that Redis uses port 6380 while the draft substitutes the common default, 6379. Treat this contextual disregard as one failure mode, not a complete explanation for hallucinations.
An independent verifier can compare each atomic claim with admitted evidence. A Natural Language Inference (NLI) classifier is one candidate: it predicts whether a premise entails, contradicts, or is neutral toward a hypothesis. Those labels are model predictions, not mathematical guarantees. Calibrate the verifier on domain examples, preserve an abstain or review path, and keep deterministic checks for fields such as timestamps and status codes.
Three verdicts are enough to start
Hallucination taxonomies can become abstract quickly. For a retrieved incident record, begin with the source relationship:
| Verdict | Meaning in this product | Incident-update example | Serving action |
|---|---|---|---|
| Supported | Admitted source states the fact | "Failover completed on May 26." | May be served |
| Not supported | Admitted source doesn't establish the fact | "Full recovery by 14:30 UTC." | Remove or abstain |
| Contradicted | Admitted source states an incompatible fact | "Incident is resolved." while status is investigating | Block and investigate |
Prior hallucination taxonomies distinguish outputs that conflict with supplied context from outputs that can't be verified from that context.[2] In an open-world setting, an unsupported statement might later turn out true. In this answer path it's still unsafe, because the product promised evidence-grounded incident updates.

Put the evidence in code
The incident record isn't decorative text. It's the authority the answer must obey. Our first cell records its source ID and version alongside the candidate claims.
1from collections import Counter
2from dataclasses import dataclass
3from enum import Enum
4
5@dataclass(frozen=True)
6class Evidence:
7 source_id: str
8 version: str
9 facts: dict[str, str]
10
11@dataclass(frozen=True)
12class Claim:
13 claim_id: str
14 text: str
15 field: str
16 value: str
17 citation_id: str
18
19incident = Evidence(
20 source_id="incident-INC-48291",
21 version="runbook-feed/2026-05-27T10:00:00Z",
22 facts={
23 "service": "embedding-api",
24 "status": "investigating",
25 "last_event": "database failover completed",
26 "last_event_at": "May 26 at 08:14 UTC",
27 },
28)
29sources = {incident.source_id: incident}
30
31draft_claims = [
32 Claim("service", "Service: embedding-api.", "service", "embedding-api", incident.source_id),
33 Claim("event", "Last event: database failover completed.", "last_event", "database failover completed", incident.source_id),
34 Claim("event_time", "Event time: May 26 at 08:14 UTC.", "last_event_at", "May 26 at 08:14 UTC", incident.source_id),
35 Claim("eta", "Full recovery is expected by 14:30 UTC.", "recovery_eta", "14:30 UTC", incident.source_id),
36]
37
38print(f"Evidence version: {incident.version}")
39print(f"Available facts: {sorted(incident.facts)}")
40print(f"Draft factual claims: {len(draft_claims)}")1Evidence version: runbook-feed/2026-05-27T10:00:00Z
2Available facts: ['last_event', 'last_event_at', 'service', 'status']
3Draft factual claims: 4An LLM can write the recovery-time sentence because it has seen incident-update patterns. That doesn't make the sentence admissible. The admitted evidence has no recovery_eta field.
Verify each atomic claim
FActScore evaluates long-form generations by decomposing them into atomic facts and measuring how many a reliable source supports.[3] The verifier below uses the same reasoning on a small operational record: a claim is either supported by its cited source, missing from that source, contradicted by a different value, or missing an admitted source entirely.
1class Verdict(str, Enum):
2 SUPPORTED = "supported"
3 NOT_SUPPORTED = "not_supported"
4 CONTRADICTED = "contradicted"
5 NO_SOURCE = "no_source"
6
7@dataclass(frozen=True)
8class Verification:
9 claim: Claim
10 verdict: Verdict
11 evidence_version: str | None
12
13def verify_claim(claim: Claim) -> Verification:
14 source = sources.get(claim.citation_id)
15 if source is None:
16 return Verification(claim, Verdict.NO_SOURCE, None)
17 expected = source.facts.get(claim.field)
18 if expected is None:
19 return Verification(claim, Verdict.NOT_SUPPORTED, source.version)
20 if expected != claim.value:
21 return Verification(claim, Verdict.CONTRADICTED, source.version)
22 return Verification(claim, Verdict.SUPPORTED, source.version)
23
24draft_verdicts = [verify_claim(claim) for claim in draft_claims]
25resolved_claim = Claim(
26 "resolved",
27 "Incident INC-48291 is resolved.",
28 "status",
29 "resolved",
30 incident.source_id,
31)
32
33assert [item.verdict for item in draft_verdicts] == [
34 Verdict.SUPPORTED,
35 Verdict.SUPPORTED,
36 Verdict.SUPPORTED,
37 Verdict.NOT_SUPPORTED,
38]
39assert verify_claim(resolved_claim).verdict == Verdict.CONTRADICTED
40
41for result in draft_verdicts + [verify_claim(resolved_claim)]:
42 print(f"{result.claim.claim_id:10} {result.verdict.value:15} {result.claim.text}")1service supported Service: embedding-api.
2event supported Last event: database failover completed.
3event_time supported Event time: May 26 at 08:14 UTC.
4eta not_supported Full recovery is expected by 14:30 UTC.
5resolved contradicted Incident INC-48291 is resolved.The distinction matters. Recovery time isn't proven false; it's absent from this source. The resolved statement is worse because it conflicts with status=investigating.
Route the response as well as the score
A detector that writes a warning beside an unsafe response has not protected the reader. Once any factual claim fails, incident-answerer-v1 keeps supported information when possible and replaces the failed detail with a bounded statement. If no safe factual clause remains, it abstains.
1def bounded_fallback(failures: list[Verification]) -> str:
2 if any(item.verdict == Verdict.CONTRADICTED for item in failures):
3 return "The draft conflicts with the incident record. Please review the latest runbook state."
4 if any(item.verdict == Verdict.NO_SOURCE for item in failures):
5 return "Incident details are unavailable right now."
6 return "The incident record does not provide a recovery estimate yet."
7
8def safe_answer(claims: list[Claim]) -> dict[str, object]:
9 verdicts = [verify_claim(claim) for claim in claims]
10 failures = [item for item in verdicts if item.verdict != Verdict.SUPPORTED]
11 supported_text = " ".join(
12 item.claim.text for item in verdicts if item.verdict == Verdict.SUPPORTED
13 )
14 if failures:
15 return {
16 "route": "shorten" if supported_text else "abstain",
17 "answer": " ".join(part for part in [supported_text, bounded_fallback(failures)] if part),
18 "blocked_claims": [item.claim.claim_id for item in failures],
19 }
20 return {
21 "route": "serve",
22 "answer": supported_text,
23 "blocked_claims": [],
24 }
25
26decision = safe_answer(draft_claims)
27conflict_decision = safe_answer([resolved_claim])
28
29assert decision["route"] == "shorten"
30assert decision["blocked_claims"] == ["eta"]
31assert "14:30" not in str(decision["answer"])
32assert conflict_decision["route"] == "abstain"
33assert "resolved" not in str(conflict_decision["answer"])
34
35print(f"Route: {decision['route']}")
36print(f"Blocked claims: {decision['blocked_claims']}")
37print(f"Served answer: {decision['answer']}")
38print(f"Contradicted-only route: {conflict_decision['route']}")1Route: shorten
2Blocked claims: ['eta']
3Served answer: Service: embedding-api. Last event: database failover completed. Event time: May 26 at 08:14 UTC. The incident record does not provide a recovery estimate yet.
4Contradicted-only route: abstainWhy doesn't not_supported mean the recovery time is false?
Answer
The verifier has scoped authority: the admitted incident record. It can show that the system has no evidence for a recovery time, which is enough to withhold the claim, but it can't claim to know every fact in the outside world.
Measure failure before choosing mitigation
A single blocked recovery time gives one regression case, not release evidence. Build a small suite that separates four failure modes: clean update, unsupported estimate, contradiction, and missing source.

1@dataclass(frozen=True)
2class AnswerCase:
3 case_id: str
4 claims: list[Claim]
5
6cases = [
7 AnswerCase("clean_update", draft_claims[:3]),
8 AnswerCase("invented_eta", draft_claims),
9 AnswerCase("wrong_status", [resolved_claim]),
10 AnswerCase(
11 "unadmitted_source",
12 [Claim("service", "Service: embedding-api.", "service", "embedding-api", "missing-feed")],
13 ),
14]
15
16def has_unsafe_claim(case: AnswerCase) -> bool:
17 return any(verify_claim(claim).verdict != Verdict.SUPPORTED for claim in case.claims)
18
19baseline_served_unsafe = sum(has_unsafe_claim(case) for case in cases)
20verdict_counts = Counter(
21 verify_claim(claim).verdict.value
22 for case in cases
23 for claim in case.claims
24)
25claim_support_rate = verdict_counts["supported"] / sum(verdict_counts.values())
26
27print(f"Baseline unsafe serves if all drafts ship: {baseline_served_unsafe}/{len(cases)}")
28print(f"Claim verdict counts: {dict(verdict_counts)}")
29print(f"Claim support rate: {claim_support_rate:.1%}")
30assert baseline_served_unsafe == 3
31assert claim_support_rate == 2 / 31Baseline unsafe serves if all drafts ship: 3/4
2Claim verdict counts: {'supported': 6, 'not_supported': 1, 'contradicted': 1, 'no_source': 1}
3Claim support rate: 66.7%For a grounded incident-status product, three simple metrics are useful:
| Metric | Calculation | Release meaning |
|---|---|---|
| Claim support rate | Supported factual claims / all factual claims | How much draft content evidence admits |
| Unsafe serve rate | Served answers containing any failed factual claim / served answers | Whether bad claims reach readers |
| Abstention rate | Answers withheld or safely shortened / total requests | Cost of being cautious |
Claim support can improve while unsafe serves remain unacceptable. A single contradicted incident status shown to an on-call engineer is still a serious failure.
Consistency is an alarm, not evidence
Black-box methods such as SelfCheckGPT compare an answer with additional sampled generations; disagreement can flag content the model may be inventing without an external database.[4] Semantic entropy groups sampled answers by meaning and measures uncertainty over those clusters.[5] Use these signals when no admitted source is available or when you need to prioritize expensive checks.
They don't authorize a recovery-time claim. A model can repeat the same unsupported estimate every time.

1def disagreement_rate(values: list[str]) -> float:
2 most_common_count = Counter(values).most_common(1)[0][1]
3 return 1 - most_common_count / len(values)
4
5unstable_eta_samples = ["14:30 UTC", "14:45 UTC", "14:30 UTC", "15:00 UTC"]
6stable_false_eta_samples = ["14:30 UTC", "14:30 UTC", "14:30 UTC", "14:30 UTC"]
7eta_verdict = verify_claim(draft_claims[-1]).verdict
8
9print(f"Unstable recovery estimate disagreement: {disagreement_rate(unstable_eta_samples):.2f}")
10print(f"Repeated recovery estimate disagreement: {disagreement_rate(stable_false_eta_samples):.2f}")
11print(f"Repeated recovery estimate evidence verdict: {eta_verdict.value}")
12
13assert disagreement_rate(stable_false_eta_samples) == 0.0
14assert eta_verdict == Verdict.NOT_SUPPORTED1Unstable recovery estimate disagreement: 0.50
2Repeated recovery estimate disagreement: 0.00
3Repeated recovery estimate evidence verdict: not_supportedThis fixes a common misconception: low uncertainty means "the model repeats itself," not "the fact is true."
Combine signals with the right authority
Evidence failure blocks claim even when samples agree. When evidence passes but generation is unstable, consistency can route case to review instead of serving answer that changes run to run.
1def route_with_signals(case: AnswerCase, sampled_statuses: list[str]) -> str:
2 if has_unsafe_claim(case):
3 return "abstain_evidence_failure"
4 if disagreement_rate(sampled_statuses) > 0.25:
5 return "review_unstable_generation"
6 return "serve_supported_answer"
7
8clean_case = cases[0]
9eta_case = cases[1]
10
11routes = {
12 "clean_stable": route_with_signals(clean_case, ["investigating"] * 4),
13 "clean_unstable": route_with_signals(
14 clean_case, ["investigating", "investigating", "mitigated", "resolved"]
15 ),
16 "eta_repeated": route_with_signals(eta_case, stable_false_eta_samples),
17}
18
19for name, route_name in routes.items():
20 print(f"{name:14} -> {route_name}")
21
22assert routes["clean_stable"] == "serve_supported_answer"
23assert routes["clean_unstable"] == "review_unstable_generation"
24assert routes["eta_repeated"] == "abstain_evidence_failure"1clean_stable -> serve_supported_answer
2clean_unstable -> review_unstable_generation
3eta_repeated -> abstain_evidence_failureFive sampled answers repeat the same unsupported claim. Does that consistency make the claim grounded?
Answer
No. Consistency can reveal instability when outputs disagree, but agreement can still repeat one unsupported model belief. Grounding requires comparison with trusted evidence.
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.
1def cited_sentence(claim: Claim) -> str:
2 result = verify_claim(claim)
3 if result.verdict != Verdict.SUPPORTED:
4 raise ValueError(f"cannot cite {claim.claim_id}: {result.verdict.value}")
5 return f"{claim.text} [{claim.citation_id}@{result.evidence_version}]"
6
7served_sentences = [cited_sentence(claim) for claim in draft_claims[:3]]
8served_answer = " ".join(served_sentences)
9
10try:
11 cited_sentence(draft_claims[-1])
12except ValueError as error:
13 blocked_citation = str(error)
14
15print(served_answer)
16print(blocked_citation)
17assert "14:30" not in served_answer
18assert "not_supported" in blocked_citation1Service: 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]
2cannot cite eta: not_supportedThis is a narrower version of claim-level factual evaluation: decompose, retrieve or admit evidence, verify, and retain the provenance for failed and passed claims. It also extends the RAG evaluation lesson: citation presence is no longer confused with citation support.
An answer includes a valid-looking citation URL. What must a citation checker verify beyond its presence?
Answer
It must resolve the cited source and test whether the source supports the nearby claim. A real URL can still be irrelevant, stale, or contradicted by the answer.
Attribute failures before adding complexity
An unsupported answer may begin with retrieval or generation:
| First failed stage | Symptom | Appropriate next action |
|---|---|---|
| Evidence admission | No incident record was retrieved for a request | Fix retrieval, permissions, freshness, or tool failure |
| Claim generation | Source exists, but answer adds unsupported recovery time | Tighten generation and post-generation claim gate |
| Consistency only | Supported facts vary across samples | Review decoding or prompt stability; don't call it a source failure |
Chain-of-Verification has a model draft an answer, plan verification questions, answer them independently, and produce a revised response. The paper reports fewer hallucinations across list questions, closed-book question answering, and long-form generation.[6] That's a possible additional generator control. It doesn't replace an authoritative incident record or the final claim gate in this product.
1@dataclass(frozen=True)
2class RunTrace:
3 request_id: str
4 case: AnswerCase
5 evidence_version: str | None
6 sampled_statuses: list[str]
7
8def first_failed_stage(trace: RunTrace) -> str:
9 if trace.evidence_version is None:
10 return "evidence_admission"
11 if has_unsafe_claim(trace.case):
12 return "claim_generation"
13 if disagreement_rate(trace.sampled_statuses) > 0.25:
14 return "generation_stability"
15 return "passed"
16
17traces = [
18 RunTrace("req-clean", cases[0], incident.version, ["investigating"] * 4),
19 RunTrace("req-eta", cases[1], incident.version, stable_false_eta_samples),
20 RunTrace("req-missing", cases[3], None, ["unknown"] * 4),
21 RunTrace("req-vary", cases[0], incident.version, ["investigating", "resolved", "investigating", "mitigated"]),
22]
23
24for trace in traces:
25 print(f"{trace.request_id:11} -> {first_failed_stage(trace)}")
26
27assert [first_failed_stage(trace) for trace in traces] == [
28 "passed",
29 "claim_generation",
30 "evidence_admission",
31 "generation_stability",
32]1req-clean -> passed
2req-eta -> claim_generation
3req-missing -> evidence_admission
4req-vary -> generation_stabilityEvaluate a candidate gate
Research benchmarks help compare methods, but they don't execute this incident feed, prompt, citation format, or serving route. Use external datasets for breadth and product regressions for release:
| Artifact | Teaches or tests | Place in this workflow |
|---|---|---|
| SelfCheckGPT[4] | Sample consistency without external facts | Triage signal when evidence is absent or costly |
| Semantic entropy[5] | Meaning-level uncertainty over samples | Escalation feature for confabulations |
| FActScore[3] | Atomic factual precision | Design model for claim-level verification |
| Incident regression traces | Exact runbook facts and response policy | Release gate for incident-answerer-v1 |

1def baseline_router(case: AnswerCase) -> str:
2 return "serve"
3
4def product_router(case: AnswerCase) -> str:
5 """Mirror safe_answer: serve supported-only drafts, shorten partial drafts, abstain otherwise."""
6 verdicts = [verify_claim(claim).verdict for claim in case.claims]
7 failures = [verdict for verdict in verdicts if verdict != Verdict.SUPPORTED]
8 has_supported = any(verdict == Verdict.SUPPORTED for verdict in verdicts)
9 if not failures:
10 return "serve"
11 if has_supported:
12 return "shorten"
13 return "abstain"
14
15SAFE_ROUTES = {"serve", "shorten"}
16
17def score_router(router) -> dict[str, float | int]:
18 # shorten counts as a safe user-visible response (failed claims removed), not as unsafe serve.
19 served = [case for case in cases if router(case) in SAFE_ROUTES]
20 unsafe_serves = sum(
21 router(case) == "serve" and has_unsafe_claim(case) for case in cases
22 )
23 supported_cases = [case for case in cases if not has_unsafe_claim(case)]
24 supported_serves = sum(router(case) in SAFE_ROUTES for case in supported_cases)
25 return {
26 "served": len(served),
27 "unsafe_serves": unsafe_serves,
28 "unsafe_serve_rate": unsafe_serves / max(len(served), 1),
29 "supported_coverage": supported_serves / len(supported_cases),
30 "abstentions": sum(router(case) == "abstain" for case in cases),
31 "shortens": sum(router(case) == "shorten" for case in cases),
32 }
33
34baseline_metrics = score_router(baseline_router)
35candidate_metrics = score_router(product_router)
36
37for name, metrics in [("baseline", baseline_metrics), ("candidate", candidate_metrics)]:
38 print(
39 f"{name:9} unsafe_rate={metrics['unsafe_serve_rate']:.1%} "
40 f"coverage={metrics['supported_coverage']:.1%} "
41 f"shortens={metrics['shortens']} abstentions={metrics['abstentions']}"
42 )
43
44assert baseline_metrics["unsafe_serve_rate"] == 0.75
45assert candidate_metrics["unsafe_serve_rate"] == 0.0
46assert candidate_metrics["supported_coverage"] == 1.0
47assert candidate_metrics["shortens"] == 1
48assert candidate_metrics["abstentions"] == 21baseline unsafe_rate=75.0% coverage=100.0% shortens=0 abstentions=0
2candidate unsafe_rate=0.0% coverage=100.0% shortens=1 abstentions=2This is a useful repair on four deliberately small cases. It isn't enough to claim production factuality. The candidate 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 suite is still failure-heavy by design, so two of four requests withhold entirely.
Keep the mitigation stack small and testable
For this workflow, extra decoding tricks aren't next priority. Failure is already local: the draft adds a fact absent from a live source. Start with controls you can test directly against that source:

| Layer | Invariant | Failure it prevents |
|---|---|---|
| Admit evidence | Record source ID and version before answer generation | Stale or untraceable facts |
| Generate conservatively | Ask only for claims licensed by source fields | Unnecessary unsupported detail |
| Verify claims | Every factual clause receives a verdict | Fluent fabrications |
| Route safely | Failed clauses trigger removal, abstention, or review | Unsafe operational promises |
| Measure after launch | Retain verdicts, versions, route, and owner | Silent recurrence |
Hand the next lesson a trace
The next chapter can't monitor a correctness property that the trace never records. Store enough information to reconstruct the answer decision without retaining unnecessary incident text: source versions, verdict counts, route, and failure stage.
1@dataclass(frozen=True)
2class MonitorEvent:
3 request_id: str
4 evidence_version: str | None
5 route: str
6 first_failed_stage: str
7 verdict_counts: dict[str, int]
8
9def trace_route(trace: RunTrace) -> str:
10 if trace.evidence_version is None:
11 return "abstain"
12 signal_route = route_with_signals(trace.case, trace.sampled_statuses)
13 return {
14 "abstain_evidence_failure": "abstain",
15 "review_unstable_generation": "review",
16 "serve_supported_answer": "serve",
17 }[signal_route]
18
19def monitor_event(trace: RunTrace) -> MonitorEvent:
20 counts = Counter(verify_claim(claim).verdict.value for claim in trace.case.claims)
21 return MonitorEvent(
22 request_id=trace.request_id,
23 evidence_version=trace.evidence_version,
24 route=trace_route(trace),
25 first_failed_stage=first_failed_stage(trace),
26 verdict_counts=dict(counts),
27 )
28
29events = [monitor_event(trace) for trace in traces]
30requirements = {
31 "known_unsafe_claims_not_served": candidate_metrics["unsafe_serves"] == 0,
32 "source_version_logged_when_admitted": all(
33 event.evidence_version is not None
34 for event in events
35 if event.first_failed_stage != "evidence_admission"
36 ),
37 "representative_labeled_holdout_collected": False,
38 "monitoring_alert_owner_assigned": False,
39}
40failed_requirements = [name for name, passed in requirements.items() if not passed]
41decision = "APPROVED" if not failed_requirements else "BLOCKED"
42
43print(f"Candidate promotion: {decision}")
44for name in failed_requirements:
45 print(f" missing: {name}")
46print(f"Example failed stage: {events[1].first_failed_stage}")
47print(f"Example review route: {events[3].route}")
48
49assert decision == "BLOCKED"
50assert [event.route for event in events] == ["serve", "abstain", "abstain", "review"]
51assert [event.evidence_version for event in events] == [
52 incident.version,
53 incident.version,
54 None,
55 incident.version,
56]1Candidate promotion: BLOCKED
2 missing: representative_labeled_holdout_collected
3 missing: monitoring_alert_owner_assigned
4Example failed stage: claim_generation
5Example review route: reviewThe candidate gate fixes its known fabricated-recovery-time regressions, but promotion remains blocked. It still needs a representative labeled holdout and an operational owner for alerts. The code has now produced exactly the facts an observability system should aggregate.