LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnApplied LLM EngineeringHallucination Detection & Mitigation
🛡️MediumAlignment & Safety

Hallucination Detection & Mitigation

Build a claim-level grounding gate for incident updates that verifies evidence, catches confident fabrication, abstains safely, and records release traces.

19 min read
Learning path
Step 77 of 196 in the full curriculum
Bias & Fairness in LLMsLLM Observability & Monitoring

Personalize this lesson

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

An on-call engineer asks a simple question: is the embedding API fully recovered? The runbook update accepted as evidence 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 time. Yet a draft adds, "Full recovery is expected by 14:30 UTC." The sentence sounds useful, but the system has no source for that promise.

Would you serve that draft? The fairness lesson blocked a judge that routed equivalent requests differently. This lesson applies the same hard stop to incident facts: a fluent large language model (LLM) answer can't turn an absent field into a fact just because someone wants certainty.

We'll build incident-answerer-v1 as a small serving gate. It splits an answer into atomic claims, checks each claim against a versioned record, routes unsupported details to abstention or a bounded rewrite, and records why release stays blocked. For this product, "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

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

Bilingual Evaluation Understudy (BLEU) and Recall-Oriented Understudy for Gisting Evaluation (ROUGE) compare surface overlap, not whether admitted evidence supports a claim.[1]Reference 1BLEU: a Method for Automatic Evaluation of Machine Translation.https://aclanthology.org/P02-1040[2]Reference 2ROUGE: A Package for Automatic Evaluation of Summaries.https://aclanthology.org/W04-1013/ "Database failover failed" and "Database failover succeeded" share most of their words, but the first calls for investigation 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?

Faithfulness to the record is the product contract

Before choosing a detector, decide what "true" means for this product. Natural language generation work calls a claim intrinsically hallucinated when it conflicts with supplied context, and extrinsically hallucinated when it adds an assertion the context can't verify. Huang et al. survey that split.[3]Reference 3A Survey on Hallucination in Large Language Modelshttps://arxiv.org/abs/2311.05232

That distinction separates factuality (agreement with verifiable world knowledge) from faithfulness (staying inside the user's instructions and provided context). An incident gate can enforce the second contract even when it can't establish the first.

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 generator can also ignore the retrieved record and fall back to a stronger parametric habit. Suppose a log states that Redis uses port 6380 while the draft substitutes the common default, 6379. That's an intrinsic hallucination: the model replaced an admitted fact with its learned prior.

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 predictions, not mathematical guarantees. Calibrate the verifier on domain examples, keep an abstain or review path, and use deterministic checks for fields such as timestamps and status codes.

For a retrieved incident record, three verdicts are enough to start:

VerdictMeaning in this productIncident-update exampleServing action
SupportedAdmitted source states the fact"Failover completed on May 26."May be served
Not supportedExtrinsic: source doesn't establish the fact"Full recovery by 14:30 UTC."Remove or abstain
ContradictedIntrinsic: source states an incompatible fact"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 gate is fail-closed on facts: if verification can't license a clause, that clause doesn't ship. A missing field therefore changes the route, not just the 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.

Put the evidence in code

The incident record isn't decorative text. Its exact source ID and version form the admitted record the answer must obey. Before running the first cell, predict its output: four drafted claims, but no recovery_eta among the available fields. The cell records that source ID and version alongside the claims.

versioned-incident-evidence.py
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)}")
Output
1Evidence version: runbook-feed/2026-05-27T10:00:00Z 2Available facts: ['last_event', 'last_event_at', 'service', 'status'] 3Draft 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.[4]Reference 4FActScore: Fine-grained Atomic Evaluation of Factual Precision in Long Form Text Generation.https://arxiv.org/abs/2305.14251

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.[5]Reference 5RAGAS: Automated Evaluation of Retrieval Augmented Generation.https://arxiv.org/abs/2309.15217

The verifier below applies that reasoning to our operational record. Predict the verdicts first: service, event, and event time should be supported; the missing recovery estimate (ETA) should be not_supported; a later resolved claim should be contradicted. A claim can also lack an admitted source entirely, which is why this product adds no_source to the usual labels.

claim-verdicts.py
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}")
Output
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. A resolved statement is stronger evidence of failure because it conflicts with status=investigating.

⚠️ 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 caught the ETA, but a warning beside the original response would still leak it. Routing is part of the safety contract. Once any factual claim fails, incident-answerer-v1 keeps supported information when that's still safe and replaces the failed detail with a bounded statement. If no safe factual clause remains, it abstains.

safe-answer-route.py
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']}")
Output
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: abstain

