Build a claim-level grounding gate for incident updates that verifies evidence, catches confident fabrication, abstains safely, and records release traces.
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.
Sequence-overlap metrics and one answer-level judgment don't establish claim support:
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.
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.
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.
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.
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: abstainA 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.
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."
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_failureA 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 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_stabilityResearch 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 grounded_router(case: AnswerCase) -> str:
5 return "abstain" if has_unsafe_claim(case) else "serve"
6
7def score_router(router) -> dict[str, float | int]:
8 served = [case for case in cases if router(case) == "serve"]
9 unsafe_serves = sum(has_unsafe_claim(case) for case in served)
10 supported_cases = [case for case in cases if not has_unsafe_claim(case)]
11 supported_serves = sum(router(case) == "serve" for case in supported_cases)
12 return {
13 "served": len(served),
14 "unsafe_serves": unsafe_serves,
15 "unsafe_serve_rate": unsafe_serves / max(len(served), 1),
16 "supported_coverage": supported_serves / len(supported_cases),
17 "abstentions": len(cases) - len(served),
18 }
19
20baseline_metrics = score_router(baseline_router)
21candidate_metrics = score_router(grounded_router)
22
23for name, metrics in [("baseline", baseline_metrics), ("candidate", candidate_metrics)]:
24 print(
25 f"{name:9} unsafe_rate={metrics['unsafe_serve_rate']:.1%} "
26 f"coverage={metrics['supported_coverage']:.1%} abstentions={metrics['abstentions']}"
27 )
28
29assert baseline_metrics["unsafe_serve_rate"] == 0.75
30assert candidate_metrics["unsafe_serve_rate"] == 0.0
31assert candidate_metrics["supported_coverage"] == 1.01baseline unsafe_rate=75.0% coverage=100.0% abstentions=0
2candidate unsafe_rate=0.0% coverage=100.0% abstentions=3This is a useful repair on four deliberately small cases. It isn't enough to claim production factuality. The candidate abstains on three of four requests here because the test suite is failure-heavy by design.
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 |
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.
| Capability | Working proof |
|---|---|
| Separate unsupported facts from contradictions | Atomic verdicts block both invented recovery times and status conflicts without confusing missing evidence with outside-world falsity |
| Route incident answers safely | The answer path shortens a partially supported draft and abstains when no safe factual clause remains |
| Combine evidence with consistency probes | Source failures abstain even when samples agree; unstable supported generations route to review |
| Measure and trace a candidate gate | Claim support, unsafe-serve, and abstention metrics feed a versioned event with route and first failed stage |
Answer every question, then check your score. Score above 75% to mark this lesson complete.
9 questions remaining.
ROUGE: A Package for Automatic Evaluation of Summaries.
Lin, C.-Y. · 2004 · Text Summarization Branches Out, ACL 2004
A Survey on Hallucination in Large Language Models
Huang et al. · 2023
FActScore: Fine-grained Atomic Evaluation of Factual Precision in Long Form Text Generation.
Min, S., et al. · 2023 · EMNLP 2023
SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models.
Manakul, P., et al. · 2023 · EMNLP 2023
Detecting Hallucinations in Large Language Models Using Semantic Entropy
Farquhar, S., et al. · 2024 · Nature
Chain-of-Verification Reduces Hallucination in Large Language Models
Dhuliawala, S., et al. · 2023
Questions and insights from fellow learners.