Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
In this fictional policy lab, Maya asks what DEPLOY-17 requires for a payment-service production deploy during a release freeze. The recorded hybrid search candidates contain the rule, and the reranker selects it. Consider this hand-written answer:
The release-freeze rule applies. You can deploy without a linked rollback plan. [DEPLOY-17]
That answer sounds plausible because its citation names a rule in the fixture. It contradicts that rule: both incident commander (IC) approval and a linked rollback plan are required before rollout. This lab blocks release when any emitted policy claim lacks support.
In retrieval-augmented generation (RAG), retrieved text supplies evidence to a language model. The previous lesson's policy-answerer-v3 recorded an evidence trace showing which chunks were retrieved, selected, and tied to source versions. A trustworthy trace lets you inspect that path. It doesn't prove the answer stayed inside the evidence.
policy-answerer-v4-eval is the evaluation harness for this request. It reports the first failed check instead of averaging everything into one quality score. Its decisions use trusted fixture labels, not a neural entailment model. Those gates still matter when the next lesson adds large language model (LLM) judges for paraphrases the labels can't cover.

TruLens calls three evaluation dimensions the RAG Triad. It is a useful framework, not a universal production standard:[1]
- Context relevance: Is supplied context relevant to the question?
- Groundedness / faithfulness: Are the answer's claims supported by supplied evidence? Claim extraction and support assessment can both make mistakes; a high estimated score doesn't guarantee zero hallucinations.[2]
- Answer relevance: Does the response address the question? Topic relevance alone doesn't certify every required condition, factual correctness, or task completion.
For this policy explanation, we add access, citation, and required-point checks. These are this lesson's contracts, not definitions of the Triad:
- Admissibility and authorization: Did every chunk in the evidence trace pass access control (such as Fine-Grained Authorization) and freshness checks before reaching the model?
- Context selection: Did hybrid search retrieve the gold rule, and did reranking preserve it within the admitted context budget?
- Claim faithfulness: Does the admitted context logically entail every atomic claim asserted by the model?
- Citation support: Does each attached citation link to an admitted chunk that actually establishes that specific claim?
- Task completeness: Did the model cover every required policy point instead of silently dropping mandatory conditions?
A fluent answer can pass one gate and fail another. Faithfulness is relative to supplied evidence, not an absolute guarantee of real-world truth. An answer can faithfully repeat an incorrect source; a true fact from the model's pre-training memory can be unsupported by this specific context. Nor does a rule saying approval and a rollback plan are required prove that meeting those conditions alone authorizes deployment. This lesson evaluates a policy explanation, not an execution permit.
Replay the freeze-deploy trace
Maya's question for this replay is:
What does DEPLOY-17 require for a payment-service production deploy during a release freeze?
The fixture starts after hybrid retrieval found the correct rule and reranking selected it. Evaluation consumes that recorded evidence path instead of reconstructing retrieval inside the answer-quality harness. The field names match the previous lesson's release trace: first_stage_ids, reranked_ids, selected_context_ids, and component versions.
Before running the fixture, predict which chunk ID should survive in selected_context_ids. Check that tuple, its source version, and the component versions against the printed trace.
1from __future__ import annotations
2
3from collections import defaultdict
4from dataclasses import dataclass, replace
5from types import MappingProxyType
6
7TARGET_ID = "deploy-freeze-approval-rule"
8
9@dataclass(frozen=True)
10class EvidenceChunk:
11 chunk_id: str
12 document_id: str
13 parent_id: str
14 version: str
15 permitted: bool
16 current: bool
17 text: str
18
19@dataclass(frozen=True)
20class GoldCase:
21 case_id: str
22 question: str
23 required_source_ids: frozenset[str]
24 required_points: frozenset[str]
25
26@dataclass(frozen=True)
27class RagTrace:
28 case_id: str
29 question: str
30 first_stage_ids: tuple[str, ...]
31 rerank_input_ids: tuple[str, ...]
32 reranked_ids: tuple[str, ...]
33 selected_context_ids: tuple[str, ...]
34 selected_versions: tuple[str, ...]
35 versions: tuple[tuple[str, str], ...]
36
37EVIDENCE = {
38 TARGET_ID: EvidenceChunk(
39 TARGET_ID,
40 "deploy-policy",
41 "deploy-policy-v2",
42 "deploy-policy/2026-06-01",
43 True,
44 True,
45 (
46 "Rule DEPLOY-17. Payment-service production deploys during a release "
47 "freeze require incident commander approval and a linked rollback plan "
48 "before rollout."
49 ),
50 ),
51 "payment-service-rollback-runbook": EvidenceChunk(
52 "payment-service-rollback-runbook",
53 "payment-rollback",
54 "payment-rollback-v1",
55 "payment-rollback/2026-05-20",
56 True,
57 True,
58 (
59 "Payment-service rollback drills must keep the previous artifact "
60 "available for 30 minutes. This runbook does not authorize freeze deploys."
61 ),
62 ),
63 "frontend-docs-deploy-rule": EvidenceChunk(
64 "frontend-docs-deploy-rule",
65 "frontend-docs",
66 "frontend-docs-v1",
67 "frontend-docs/2026-03-01",
68 True,
69 True,
70 "Frontend documentation deploys may ship during normal business hours without freeze approval.",
71 ),
72 "restricted-breakglass-note": EvidenceChunk(
73 "restricted-breakglass-note",
74 "breakglass-terms",
75 "breakglass-terms",
76 "breakglass/2026-05-01",
77 False,
78 True,
79 "Payment-service deploys may bypass freeze approval during executive escalations.",
80 ),
81}
82# Trusted replay records. Frozen records plus a read-only mapping protect this
83# in-memory fixture; a real service needs versioned, access-controlled storage.
84REPLAY_SNAPSHOT = MappingProxyType(dict(EVIDENCE))
85GOLD = GoldCase(
86 case_id="payment-freeze-deploy-001",
87 question=(
88 "What does DEPLOY-17 require for a payment-service production deploy "
89 "during a release freeze?"
90 ),
91 required_source_ids=frozenset({TARGET_ID}),
92 required_points=frozenset({"freeze-scope", "approval", "rollback-plan"}),
93)
94TRACE = RagTrace(
95 case_id=GOLD.case_id,
96 question=GOLD.question,
97 first_stage_ids=(
98 "payment-service-rollback-runbook",
99 "frontend-docs-deploy-rule",
100 TARGET_ID,
101 ),
102 rerank_input_ids=(
103 "payment-service-rollback-runbook",
104 "frontend-docs-deploy-rule",
105 TARGET_ID,
106 ),
107 reranked_ids=(TARGET_ID, "payment-service-rollback-runbook", "frontend-docs-deploy-rule"),
108 selected_context_ids=(TARGET_ID,),
109 selected_versions=("deploy-policy/2026-06-01",),
110 versions=(
111 ("retriever", "policy-retriever-v2"),
112 ("index", "policy-index/2026-06-02"),
113 ("sparse", "bm25-tokenizer-v1"),
114 ("dense", "fixture-embeddings-v1"),
115 ("fusion", "rrf-k60"),
116 ("reranker", "fixture-cross-encoder-v1"),
117 ),
118)
119
120selected_sources = [
121 (
122 chunk.chunk_id,
123 chunk.document_id,
124 chunk.parent_id,
125 chunk.version,
126 )
127 for chunk in (EVIDENCE[chunk_id] for chunk_id in TRACE.selected_context_ids)
128]
129print("Selected context:", TRACE.selected_context_ids)
130print("Selected sources:", selected_sources)
131print("Pipeline versions:", dict(TRACE.versions))
132assert TRACE.selected_context_ids == (TARGET_ID,)1Selected context: ('deploy-freeze-approval-rule',)
2Selected sources: [('deploy-freeze-approval-rule', 'deploy-policy', 'deploy-policy-v2', 'deploy-policy/2026-06-01')]
3Pipeline versions: {'retriever': 'policy-retriever-v2', 'index': 'policy-index/2026-06-02', 'sparse': 'bm25-tokenizer-v1', 'dense': 'fixture-embeddings-v1', 'fusion': 'rrf-k60', 'reranker': 'fixture-cross-encoder-v1'}The trace identifies the sources and retrieval configuration to inspect. This in-memory fixture has one immutable revision per chunk. A real replay store must resolve the recorded revision, not substitute today's text. To reproduce generation, also save the exact assembled prompt, generator version, decoding settings, and original output. Even those records don't promise identical resampling from a hosted model.
Gate admissibility before scoring quality
A relevance score can't make forbidden evidence acceptable. Here, first_stage_ids means candidates returned across the request's authorization boundary, not every record an index scans internally. Restricted, stale, or unknown evidence in that returned path fails this fixture's contract, even if selection later drops it. permitted and current are trusted test labels; these booleans don't implement access control or freshness checks.
1def admissible_evidence_path(trace: RagTrace, gold: GoldCase) -> bool:
2 if trace.case_id != gold.case_id or trace.question != gold.question:
3 return False
4 if not trace.selected_context_ids:
5 return False
6 if len(trace.selected_context_ids) != len(trace.selected_versions):
7 return False
8 required_version_keys = {"retriever", "index", "sparse", "dense", "fusion", "reranker"}
9 if any(
10 not isinstance(record, tuple)
11 or len(record) != 2
12 or any(not isinstance(value, str) or not value.strip() for value in record)
13 for record in trace.versions
14 ):
15 return False
16 version_map = dict(trace.versions)
17 if len(version_map) != len(trace.versions):
18 return False
19 if not required_version_keys.issubset(version_map):
20 return False
21 if any(not version_map[key].strip() for key in required_version_keys):
22 return False
23 stage_ids = (
24 trace.first_stage_ids,
25 trace.rerank_input_ids,
26 trace.reranked_ids,
27 trace.selected_context_ids,
28 )
29 if any(len(ids) != len(set(ids)) for ids in stage_ids):
30 return False
31 traced_ids = tuple(chunk_id for ids in stage_ids for chunk_id in ids)
32 if any(chunk_id not in EVIDENCE for chunk_id in traced_ids):
33 return False
34 traced = [EVIDENCE[chunk_id] for chunk_id in traced_ids]
35 selected = [EVIDENCE[chunk_id] for chunk_id in trace.selected_context_ids]
36 rerank_input_came_from_retrieval = set(trace.rerank_input_ids).issubset(
37 trace.first_stage_ids
38 )
39 reranked_same_candidates = set(trace.reranked_ids) == set(trace.rerank_input_ids)
40 returned_by_reranker = set(trace.selected_context_ids).issubset(trace.reranked_ids)
41 versions_match = all(
42 chunk.version == version
43 for chunk, version in zip(selected, trace.selected_versions)
44 )
45 allowed_and_current = all(
46 chunk.permitted is True
47 and chunk.current is True
48 and REPLAY_SNAPSHOT.get(chunk.chunk_id) == chunk
49 for chunk in traced
50 )
51 return (
52 rerank_input_came_from_retrieval
53 and reranked_same_candidates
54 and returned_by_reranker
55 and versions_match
56 and allowed_and_current
57 )
58
59restricted_trace = replace(
60 TRACE,
61 first_stage_ids=("restricted-breakglass-note",),
62 rerank_input_ids=("restricted-breakglass-note",),
63 reranked_ids=("restricted-breakglass-note",),
64 selected_context_ids=("restricted-breakglass-note",),
65 selected_versions=("breakglass/2026-05-01",),
66)
67blocked_candidate_trace = replace(
68 TRACE,
69 first_stage_ids=TRACE.first_stage_ids + ("restricted-breakglass-note",),
70 rerank_input_ids=TRACE.rerank_input_ids + ("restricted-breakglass-note",),
71 reranked_ids=TRACE.reranked_ids + ("restricted-breakglass-note",),
72)
73unknown_candidate_trace = replace(TRACE, first_stage_ids=TRACE.first_stage_ids + ("missing",))
74stale_version_trace = replace(TRACE, selected_versions=("deploy-policy/2025-02-01",))
75missing_version_trace = replace(TRACE, versions=TRACE.versions[:-1])
76wrong_case_trace = replace(TRACE, case_id="payment-freeze-deploy-002")
77duplicate_candidate_trace = replace(
78 TRACE,
79 first_stage_ids=TRACE.first_stage_ids + (TARGET_ID,),
80)
81print("Fixture path admissible:", admissible_evidence_path(TRACE, GOLD))
82print("Restricted context admissible:", admissible_evidence_path(restricted_trace, GOLD))
83print("Blocked candidate admissible:", admissible_evidence_path(blocked_candidate_trace, GOLD))
84print("Unknown candidate admissible:", admissible_evidence_path(unknown_candidate_trace, GOLD))
85print("Wrong-version path admissible:", admissible_evidence_path(stale_version_trace, GOLD))
86print("Incomplete trace admissible:", admissible_evidence_path(missing_version_trace, GOLD))
87print("Wrong-case trace admissible:", admissible_evidence_path(wrong_case_trace, GOLD))
88print("Duplicate candidate admissible:", admissible_evidence_path(duplicate_candidate_trace, GOLD))
89assert admissible_evidence_path(TRACE, GOLD)
90assert not admissible_evidence_path(restricted_trace, GOLD)
91assert not admissible_evidence_path(blocked_candidate_trace, GOLD)
92assert not admissible_evidence_path(unknown_candidate_trace, GOLD)
93assert not admissible_evidence_path(stale_version_trace, GOLD)
94assert not admissible_evidence_path(missing_version_trace, GOLD)
95assert not admissible_evidence_path(wrong_case_trace, GOLD)
96assert not admissible_evidence_path(duplicate_candidate_trace, GOLD)1Fixture path admissible: True
2Restricted context admissible: False
3Blocked candidate admissible: False
4Unknown candidate admissible: False
5Wrong-version path admissible: False
6Incomplete trace admissible: False
7Wrong-case trace admissible: False
8Duplicate candidate admissible: FalseThe case and question checks bind these labels to this replay; they don't establish that the recorded text is the caller's actual request or authenticate that caller. Complete record comparisons reject edited source text, parents, or metadata under an unchanged ID/version. The component names are teaching version labels, not immutable artifact pins. A service also needs trusted caller/grant records and current access checks.
A restricted break-glass rule would answer the question perfectly. Why must the evaluation fail before computing relevance?
Answer
Relevance isn't permission. Once restricted text enters retrieval or reranking, a score, cache, trace, or response can expose evidence Maya isn't allowed to use. Authorization and freshness are hard gates across the full evidence path.
Separate candidates from selected context
The path is allowed. That still doesn't say the required rule is in the candidate list, or that it survived into the prompt.
Context relevance assesses relevance to the question; evidence sufficiency also needs a coverage check. We inspect candidate retrieval and selected context separately.
Pause on that distinction: if the rule appears in first-stage candidates but disappears before the prompt, which recall should fall? Keep that prediction in mind while reviewing the metrics:
| Layer | Gate | Meaning on this trace |
|---|---|---|
| Candidate retrieval | candidate_recall | Did hybrid search pull the required rule into initial candidates? |
| Selected context | context_recall | Did the rule survive reranking and token budget admission? |
| Selected context | selected_context_precision | Did admitted context filter out irrelevant distractors? |
Selected-context precision directly measures noise reduction:
Distractors can increase context cost and introduce competing evidence. Lost in the Middle found position-dependent degradation on its tested tasks and models, especially when relevant information appeared in the middle of long contexts.[3] Extra irrelevant chunks don't automatically trigger that failure in every model; test position and distraction separately.
Ranking metrics such as mean reciprocal rank (MRR) and normalized discounted cumulative gain (NDCG) ask whether top evidence appears early in a ranked list.[4] At generation evaluation time, we care about admission: did the evidence make it into the prompt, and how clean is the prompt?
1def coverage(ids: tuple[str, ...], required_ids: frozenset[str]) -> float:
2 if not required_ids:
3 raise ValueError("Source recall needs a nonempty reference set")
4 if len(ids) != len(set(ids)):
5 raise ValueError("Source recall needs unique admitted IDs")
6 return len(set(ids) & required_ids) / len(required_ids)
7
8def selected_context_precision(ids: tuple[str, ...], useful_ids: frozenset[str]) -> float:
9 """ID-set precision, not rank-weighted Ragas ContextPrecision."""
10 if len(ids) != len(set(ids)):
11 raise ValueError("Selected precision needs unique admitted IDs")
12 return len(set(ids) & useful_ids) / len(ids) if ids else 0.0
13
14candidate_recall = coverage(TRACE.first_stage_ids, GOLD.required_source_ids)
15context_recall = coverage(TRACE.selected_context_ids, GOLD.required_source_ids)
16selected_precision = selected_context_precision(
17 TRACE.selected_context_ids, GOLD.required_source_ids
18)
19print(f"Candidate recall: {candidate_recall:.1f}")
20print(f"Selected-context recall: {context_recall:.1f}")
21print(f"Selected-context precision: {selected_precision:.1f}")
22assert (candidate_recall, context_recall, selected_precision) == (1.0, 1.0, 1.0)1Candidate recall: 1.0
2Selected-context recall: 1.0
3Selected-context precision: 1.0This result describes only the evidence path. It doesn't say what the model wrote. These are set metrics over labeled chunk IDs, not a reproduction of the original RAGAS context-relevance metric.[2] An alternative chunk could contain equally good evidence yet score as a miss against an incomplete gold set. Label acceptable alternatives or assess coverage of answer-bearing facts before treating that miss as a retrieval defect.
Metric names can hide different denominators. Current Ragas documents a rank-weighted ContextPrecision and a separate IDBasedContextPrecision using the matching-ID fraction.[5] Our unique-ID precision follows the latter idea. With one useful chunk and two distractors, set precision is regardless of order; the rank-weighted metric can change when the useful chunk moves.
Turn an answer into testable claims
Consider two responses produced from the same valid context:
| Response | What changed? |
|---|---|
unsafe-bypass | Adds a no-rollback-plan claim that the deploy rule never states |
supported-deploy | States only the deploy scope and conditions present in DEPLOY-17 |
Context can be sufficient while the answer isn't. Split the answer into atomic policy claims so you can score it. Each claim below carries its text and citation. A separate, trusted annotation will say what the evidence supports. FActScore similarly evaluates factual precision at the level of atomic facts rather than treating a whole passage as uniformly correct.[6]
Before looking at the output, decide which answer should score lower. Both use the same selected context, but the bypass replaces required conditions with an unsupported exemption; count supported claims, not citation count or answer length.
1@dataclass(frozen=True)
2class Claim:
3 claim_id: str
4 text: str
5 citation_id: str | None
6
7@dataclass(frozen=True)
8class Answer:
9 answer_id: str
10 claims: tuple[Claim, ...]
11
12UNSAFE_BYPASS = Answer(
13 "unsafe-bypass",
14 (
15 Claim(
16 "freeze-scope",
17 "The request is governed by the release-freeze deploy rule.",
18 TARGET_ID,
19 ),
20 Claim(
21 "bypass",
22 "The deploy can start without a linked rollback plan.",
23 TARGET_ID,
24 ),
25 ),
26)
27SUPPORTED_DEPLOY = Answer(
28 "supported-deploy",
29 (
30 Claim(
31 "freeze-scope",
32 "The request is governed by the release-freeze deploy rule.",
33 TARGET_ID,
34 ),
35 Claim(
36 "approval",
37 "Incident commander approval is required before rollout.",
38 TARGET_ID,
39 ),
40 Claim(
41 "rollback-plan",
42 "A linked rollback plan is required before rollout.",
43 TARGET_ID,
44 ),
45 ),
46)
47PARTIAL_DEPLOY = Answer(
48 "partial-deploy",
49 (
50 Claim(
51 "freeze-scope",
52 "The request is governed by the release-freeze deploy rule.",
53 TARGET_ID,
54 ),
55 Claim(
56 "approval",
57 "Incident commander approval is required before rollout.",
58 TARGET_ID,
59 ),
60 ),
61)
62EMPTY_ANSWER = Answer("empty", ())
63
64print("Unsafe claims:", [claim.claim_id for claim in UNSAFE_BYPASS.claims])
65print("Supported claims:", [claim.claim_id for claim in SUPPORTED_DEPLOY.claims])
66print("Partial claims:", [claim.claim_id for claim in PARTIAL_DEPLOY.claims])
67print("Empty claims:", [claim.claim_id for claim in EMPTY_ANSWER.claims])1Unsafe claims: ['freeze-scope', 'bypass']
2Supported claims: ['freeze-scope', 'approval', 'rollback-plan']
3Partial claims: ['freeze-scope', 'approval']
4Empty claims: []These claims are hand-written test fixtures, not outputs from a model call. In live evaluation, inspect claim extraction too: dropping a qualifier such as "only" or skipping an unsupported sentence can inflate every later score.
Faithfulness checks claims against context
Selected context can still lose to a generator that outruns it. Check each claim against the selected DEPLOY-17 sentence before writing a scorer:
| Claim | Evidence relationship | Supported? |
|---|---|---|
freeze-scope | The rule explicitly covers this service and freeze | yes |
bypass | The rule requires the plan; the answer says it can be absent | no |
One of two unsafe claims is supported, so faithfulness is . RAGAS formalizes this ratio after an LLM extracts atomic statements and evaluates which ones are logically inferred from context.[2]
Verifying whether context supports claim is formally modeled as a Natural Language Inference (NLI) task. Treating the admitted context as the premise and atomic claim as the hypothesis, the evaluator (an NLI cross-encoder or calibrated LLM judge) assigns one of three categorical relations:
- Entailment (): The context logically necessitates the claim, including all scopes and conditions.
- Contradiction (): The context directly conflicts with the claim. Here, DEPLOY-17 requires a linked rollback plan; the answer asserts the plan can be absent.
- Neutral / Not Established (): The context doesn't contain enough evidence to confirm or refute the claim (such as speculating about unmentioned executive waivers).
A learned evaluator estimates these relations; its verdict isn't a formal proof. The claim-level faithfulness metric is the fraction of emitted claims assessed as entailed by the admitted context:
where is the total count of atomic claims in the response.
Token overlap alone can't distinguish support here: "approval is required" and "approval is not required" share most of their tokens while stating opposite rules. Entailment requires agreement with scope and conditions, not just lexical similarity.
The code uses a hand-labeled teaching answer key. Each supported text maps to an exact source revision and a required point. An unknown text is unadjudicated, not automatically false. This fixture refuses to certify it until reviewed. In a real study, independently review the labels; never let the answer supply its own support verdict.
1REVIEWED_SUPPORT = {
2 "The request is governed by the release-freeze deploy rule.": (
3 TARGET_ID, "deploy-policy/2026-06-01", "freeze-scope"
4 ),
5 "Incident commander approval is required before rollout.": (
6 TARGET_ID, "deploy-policy/2026-06-01", "approval"
7 ),
8 "A linked rollback plan is required before rollout.": (
9 TARGET_ID, "deploy-policy/2026-06-01", "rollback-plan"
10 ),
11}
12REVIEWED_UNSUPPORTED = {"The deploy can start without a linked rollback plan."}
13
14def source_supports(claim: Claim, chunk: EvidenceChunk) -> bool:
15 annotation = REVIEWED_SUPPORT.get(claim.text)
16 return (
17 annotation is not None
18 and annotation[:2] == (chunk.chunk_id, chunk.version)
19 and REPLAY_SNAPSHOT.get(chunk.chunk_id) == chunk
20 )
21
22def claim_supported_by_context(claim: Claim, trace: RagTrace) -> bool:
23 return any(
24 source_supports(claim, EVIDENCE[chunk_id])
25 for chunk_id in trace.selected_context_ids
26 )
27
28def faithfulness(answer: Answer, trace: RagTrace) -> float:
29 if any(
30 claim.text not in REVIEWED_SUPPORT and claim.text not in REVIEWED_UNSUPPORTED
31 for claim in answer.claims
32 ):
33 raise ValueError("Unadjudicated claim: review before scoring")
34 supported = sum(
35 claim_supported_by_context(claim, trace) for claim in answer.claims
36 )
37 return supported / len(answer.claims) if answer.claims else 0.0
38
39print(f"unsafe-bypass faithfulness: {faithfulness(UNSAFE_BYPASS, TRACE):.2f}")
40print(
41 "supported-deploy faithfulness: "
42 f"{faithfulness(SUPPORTED_DEPLOY, TRACE):.2f}"
43)
44print(f"empty faithfulness: {faithfulness(EMPTY_ANSWER, TRACE):.2f}")
45assert faithfulness(UNSAFE_BYPASS, TRACE) == 0.5
46assert faithfulness(SUPPORTED_DEPLOY, TRACE) == 1.0
47assert faithfulness(EMPTY_ANSWER, TRACE) == 0.01unsafe-bypass faithfulness: 0.50
2supported-deploy faithfulness: 1.00
3empty faithfulness: 0.00The unsafe response is on topic and cites a real selected rule. It still fails because one claim contradicts that rule. The empty-claims score of 0.0 is a deliberate fixture convention, not a zero-denominator ratio. Current Ragas also defines faithfulness through extracted-claim support; its extraction and support models can err.[7] This answer key binds each claim to one source. A claim requiring several sources needs an evidence-set annotation or another evaluator. We'll handle justified abstention separately.
Citation presence isn't citation support
Faithfulness asks whether selected context could support the sentence. Citation support asks whether the attached source is the one that does. RAGAS faithfulness doesn't check citation IDs, so this gate is extra.
A citation metric needs two checks:
- Coverage: Does every policy claim cite a source?
- Support: Does the cited selected source establish that claim?
A fabricated rollback exemption can get perfect citation coverage by attaching the correct-looking chunk ID. Presence alone is a weak gate.
Before running the next cell, predict what changes for MIS_CITED: faithfulness, citation support, or both.
1MIS_CITED = Answer(
2 "mis-cited",
3 tuple(
4 replace(claim, citation_id="payment-service-rollback-runbook")
5 for claim in SUPPORTED_DEPLOY.claims
6 ),
7)
8
9def citation_coverage(answer: Answer) -> float:
10 cited = sum(
11 isinstance(claim.citation_id, str) and bool(claim.citation_id.strip())
12 for claim in answer.claims
13 )
14 return cited / len(answer.claims) if answer.claims else 0.0
15
16def citation_support(answer: Answer, trace: RagTrace) -> float:
17 supported = 0
18 for claim in answer.claims:
19 if claim.citation_id not in trace.selected_context_ids:
20 continue
21 if source_supports(claim, EVIDENCE[claim.citation_id]):
22 supported += 1
23 return supported / len(answer.claims) if answer.claims else 0.0
24
25print(f"unsafe citation coverage: {citation_coverage(UNSAFE_BYPASS):.2f}")
26print(f"unsafe citation support: {citation_support(UNSAFE_BYPASS, TRACE):.2f}")
27print(f"mis-cited answer faithfulness: {faithfulness(MIS_CITED, TRACE):.2f}")
28print(f"mis-cited citation support: {citation_support(MIS_CITED, TRACE):.2f}")
29assert citation_coverage(UNSAFE_BYPASS) == 1.0
30assert citation_support(UNSAFE_BYPASS, TRACE) == 0.5
31assert faithfulness(MIS_CITED, TRACE) == 1.0
32assert citation_support(MIS_CITED, TRACE) == 0.01unsafe citation coverage: 1.00
2unsafe citation support: 0.50
3mis-cited answer faithfulness: 1.00
4mis-cited citation support: 0.00In the mis-cited response, selected context supports every sentence, so faithfulness is 1.0. Its citations still fail because they point at payment-service-rollback-runbook, which isn't selected and doesn't establish those claims. Adding that unrelated runbook to context would fix neither the citation support nor its relevance to Maya's question.
An answer gets faithfulness 1.0 but citation support 0.0. What happened?
Answer
The answer may match evidence somewhere in context while its attached citations point somewhere else, or outside the admitted context entirely. Inspect claim-to-citation alignment rather than changing retrieval first.
Check whether the answer finished the task
A 1.0 faithfulness score can still fail release if a required point never appears as a supported claim. For this golden case, Maya needs three answer points: deploy scope, approval, and rollback plan. Treat point coverage as a labeled completeness gate:
1def supported_point_coverage(answer: Answer, trace: RagTrace, gold: GoldCase) -> float:
2 if not gold.required_points:
3 raise ValueError("Point coverage needs nonempty required points")
4 supported_points = {
5 REVIEWED_SUPPORT[claim.text][2]
6 for claim in answer.claims
7 if claim_supported_by_context(claim, trace)
8 }
9 return len(supported_points & gold.required_points) / len(gold.required_points)
10
11unsafe_coverage = supported_point_coverage(UNSAFE_BYPASS, TRACE, GOLD)
12supported_coverage = supported_point_coverage(SUPPORTED_DEPLOY, TRACE, GOLD)
13partial_coverage = supported_point_coverage(PARTIAL_DEPLOY, TRACE, GOLD)
14print(f"unsafe supported point coverage: {unsafe_coverage:.2f}")
15print(f"supported answer point coverage: {supported_coverage:.2f}")
16print(f"partial supported point coverage: {partial_coverage:.2f}")
17assert unsafe_coverage == 1 / 3
18assert supported_coverage == 1.0
19assert partial_coverage == 2 / 31unsafe supported point coverage: 0.33
2supported answer point coverage: 1.00
3partial supported point coverage: 0.67Point coverage does a different job from answer relevance. A response can sound on-topic while omitting the rollback condition; RAGAS answer relevance asks whether the response addresses the question, while this labeled gate checks whether each required policy point appears.[2]
PARTIAL_DEPLOY is the useful contrast: faithfulness is 1.0 while point coverage is only because the rollback-plan claim never appears. Conversely, a correctly quoted frontend deployment rule could be fully supported yet irrelevant to Maya's payment-service question. The required points are a task-specific relevance and completeness rubric, not a general semantic relevance detector.
Before checking the partial answer's output, predict whether its missing rollback point should be blamed on faithfulness or completeness.
An answer emits no policy claims. Does its lack of unsupported claims make it releasable?
Answer
Not on this answerable case. Silence doesn't invent policy, but it doesn't finish Maya's task either. Block it as incomplete. A clear, justified abstention on an unanswerable question is a different behavior and needs a different label.
When declining to answer is correct
Suppose Maya asks whether an executive escalation waives the rollback requirement. The admitted rule doesn't establish that exception. "The available policy doesn't establish a waiver; ask the policy owner" is a useful abstention. "No waiver exists anywhere" is an unsupported universal claim.
Label both corpus answerability and selected-evidence sufficiency. If the corpus contains the needed rule but retrieval misses it, abstaining can be the safe generation behavior while retrieval still fails. Report that distinction instead of rewarding every refusal or forcing every question into an answer-required gate.
| Corpus can answer? | Selected evidence sufficient? | Response | Interpretation |
|---|---|---|---|
| yes | yes | Complete, supported answer | Task success |
| no | no | Explain the evidence limit | Justified abstention |
| yes | no | Explain the evidence limit | Safe response, retrieval/selection failure |
| yes | yes | Refuse without a reason | Unnecessary abstention |
The harness below remains scoped to Maya's answerable requirements question. A production suite needs an explicit abstention branch, including safe handling of empty context, and separate rates for unsupported answering and unnecessary abstention. Don't turn an undefined claim ratio into a perfect faithfulness score.
Attribute the first failure
Evaluating a multi-component RAG system as a monolithic black box leads to circular debugging. When a generated answer fails in production, you can't immediately tell whether to blame the vector index, the reranker, the generator's temperature, or the prompt template.
Pipeline order gives an investigation priority. It doesn't resolve every cause. If retrieval misses a rule and the answer invents a waiver, both defects matter: the model should handle insufficient evidence safely. Short-circuiting a release decision can save work, while a diagnostic run can still assess downstream behavior on the exact original context.
This answerable-case fixture checks:
- Admissibility: Did unauthorized or stale evidence enter the pipeline?
- Candidate retrieval: Did hybrid search place the required chunk in initial candidates?
- Context selection: Did reranking keep the chunk in the admitted prompt context?
- Claim presence: Are there policy claims to evaluate?
- Claim review: Does the fixed answer key cover their exact text?
- Answer faithfulness: Are emitted claims supported by admitted evidence?
- Citation support: Do the attached sources support their claims?
- Task completeness: Are all required points covered?
Each failure narrows the next inspection:
- A missing candidate calls for checking the index, query, retrieval, and gold labels.
- A dropped rule calls for checking shortlist depth, reranking, filtering, deduplication, and packing.
- No extracted policy claims calls for inspecting the original output and extraction. It doesn't prove an API returned blank text.
- Unreviewed text needs adjudication; the answer key can't distinguish a new contradiction from a new valid paraphrase.
- An invented bypass calls for checking generation and its support labels. Lower temperature or a different model isn't a guaranteed repair.
- A wrong citation calls for inspecting source attachment and validation.
- A missing required point calls for inspecting answer coverage and the rubric.