Why 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. 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 incident-answer regression cases: clean_update has three supported claims and serves; invented_eta is not supported on recovery_eta and shortens; wrong_status is contradicted and abstains; unadmitted_source is no_source and abstains. Baseline would serve all four and ship three unsafe answers. Claim support is 6 of 9.
The same four fixtures show why a baseline that ships every draft is 3/4 unsafe, while claim support of 6/9 still hides those three bad answers.
grounding-regression-suite.py
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 / 3
Output
1Baseline 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%

The suite gives us three different denominators. 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 rateAnswers withheld or safely shortened / total requestsCost of being cautious

Claim support can improve while unsafe serves remain unacceptable. One contradicted incident status shown to an on-call engineer is still a serious failure, even if every other clause passed.

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. White-box uncertainty methods look at token log-probabilities or token entropy, but token-level logit uncertainty measures vocabulary dispersion across surface forms. If an LLM varies between "14:30 UTC" and "2:30 PM UTC", high token entropy flags lexical variety rather than factual doubt.

Black-box methods bypass token logits by evaluating multiple stochastic generations. SelfCheckGPT queries consistency across sampled passages using NLI, LLM prompting, or BERTScore to detect sentence-level fabrications without external databases.[6]Reference 6SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models.https://arxiv.org/abs/2303.08896 Semantic entropy takes a meaning-level view: it groups sampled answers into semantic equivalence classes using bidirectional entailment, then computes entropy over those meaning clusters:

SE(x)=−∑c∈Cp(c)log⁡p(c)SE(x) = -\sum_{c \in C} p(c) \log p(c)SE(x)=−∑c∈C​p(c)logp(c)

Farquhar et al. designed semantic entropy specifically for confabulations: arbitrary, sample-unstable generations where the model's answers fluctuate across stochastic draws.[7]Reference 7Detecting Hallucinations in Large Language Models Using Semantic Entropyhttps://www.nature.com/articles/s41586-024-07421-0

Use these consistency probes when no admitted source is available, or to prioritize expensive verification paths. They don't authorize a recovery-time claim. If a model has a strong parametric bias, it can repeat the exact same unsupported estimate across every sample (SE≈0SE \approx 0SE≈0). Setting temperature to 0 doesn't fix that either; it only makes the ungrounded guess deterministic.

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. Only after every claim is supported does disagreement above 0.25 send 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. Only a fully supported answer uses disagreement to split serve from review.

The next cell compares unstable samples with a repeated false estimate, then checks both against the admitted record. Predict which signal should decide the repeated ETA.

consistency-is-not-truth.py
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_SUPPORTED
Output
1Unstable recovery estimate disagreement: 0.50 2Repeated recovery estimate disagreement: 0.00 3Repeated recovery estimate evidence verdict: not_supported

Low disagreement means "the model repeats itself," not "the fact is true." Evidence still has to license the claim.

Combine signals with the right authority

Evidence wins even when samples agree. When every claim is supported but generation is unstable, consistency can send the case to review instead of serving an answer that changes from run to run. The router below applies that order to the four fixtures: a mixed not_supported draft shortens, a conflict or missing source abstains, and only a fully supported answer consults disagreement.

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
1def route_with_signals(case: AnswerCase, sampled_statuses: list[str]) -> str: 2 verdicts = [verify_claim(claim).verdict for claim in case.claims] 3 failures = [verdict for verdict in verdicts if verdict != Verdict.SUPPORTED] 4 has_supported = any(verdict == Verdict.SUPPORTED for verdict in verdicts) 5 only_missing_fields = failures and all( 6 verdict == Verdict.NOT_SUPPORTED for verdict in failures 7 ) 8 if failures: 9 if has_supported and only_missing_fields: 10 return "shorten_unsupported_detail" 11 return "abstain_evidence_failure" 12 if disagreement_rate(sampled_statuses) > 0.25: 13 return "review_unstable_generation" 14 return "serve_supported_answer" 15 16clean_case = cases[0] 17eta_case = cases[1] 18 19routes = { 20 "clean_stable": route_with_signals(clean_case, ["investigating"] * 4), 21 "clean_unstable": route_with_signals( 22 clean_case, ["investigating", "investigating", "mitigated", "resolved"] 23 ), 24 "eta_repeated": route_with_signals(eta_case, stable_false_eta_samples), 25} 26 27for name, route_name in routes.items(): 28 print(f"{name:14} -> {route_name}") 29 30assert routes["clean_stable"] == "serve_supported_answer" 31assert routes["clean_unstable"] == "review_unstable_generation" 32assert routes["eta_repeated"] == "shorten_unsupported_detail"
Output
1clean_stable -> serve_supported_answer 2clean_unstable -> review_unstable_generation 3eta_repeated -> shorten_unsupported_detail

Five 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.

citation-faithfulness.py
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_citation
Output
1Service: 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_supported

