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][2] "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]
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:
| 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 | Extrinsic: source doesn't establish the fact | "Full recovery by 14:30 UTC." | Remove or abstain |
| Contradicted | Intrinsic: source states an incompatible fact | "Incident is resolved." while status is investigating | Block and investigate |

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.

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

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%The suite gives us three different denominators. For a grounded incident-status product, track:
| 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. 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] 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:
Farquhar et al. designed semantic entropy specifically for confabulations: arbitrary, sample-unstable generations where the model's answers fluctuate across stochastic draws.[7]
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 (). Setting temperature to 0 doesn't fix that either; it only makes the ungrounded guess deterministic.

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.
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_supportedLow 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.
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"1clean_stable -> serve_supported_answer
2clean_unstable -> review_unstable_generation
3eta_repeated -> shorten_unsupported_detailFive 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_supportedClaim-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 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 the 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 (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]
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.
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
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:
| Artifact | Teaches or tests | Place in this workflow |
|---|---|---|
| SelfCheckGPT[6] | Sample consistency without external facts | Triage signal when evidence is absent or costly |
| Semantic entropy[7] | Meaning-level uncertainty over samples | Escalation feature for confabulations, not repeated false beliefs |
| FActScore[4] | Atomic factual precision against a knowledge source | Design model for claim-level verification |
| Ragas Faithfulness[5] | Statement support against retrieved context | Closest published analog of this product's claim checks |
| Incident regression traces | Exact runbook facts and response policy | Release gate for incident-answerer-v1 |

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