1RETRIEVAL_MISS = replace(
2 TRACE,
3 first_stage_ids=("payment-service-rollback-runbook", "frontend-docs-deploy-rule"),
4 rerank_input_ids=("payment-service-rollback-runbook", "frontend-docs-deploy-rule"),
5 reranked_ids=("payment-service-rollback-runbook", "frontend-docs-deploy-rule"),
6 selected_context_ids=("payment-service-rollback-runbook",),
7 selected_versions=("payment-rollback/2026-05-20",),
8)
9SELECTION_MISS = replace(
10 TRACE,
11 selected_context_ids=("payment-service-rollback-runbook",),
12 selected_versions=("payment-rollback/2026-05-20",),
13)
14
15def first_failed_stage(trace: RagTrace, answer: Answer, gold: GoldCase) -> str:
16 if not admissible_evidence_path(trace, gold):
17 return "admissibility"
18 if coverage(trace.first_stage_ids, gold.required_source_ids) < 1.0:
19 return "candidate retrieval"
20 if coverage(trace.selected_context_ids, gold.required_source_ids) < 1.0:
21 return "context selection"
22 if not answer.claims:
23 return "claim presence"
24 if any(
25 claim.text not in REVIEWED_SUPPORT and claim.text not in REVIEWED_UNSUPPORTED
26 for claim in answer.claims
27 ):
28 return "claim review"
29 if faithfulness(answer, trace) < 1.0:
30 return "answer faithfulness"
31 if citation_support(answer, trace) < 1.0:
32 return "citation support"
33 if supported_point_coverage(answer, trace, gold) < 1.0:
34 return "answer completeness"
35 return "pass"
36
37diagnoses = {
38 "missing candidate": first_failed_stage(RETRIEVAL_MISS, SUPPORTED_DEPLOY, GOLD),
39 "dropped context": first_failed_stage(SELECTION_MISS, SUPPORTED_DEPLOY, GOLD),
40 "invented bypass": first_failed_stage(TRACE, UNSAFE_BYPASS, GOLD),
41 "wrong citation": first_failed_stage(TRACE, MIS_CITED, GOLD),
42 "empty answer": first_failed_stage(TRACE, EMPTY_ANSWER, GOLD),
43 "missing rollback point": first_failed_stage(TRACE, PARTIAL_DEPLOY, GOLD),
44 "supported answer": first_failed_stage(TRACE, SUPPORTED_DEPLOY, GOLD),
45}
46for variant, stage in diagnoses.items():
47 print(f"{variant}: {stage}")
48assert diagnoses["missing candidate"] == "candidate retrieval"
49assert diagnoses["dropped context"] == "context selection"
50assert diagnoses["invented bypass"] == "answer faithfulness"
51assert diagnoses["wrong citation"] == "citation support"
52assert diagnoses["empty answer"] == "claim presence"
53assert diagnoses["missing rollback point"] == "answer completeness"
54assert diagnoses["supported answer"] == "pass"1missing candidate: candidate retrieval
2dropped context: context selection
3invented bypass: answer faithfulness
4wrong citation: citation support
5empty answer: claim presence
6missing rollback point: answer completeness
7supported answer: passEmpty claims fail claim presence. The partial answer emits supported claims but misses the rollback point, so it fails answer completeness. An unseen paraphrase returns claim review; the fixed key can't decide its meaning. These labels name observed checks, not proven causes.
Try two attempts to fool the evaluator. The first keeps a valid claim ID but negates its text. The second repeats an approval claim under the rollback ID. Neither should gain support or completeness from its identifier.
1negated = replace(
2 SUPPORTED_DEPLOY.claims[1], text="Incident commander approval is not required."
3)
4spoofed_text = Answer("negated", (negated,))
5spoofed_point = Answer(
6 "renamed-approval",
7 PARTIAL_DEPLOY.claims + (replace(SUPPORTED_DEPLOY.claims[1], claim_id="rollback-plan"),),
8)
9paraphrase = Answer("unreviewed-paraphrase", (
10 Claim("approval", "Approval from the incident commander is mandatory.", TARGET_ID),
11))
12print("Negated known claim:", first_failed_stage(TRACE, spoofed_text, GOLD))
13print("Renamed duplicate:", first_failed_stage(TRACE, spoofed_point, GOLD))
14print("Valid but unreviewed paraphrase:", first_failed_stage(TRACE, paraphrase, GOLD))
15assert first_failed_stage(TRACE, spoofed_text, GOLD) == "claim review"
16assert first_failed_stage(TRACE, spoofed_point, GOLD) == "answer completeness"
17assert first_failed_stage(TRACE, paraphrase, GOLD) == "claim review"
18assert supported_point_coverage(spoofed_point, TRACE, GOLD) == 2 / 31Negated known claim: claim review
2Renamed duplicate: answer completeness
3Valid but unreviewed paraphrase: claim reviewAn unreviewed contradiction and an unreviewed correct paraphrase both require review. That is an honest limit of this fixture, not evidence that their meanings are equivalent. Once reviewed, store separate labels. Keep atomic-claim segmentation and deduplication consistent so repetition can't inflate a reported average.
Challenge the source and citation receipts
Predict whether an unchanged source ID/version is sufficient after its text changes. Then compare a blank citation string with a citation that actually names a source.
1original_chunk = EVIDENCE[TARGET_ID]
2EVIDENCE[TARGET_ID] = replace(original_chunk, text="Edited policy under an unchanged version")
3try:
4 print("Edited source diagnosis:", first_failed_stage(TRACE, SUPPORTED_DEPLOY, GOLD))
5 assert first_failed_stage(TRACE, SUPPORTED_DEPLOY, GOLD) == "admissibility"
6 assert not source_supports(SUPPORTED_DEPLOY.claims[1], EVIDENCE[TARGET_ID])
7finally:
8 EVIDENCE[TARGET_ID] = original_chunk
9
10blank_citations = Answer(
11 "blank-citations",
12 tuple(replace(claim, citation_id=" ") for claim in SUPPORTED_DEPLOY.claims),
13)
14wrong_question = replace(TRACE, question="Does a different service have a freeze exemption?")
15print(f"Blank citation coverage: {citation_coverage(blank_citations):.1f}")
16print("Changed question admissible:", admissible_evidence_path(wrong_question, GOLD))
17assert citation_coverage(blank_citations) == 0.0
18assert first_failed_stage(TRACE, blank_citations, GOLD) == "citation support"
19assert not admissible_evidence_path(wrong_question, GOLD)1Edited source diagnosis: admissibility
2Blank citation coverage: 0.0
3Changed question admissible: FalseMake bad behaviors part of release testing
Evaluation should show both that a supported answer is accepted and that known bad answers are blocked. These variants become a tiny regression suite:
1@dataclass(frozen=True)
2class ReleaseCase:
3 name: str
4 trace: RagTrace
5 answer: Answer
6 should_release: bool
7
8def can_release(trace: RagTrace, answer: Answer, gold: GoldCase) -> bool:
9 return first_failed_stage(trace, answer, gold) == "pass"
10
11RELEASE_CASES = (
12 ReleaseCase("supported answer", TRACE, SUPPORTED_DEPLOY, True),
13 ReleaseCase("unsupported bypass", TRACE, UNSAFE_BYPASS, False),
14 ReleaseCase("wrong citation", TRACE, MIS_CITED, False),
15 ReleaseCase("empty answer", TRACE, EMPTY_ANSWER, False),
16 ReleaseCase("dropped source", SELECTION_MISS, SUPPORTED_DEPLOY, False),
17 ReleaseCase("missing rollback point", TRACE, PARTIAL_DEPLOY, False),
18)
19regression_passes = 0
20for case in RELEASE_CASES:
21 observed = can_release(case.trace, case.answer, GOLD)
22 regression_passes += observed == case.should_release
23 print(f"{case.name}: release={observed}, expected={case.should_release}")
24print(f"Regression checks passed: {regression_passes}/{len(RELEASE_CASES)}")
25assert regression_passes == len(RELEASE_CASES)1supported answer: release=True, expected=True
2unsupported bypass: release=False, expected=False
3wrong citation: release=False, expected=False
4empty answer: release=False, expected=False
5dropped source: release=False, expected=False
6missing rollback point: release=False, expected=False
7Regression checks passed: 6/6Release behavior is now concrete: pass the supported response and reject the five controlled defects.
Slice results before trusting an average
A larger golden set should include release-freeze deploys, incident hotfixes, schema migrations, and data backfills. Aggregate quality can remain high while a costly policy slice regresses.
1@dataclass(frozen=True)
2class LabeledOutcome:
3 workflow: str
4 released_correctly: bool
5
6 def __post_init__(self) -> None:
7 if not isinstance(self.workflow, str) or not self.workflow.strip():
8 raise ValueError("workflow must be a nonempty string")
9 if type(self.released_correctly) is not bool:
10 raise ValueError("outcome must be a Boolean")
11
12OUTCOMES = (
13 LabeledOutcome("release-freeze", True),
14 LabeledOutcome("release-freeze", False),
15 LabeledOutcome("incident-hotfix", True),
16 LabeledOutcome("incident-hotfix", True),
17 LabeledOutcome("schema-migration", False),
18)
19by_workflow: dict[str, list[bool]] = defaultdict(list)
20for outcome in OUTCOMES:
21 by_workflow[outcome.workflow].append(outcome.released_correctly)
22
23overall = sum(outcome.released_correctly for outcome in OUTCOMES) / len(OUTCOMES)
24print(f"Overall pass rate: {overall:.0%}")
25for workflow, results in sorted(by_workflow.items()):
26 print(f"{workflow}: {sum(results)}/{len(results)} ({sum(results) / len(results):.0%})")
27assert overall == 0.6
28assert sum(by_workflow["schema-migration"]) == 01Overall pass rate: 60%
2incident-hotfix: 2/2 (100%)
3release-freeze: 1/2 (50%)
4schema-migration: 0/1 (0%)1REQUIRED_FIXTURE_SLICES = frozenset({"release-freeze", "incident-hotfix", "schema-migration"})
2
3def slice_regression_clear(results_by_workflow: dict[str, list[bool]]) -> bool:
4 # The local 95/100 rule is exact count arithmetic, not a population bound.
5 if not REQUIRED_FIXTURE_SLICES.issubset(results_by_workflow):
6 return False
7 for workflow in REQUIRED_FIXTURE_SLICES:
8 results = results_by_workflow[workflow]
9 if (
10 not isinstance(results, list)
11 or not results
12 or any(type(result) is not bool for result in results)
13 or sum(results) * 100 < 95 * len(results)
14 ):
15 return False
16 return True
17
18release_report = {
19 "service_version": "policy-answerer-v4-eval",
20 "source_trace": TRACE.case_id,
21 "required_policy_version": EVIDENCE[TARGET_ID].version,
22 "checks": {
23 "admissible_evidence_path": admissible_evidence_path(TRACE, GOLD),
24 "supported_answer_released": can_release(TRACE, SUPPORTED_DEPLOY, GOLD),
25 "known_bad_answers_blocked": all(
26 not can_release(case.trace, case.answer, GOLD)
27 for case in RELEASE_CASES
28 if not case.should_release
29 ),
30 "slice_regression_clear": slice_regression_clear(by_workflow),
31 },
32}
33print("Release checks:", release_report["checks"])
34print("Release allowed:", all(release_report["checks"].values()))
35assert not all(release_report["checks"].values())1Release checks: {'admissible_evidence_path': True, 'supported_answer_released': True, 'known_bad_answers_blocked': True, 'slice_regression_clear': False}
2Release allowed: FalseThe supported trace passes, but this synthetic five-case report blocks release. Two required slices miss the local 95% threshold. Missing or empty required slices also fail; an empty report mustn't pass through all([]). The two incident-hotfix successes don't establish a 95% population success rate. This lab only requires a nonempty slice to demonstrate logic. A real study must predefine thresholds, required slices, adequate sample sizes, and uncertainty rules.
Choose the aggregation unit with equal care. For the formulas below, counts answers with atomic claims; report empty-claims cases and abstentions separately rather than silently assigning an undefined ratio. Two averages answer different questions:
- Pooled-claim faithfulness (micro-average): All claims from all answers are dumped into a single pool:
- Mean per-answer faithfulness (macro-average): Each answer's faithfulness ratio is computed individually, and those ratios are averaged across queries:
Pooling weights answers by claim count; a per-answer mean weights answers equally. Neither is inherently the correct estimand for every study. Label the unit and show both when the distinction affects interpretation.
Consider two hypothetical test cases:
- Query 1: The model generates a wordy, 9-claim explanation of a basic policy rule. Every single claim is entailed by the context (9/9 supported, 100% faithfulness).
- Query 2: The model generates a concise, 1-claim response on a critical freeze rule, but hallucinates an unauthorized bypass (0/1 supported, 0% faithfulness).
Under pooled-claim aggregation:
Under mean per-answer aggregation:
The fraction of nonempty answers whose claims are all supported is:
The first answer contributes nine times the weight under pooling. These two cases don't establish a population failure rate. Complete claim support also doesn't detect omissions: PARTIAL_DEPLOY has no unsupported claims but misses rollback. Avoid labeling this pass fraction "safety" or treating it as complete task success.
1annotated_counts = [(9, 9), (0, 1)] # (supported, emitted), hypothetical cases
2pooled = sum(supported for supported, _ in annotated_counts) / sum(
3 emitted for _, emitted in annotated_counts
4)
5per_answer = sum(supported / emitted for supported, emitted in annotated_counts) / len(annotated_counts)
6claim_support_pass = sum(supported == emitted for supported, emitted in annotated_counts) / len(annotated_counts)
7print(f"Pooled claim support: {pooled:.0%}")
8print(f"Mean per-answer support: {per_answer:.0%}")
9print(f"All-claims-supported fraction: {claim_support_pass:.0%}")
10print(f"Partial answer: all claims supported={faithfulness(PARTIAL_DEPLOY, TRACE) == 1.0}, "
11 f"task complete={supported_point_coverage(PARTIAL_DEPLOY, TRACE, GOLD) == 1.0}")
12assert (pooled, per_answer, claim_support_pass) == (0.9, 0.5, 0.5)
13assert not can_release(TRACE, PARTIAL_DEPLOY, GOLD)
14assert not slice_regression_clear({})
15assert not slice_regression_clear({workflow: [] for workflow in REQUIRED_FIXTURE_SLICES})1Pooled claim support: 90%
2Mean per-answer support: 50%
3All-claims-supported fraction: 50%
4Partial answer: all claims supported=True, task complete=FalseChoose claim-support, completeness, and critical-slice rules for the actual product before comparing systems. Evaluate the same questions and retain paired results. Claims within an answer and paraphrases of the same case aren't independent test questions. Estimate uncertainty at the question or case-family level, keep calibration separate from held-out evaluation, and show slice counts. A high average over near-duplicates isn't broad coverage.
Where automated judges enter
The harness's answer key makes the arithmetic testable. It doesn't solve semantic support checking. Once questions or answers change, humans or a calibrated judge must decide whether each claim is entailed, contradicted, or not established by the admitted evidence.
The original RAGAS paper estimates faithfulness from claim support, answer relevance from embedding similarity to questions generated from the answer, and context relevance from extracted relevant sentences. "Reference-free" here means no gold answer is required, not that no evidence is required.[2] ARES trains judges on synthetic examples and uses human labels with prediction-powered inference to estimate aggregate evaluation scores with confidence intervals. Those intervals concern system-level estimates, not a guarantee that an individual answer is correct.[8]
Predict the boundary before adding a judge: which decision can semantic scoring help with, and which decisions must remain hard gates? A judge may recognize a valid paraphrase, but it can't authorize a restricted chunk or repair missing provenance.
Those approaches don't remove the need for this trace design. An automated judge still needs:
| Input | Why the judge needs it |
|---|---|
| Question and expected workflow | Decide whether the answer addressed the request |
| Admitted context and version | Decide whether claims are grounded in allowed evidence |
| Atomic claims and citations | Explain which claim failed and which source was cited |
| Human-reviewed calibration set | Detect judge mistakes and drift |
For a support judge, include supported claims, contradictions, missing evidence, valid paraphrases, wrong citations, and appropriate abstentions. A set containing only good answers can't measure false acceptance. Give reviewers the same question and versioned evidence as the judge; resolve ambiguous labels and measure both false accepts and false rejects on held-out cases. Record the judge model, prompt, rubric, and parsing failures. Retrieved text is evidence to assess, not instructions the judge should obey.
For a separate clarity comparison, use two already-supported answers and randomize their presentation order. Don't substitute a preference for fluent writing for evidence entailment. The next lesson develops rubric-driven LLM judgments; authorization and trace-integrity checks remain independent of those judgments.
Release gates for policy-answerer-v4
Before this answerer serves another freeze-deploy question:
| Gate | Minimum evidence | First fix when it fails |
|---|---|---|
| Admissibility | Retrieved, reranked, and selected chunk IDs; versions; ACL/freshness decision | Evidence filtering |
| Candidate recall | Required chunk in retrieved candidates | Chunking, query, or retrieval lane |
| Context admission | Sufficient admitted evidence; measure distracting context separately | Reranker or context gate |
| Faithfulness | Supported claim ledger | Prompt, abstention, or generation policy |
| Citation support | Claim-to-source validation | Citation attachment or validator |
| Completeness | Required points covered, or justified abstention on an unanswerable case | Answer policy and coverage rubric |
| Slice health | Workflow-level regression report | Block release and debug affected slice |
Don't compress these gates into one quality percentage. A dashboard should tell an engineer what to repair first.