Claim-level factual evaluation here follows a narrower loop: decompose, retrieve or admit evidence, verify, and retain the provenance for failed and passed claims. It extends the retrieval-augmented generation (RAG) evaluation lesson by separating citation presence from 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

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

First failed stageSymptomAppropriate next action
Evidence admissionNo incident record was retrieved for a requestFix retrieval, permissions, freshness, or tool failure
Claim generationSource exists, but answer adds unsupported recovery timeTighten generation and the post-generation claim gate
Consistency onlySupported facts vary across samplesReview decoding or prompt stability; don't call it a source failure

Chain-of-Verification (CoVe) is a generator-side control: the model drafts an answer, plans verification questions, answers them independently, and produces a revised response. The paper reports fewer hallucinations across list questions, closed-book question answering, and long-form generation.[8]Reference 8Chain-of-Verification Reduces Hallucination in Large Language Modelshttps://arxiv.org/abs/2309.11495

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
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]
Output
1req-clean -> passed 2req-eta -> claim_generation 3req-missing -> evidence_admission 4req-vary -> generation_stability

Evaluate a candidate gate

The traces identify a likely repair, but they don't prove it works outside these fixtures. 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:

ArtifactTeaches or testsPlace in this workflow
SelfCheckGPT[6]Reference 6SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models.https://arxiv.org/abs/2303.08896Sample consistency without external factsTriage signal when evidence is absent or costly
Semantic entropy[7]Reference 7Detecting Hallucinations in Large Language Models Using Semantic Entropyhttps://www.nature.com/articles/s41586-024-07421-0Meaning-level uncertainty over samplesEscalation feature for confabulations, not repeated false beliefs
FActScore[4]Reference 4FActScore: Fine-grained Atomic Evaluation of Factual Precision in Long Form Text Generation.https://arxiv.org/abs/2305.14251Atomic factual precision against a knowledge sourceDesign model for claim-level verification
Ragas Faithfulness[5]Reference 5RAGAS: Automated Evaluation of Retrieval Augmented Generation.https://arxiv.org/abs/2309.15217Statement support against retrieved contextClosest published analog of this product's claim checks
Incident regression tracesExact runbook facts and response policyRelease gate for incident-answerer-v1
Release board for incident-answerer-v1. Candidate metrics on four fixtures are unsafe serve 0 percent, supported coverage 100 percent, one shorten, and two abstentions. Known unsafe claims and source versions pass, but representative labeled holdout and monitoring alert owner are missing, so promotion stays blocked.
The candidate hits 0% unsafe serve on four known fixtures. Promotion still waits on a representative holdout and an alert owner.

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
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"] == 2
Output
1baseline unsafe_rate=75.0% coverage=100.0% shortens=0 abstentions=0 2candidate unsafe_rate=0.0% coverage=100.0% shortens=1 abstentions=2

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 suite is failure-heavy by design, so two of four requests withhold entirely. It's the safe cost of refusing unsupported or conflicting facts, not a reason to loosen the gate.

Keep the mitigation stack small and testable

For this workflow, extra decoding tricks aren't the next priority. The failure is local: the draft adds a fact absent from a live source. Start with controls you can test directly against that source, then add complexity only when a trace points elsewhere:

LayerInvariantFailure it prevents
Admit evidenceRecord source ID and version before answer generationStale or untraceable facts
Generate conservativelyAsk only for claims licensed by source fieldsUnnecessary unsupported detail
Verify claimsEvery factual clause receives a verdictFluent fabrications
Route safelyFailed clauses trigger removal, abstention, or reviewUnsafe operational promises
Measure after launchRetain verdicts, versions, route, and ownerSilent recurrence

Hand the next lesson a trace

The next chapter can't monitor a correctness property that the trace never records. Store enough to reconstruct the answer decision without retaining unnecessary incident text: source versions, verdict counts, route, and failure stage.

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
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 "shorten_unsupported_detail": "shorten", 16 "review_unstable_generation": "review", 17 "serve_supported_answer": "serve", 18 }[signal_route] 19 20def monitor_event(trace: RunTrace) -> MonitorEvent: 21 counts = Counter(verify_claim(claim).verdict.value for claim in trace.case.claims) 22 return MonitorEvent( 23 request_id=trace.request_id, 24 evidence_version=trace.evidence_version, 25 route=trace_route(trace), 26 first_failed_stage=first_failed_stage(trace), 27 verdict_counts=dict(counts), 28 ) 29 30events = [monitor_event(trace) for trace in traces] 31requirements = { 32 "known_unsafe_claims_not_served": candidate_metrics["unsafe_serves"] == 0, 33 "source_version_logged_when_admitted": all( 34 event.evidence_version is not None 35 for event in events 36 if event.first_failed_stage != "evidence_admission" 37 ), 38 "representative_labeled_holdout_collected": False, 39 "monitoring_alert_owner_assigned": False, 40} 41failed_requirements = [name for name, passed in requirements.items() if not passed] 42decision = "APPROVED" if not failed_requirements else "BLOCKED" 43 44print(f"Candidate promotion: {decision}") 45for name in failed_requirements: 46 print(f" missing: {name}") 47print(f"Example failed stage: {events[1].first_failed_stage}") 48print(f"Example review route: {events[3].route}") 49 50assert decision == "BLOCKED" 51assert [event.route for event in events] == ["serve", "shorten", "abstain", "review"] 52assert [event.evidence_version for event in events] == [ 53 incident.version, 54 incident.version, 55 None, 56 incident.version, 57]
Output
1Candidate promotion: BLOCKED 2 missing: representative_labeled_holdout_collected 3 missing: monitoring_alert_owner_assigned 4Example failed stage: claim_generation 5Example review route: review

The 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 the facts an observability system should aggregate.

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?

Correct answer: not_supported, contradicted, no_source

An absent field is not supported, a value that conflicts with status=investigating is contradicted, and a citation to missing-feed has no admitted source. The verifier is scoped to the versioned record.

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?

Correct answer: Make separate claims for service, status, event, and event time, then verify each field independently.

Atomic field-value claims let the gate keep supported details while blocking only the contradicted status. Combining event and time would hide which part of a sentence the source actually supports.

3.A candidate contains supported service, event, and event-time claims plus an unsupported recovery ETA. What should the safe-answer route return?

Correct answer: Shorten the answer, retain supported facts, remove the ETA, and state that the record provides no recovery estimate.

A mixed draft has safe supported clauses and one not_supported detail. The route keeps the safe content and uses a bounded limitation statement. It doesn't serve an unsupported promise or suppress useful evidence unnecessarily.

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?

Correct answer: shorten_unsupported_detail, because agreement cannot license the missing field and supported clauses remain.

Consistency is a triage signal, not evidence. The router checks claim verdicts before disagreement, so it removes the missing ETA and keeps supported clauses even though every sample repeated the same guess.

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?

Correct answer: Reject the sentence because the cited version doesn't support its recovery-time claim.

Citation presence is not citation faithfulness. The checker must resolve the cited version and verify the nearby atomic claim. A valid-looking identifier cannot turn a missing field into evidence.

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?

Correct answer: 66.7% support and 75.0% unsafe serves.

Six of nine claims are supported, so claim support is 66.7%. Serving all four cases exposes the three cases with failed claims, giving an unsafe serve rate of 75.0%. The two metrics measure different units.

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?

Correct answer: claim_generation; constrain generation and keep the post-generation claim gate.

Admission succeeded because the versioned source was present. The error began when the draft introduced a field the source didn't license, so retrieval expansion doesn't target the measured failure.

8.All claims in a run are supported, but sampled statuses are investigating, resolved, investigating, and mitigated. The router sends supported answers to review when disagreement exceeds 0.25. What should the trace record?

Correct answer: first_failed_stage=generation_stability and route=review, because disagreement is 1 - 2/4 = 0.50.

Evidence admission and claim verification passed. The most common sampled status occurs twice, so disagreement is 0.50, above the review threshold. The trace should preserve that operational distinction.

9.A candidate gate reaches 0% unsafe serve and 100% supported coverage on four known failure-heavy fixtures, but has no representative labeled holdout and no monitoring alert owner. What is the release decision?

Correct answer: Keep promotion blocked, collect a representative holdout, and assign an owner for alerts.

The four fixtures show repair of known failures, not representative production behavior. The release contract also requires someone accountable for monitoring response. Both missing artifacts keep promotion blocked.

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?

Correct answer: The draft is faithful to the admitted version; freshness of that version remains an evidence-admission concern.

The verifier proves agreement with the selected source version, not present-world truth. A stale admitted record can support a stale status. Freshness and source admission must be monitored separately from claim faithfulness.

10 questions remaining.

Next Step
Continue to LLM Observability & Monitoring

You can now emit a versioned trace whenever a factual claim is served, shortened, or blocked, including the evidence version, verdict counts, route, and first failed stage. Next you'll turn those traces into quality metrics, alerts, and request-level debugging so a silent gate bypass doesn't hide behind a healthy 200.

PreviousBias & Fairness in LLMs
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
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

Huang et al. · 2023

https://arxiv.org/abs/2311.05232

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: Automated Evaluation of Retrieval Augmented Generation.

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

https://arxiv.org/abs/2309.15217

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

Detecting Hallucinations in Large Language Models Using Semantic Entropy

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

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

Chain-of-Verification Reduces Hallucination in Large Language Models

Dhuliawala, S., et al. · 2023

https://arxiv.org/abs/2309.11495

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.