Skip to content
Skip to lesson
Back to Topics
LearnApplied LLM EngineeringRAG Evaluation for Reliable Answers
📊MediumEvaluation & Benchmarks

RAG Evaluation for Reliable Answers

Test whether a RAG answer is supported, cites the right source, and covers every required point. Catch a convincing answer that still gets the policy wrong.

31 min read
Learning path
Step 74 of 199 in the full curriculum

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 candidates contain the rule, and the selects it. Consider this hand-written answer:

The release-freeze rule applies. You can deploy without a linked 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 . This lab blocks release when any emitted policy claim lacks support.

In , retrieved text supplies evidence to a language model. The previous lesson's policy-answerer-v3 recorded an evidence showing which chunks were retrieved, selected, and tied to source versions. Use that trace alongside claim-level support checks to evaluate whether the answer stayed inside the evidence.

policy-answerer-v4-eval is the 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 still matter when a judge later scores paraphrases the labels can't cover.

A matrix scores complete, partial, and unsafe answers against the release-freeze scope, approval, and the rollback plan. The complete answer supports all three, for faithfulness 3 of 3 and completeness 3 of 3. The partial answer supports two and omits rollback, for 2 of 2 and 2 of 3. The unsafe answer supports scope, omits approval, and reverses rollback, for 1 of 2 and 1 of 3.
The partial answer is fully grounded and covers two of three required points. The unsafe answer covers scope, leaves approval out, and contradicts the rollback condition. The matrix separates claim support from task coverage.

The RAG Triad groups three useful measurements: context relevance, groundedness (which RAGAS calls faithfulness), and answer relevance.[1] Map those measurements to your product's risks, then add checks for access, citations, and completeness.

  1. Context relevance: Is supplied context relevant to the question?
  2. Groundedness / faithfulness: Are the answer's claims supported by supplied evidence? Claim extraction and support assessment can both make mistakes; validate high-scoring outputs against human-labeled examples and representative cases.[2]
  3. 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 alongside 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 measures support from the supplied evidence. A faithful answer can repeat an incorrect source, and a true fact from pre-training can lack support in the request's context. A rule that requires approval and a rollback plan also leaves deployment subject to the policy's other conditions. This lesson evaluates a policy explanation. Execution still requires its own authorization.

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.

evaluation-trace-fixture.py
from __future__ import annotations

from collections import defaultdict
from dataclasses import dataclass, replace
from types import MappingProxyType

TARGET_ID = "deploy-freeze-approval-rule"

@dataclass(frozen=True)
class EvidenceChunk:
    chunk_id: str
    document_id: str
    parent_id: str
    version: str
    permitted: bool
    current: bool
    text: str

@dataclass(frozen=True)
class GoldCase:
    case_id: str
    question: str
    required_source_ids: frozenset[str]
    required_points: frozenset[str]

@dataclass(frozen=True)
class RagTrace:
    case_id: str
    question: str
    first_stage_ids: tuple[str, ...]
    rerank_input_ids: tuple[str, ...]
    reranked_ids: tuple[str, ...]
    selected_context_ids: tuple[str, ...]
    selected_versions: tuple[str, ...]
    versions: tuple[tuple[str, str], ...]

EVIDENCE = {
    TARGET_ID: EvidenceChunk(
        TARGET_ID,
        "deploy-policy",
        "deploy-policy-v2",
        "deploy-policy/2026-06-01",
        True,
        True,
        (
            "Rule DEPLOY-17. Payment-service production deploys during a release "
            "freeze require incident commander approval and a linked rollback plan "
            "before rollout."
        ),
    ),
    "payment-service-rollback-runbook": EvidenceChunk(
        "payment-service-rollback-runbook",
        "payment-rollback",
        "payment-rollback-v1",
        "payment-rollback/2026-05-20",
        True,
        True,
        (
            "Payment-service rollback drills must keep the previous artifact "
            "available for 30 minutes. This runbook does not authorize freeze deploys."
        ),
    ),
    "frontend-docs-deploy-rule": EvidenceChunk(
        "frontend-docs-deploy-rule",
        "frontend-docs",
        "frontend-docs-v1",
        "frontend-docs/2026-03-01",
        True,
        True,
        "Frontend documentation deploys may ship during normal business hours without freeze approval.",
    ),
    "restricted-breakglass-note": EvidenceChunk(
        "restricted-breakglass-note",
        "breakglass-terms",
        "breakglass-terms",
        "breakglass/2026-05-01",
        False,
        True,
        "Payment-service deploys may bypass freeze approval during executive escalations.",
    ),
}
# Trusted replay records. Frozen records plus a read-only mapping protect this
# in-memory fixture; a real service needs versioned, access-controlled storage.
REPLAY_SNAPSHOT = MappingProxyType(dict(EVIDENCE))
GOLD = GoldCase(
    case_id="payment-freeze-deploy-001",
    question=(
        "What does DEPLOY-17 require for a payment-service production deploy "
        "during a release freeze?"
    ),
    required_source_ids=frozenset({TARGET_ID}),
    required_points=frozenset({"freeze-scope", "approval", "rollback-plan"}),
)
TRACE = RagTrace(
    case_id=GOLD.case_id,
    question=GOLD.question,
    first_stage_ids=(
        "payment-service-rollback-runbook",
        "frontend-docs-deploy-rule",
        TARGET_ID,
    ),
    rerank_input_ids=(
        "payment-service-rollback-runbook",
        "frontend-docs-deploy-rule",
        TARGET_ID,
    ),
    reranked_ids=(TARGET_ID, "payment-service-rollback-runbook", "frontend-docs-deploy-rule"),
    selected_context_ids=(TARGET_ID,),
    selected_versions=("deploy-policy/2026-06-01",),
    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"),
    ),
)

selected_sources = [
    (
        chunk.chunk_id,
        chunk.document_id,
        chunk.parent_id,
        chunk.version,
    )
    for chunk in (EVIDENCE[chunk_id] for chunk_id in TRACE.selected_context_ids)
]
print("Selected context:", TRACE.selected_context_ids)
print("Selected sources:", selected_sources)
print("Pipeline versions:", dict(TRACE.versions))
assert TRACE.selected_context_ids == (TARGET_ID,)
Output
Selected context: ('deploy-freeze-approval-rule',)
Selected sources: [('deploy-freeze-approval-rule', 'deploy-policy', 'deploy-policy-v2', 'deploy-policy/2026-06-01')]
Pipeline 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.

The trace names the right chunk and the right versions. Were those candidates allowed to be on the path at all?

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.

admissible-evidence-path-gate.py
def admissible_evidence_path(trace: RagTrace, gold: GoldCase) -> bool:
    if trace.case_id != gold.case_id or trace.question != gold.question:
        return False
    if not trace.selected_context_ids:
        return False
    if len(trace.selected_context_ids) != len(trace.selected_versions):
        return False
    required_version_keys = {"retriever", "index", "sparse", "dense", "fusion", "reranker"}
    if any(
        not isinstance(record, tuple)
        or len(record) != 2
        or any(not isinstance(value, str) or not value.strip() for value in record)
        for record in trace.versions
    ):
        return False
    version_map = dict(trace.versions)
    if len(version_map) != len(trace.versions):
        return False
    if not required_version_keys.issubset(version_map):
        return False
    if any(not version_map[key].strip() for key in required_version_keys):
        return False
    stage_ids = (
        trace.first_stage_ids,
        trace.rerank_input_ids,
        trace.reranked_ids,
        trace.selected_context_ids,
    )
    if any(len(ids) != len(set(ids)) for ids in stage_ids):
        return False
    traced_ids = tuple(chunk_id for ids in stage_ids for chunk_id in ids)
    if any(chunk_id not in EVIDENCE for chunk_id in traced_ids):
        return False
    traced = [EVIDENCE[chunk_id] for chunk_id in traced_ids]
    selected = [EVIDENCE[chunk_id] for chunk_id in trace.selected_context_ids]
    rerank_input_came_from_retrieval = set(trace.rerank_input_ids).issubset(
        trace.first_stage_ids
    )
    reranked_same_candidates = set(trace.reranked_ids) == set(trace.rerank_input_ids)
    returned_by_reranker = set(trace.selected_context_ids).issubset(trace.reranked_ids)
    versions_match = all(
        chunk.version == version
        for chunk, version in zip(selected, trace.selected_versions)
    )
    allowed_and_current = all(
        chunk.permitted is True
        and chunk.current is True
        and REPLAY_SNAPSHOT.get(chunk.chunk_id) == chunk
        for chunk in traced
    )
    return (
        rerank_input_came_from_retrieval
        and reranked_same_candidates
        and returned_by_reranker
        and versions_match
        and allowed_and_current
    )

restricted_trace = replace(
    TRACE,
    first_stage_ids=("restricted-breakglass-note",),
    rerank_input_ids=("restricted-breakglass-note",),
    reranked_ids=("restricted-breakglass-note",),
    selected_context_ids=("restricted-breakglass-note",),
    selected_versions=("breakglass/2026-05-01",),
)
blocked_candidate_trace = replace(
    TRACE,
    first_stage_ids=TRACE.first_stage_ids + ("restricted-breakglass-note",),
    rerank_input_ids=TRACE.rerank_input_ids + ("restricted-breakglass-note",),
    reranked_ids=TRACE.reranked_ids + ("restricted-breakglass-note",),
)
unknown_candidate_trace = replace(TRACE, first_stage_ids=TRACE.first_stage_ids + ("missing",))
stale_version_trace = replace(TRACE, selected_versions=("deploy-policy/2025-02-01",))
missing_version_trace = replace(TRACE, versions=TRACE.versions[:-1])
wrong_case_trace = replace(TRACE, case_id="payment-freeze-deploy-002")
duplicate_candidate_trace = replace(
    TRACE,
    first_stage_ids=TRACE.first_stage_ids + (TARGET_ID,),
)
print("Fixture path admissible:", admissible_evidence_path(TRACE, GOLD))
print("Restricted context admissible:", admissible_evidence_path(restricted_trace, GOLD))
print("Blocked candidate admissible:", admissible_evidence_path(blocked_candidate_trace, GOLD))
print("Unknown candidate admissible:", admissible_evidence_path(unknown_candidate_trace, GOLD))
print("Wrong-version path admissible:", admissible_evidence_path(stale_version_trace, GOLD))
print("Incomplete trace admissible:", admissible_evidence_path(missing_version_trace, GOLD))
print("Wrong-case trace admissible:", admissible_evidence_path(wrong_case_trace, GOLD))
print("Duplicate candidate admissible:", admissible_evidence_path(duplicate_candidate_trace, GOLD))
assert admissible_evidence_path(TRACE, GOLD)
assert not admissible_evidence_path(restricted_trace, GOLD)
assert not admissible_evidence_path(blocked_candidate_trace, GOLD)
assert not admissible_evidence_path(unknown_candidate_trace, GOLD)
assert not admissible_evidence_path(stale_version_trace, GOLD)
assert not admissible_evidence_path(missing_version_trace, GOLD)
assert not admissible_evidence_path(wrong_case_trace, GOLD)
assert not admissible_evidence_path(duplicate_candidate_trace, GOLD)
Output
Fixture path admissible: True
Restricted context admissible: False
Blocked candidate admissible: False
Unknown candidate admissible: False
Wrong-version path admissible: False
Incomplete trace admissible: False
Wrong-case trace admissible: False
Duplicate candidate admissible: False

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

The path can pass those checks and still be the wrong evidence for the answer. Is the required rule in the candidate list, and did it survive into the prompt?

Separate candidates from selected context

Admissibility establishes a permitted, current evidence path. Next, measure whether retrieval found the required rule and reranking kept it inside the prompt.

Context relevance asks whether supplied text is about 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:

LayerGateMeaning on this trace
Candidate retrievalcandidate_recallDid hybrid search pull the required rule into initial candidates?
Selected contextcontext_recallDid the rule survive reranking and token budget admission?
Selected contextselected_context_precisionDid admitted context filter out irrelevant distractors?

Selected-context precision directly measures noise reduction:

Precision⁡selected=∣admitted chunk IDs∩useful chunk IDs∣∣admitted chunk IDs∣\operatorname{Precision}_{\text{selected}} = \frac{|\text{admitted chunk IDs} \cap \text{useful chunk IDs}|}{|\text{admitted chunk IDs}|}

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 and 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?

evidence-path-metrics.py
def coverage(ids: tuple[str, ...], required_ids: frozenset[str]) -> float:
    if not required_ids:
        raise ValueError("Source recall needs a nonempty reference set")
    if len(ids) != len(set(ids)):
        raise ValueError("Source recall needs unique admitted IDs")
    return len(set(ids) & required_ids) / len(required_ids)

def selected_context_precision(ids: tuple[str, ...], useful_ids: frozenset[str]) -> float:
    """ID-set precision, not rank-weighted Ragas ContextPrecision."""
    if len(ids) != len(set(ids)):
        raise ValueError("Selected precision needs unique admitted IDs")
    return len(set(ids) & useful_ids) / len(ids) if ids else 0.0

candidate_recall = coverage(TRACE.first_stage_ids, GOLD.required_source_ids)
context_recall = coverage(TRACE.selected_context_ids, GOLD.required_source_ids)
selected_precision = selected_context_precision(
    TRACE.selected_context_ids, GOLD.required_source_ids
)
print(f"Candidate recall: {candidate_recall:.1f}")
print(f"Selected-context recall: {context_recall:.1f}")
print(f"Selected-context precision: {selected_precision:.1f}")
assert (candidate_recall, context_recall, selected_precision) == (1.0, 1.0, 1.0)
Output
Candidate recall: 1.0
Selected-context recall: 1.0
Selected-context precision: 1.0
The recorded trace finds the DEPLOY-17 rule among three candidates, reranks it into a one-chunk context, and scores candidate recall, context recall, and selected-set precision at one. In a labeled counterfactual, keeping two distractors alongside the rule preserves recall at one while lowering set precision to one third.
The recorded fixture retrieves and selects the required rule alone. The lower path holds recall steady when two permitted distractors remain, while selected-set precision falls to one third.

This result describes the evidence path. Inspect the generated answer separately. These are set metrics over labeled chunk IDs; the original RAGAS context-relevance metric uses a different method.[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 1/31/3 regardless of order. The rank-weighted metric can change when the useful chunk moves.

Context can be sufficient and the written answer can still be wrong. How do you score the sentences themselves?

Turn an answer into testable claims

Consider two responses produced from the same valid context:

ResponseWhat changed?
unsafe-bypassAdds a no-rollback-plan claim that the deploy rule never states
supported-deployStates 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.

answer-claim-fixtures.py
@dataclass(frozen=True)
class Claim:
    claim_id: str
    text: str
    citation_id: str | None

@dataclass(frozen=True)
class Answer:
    answer_id: str
    claims: tuple[Claim, ...]

UNSAFE_BYPASS = Answer(
    "unsafe-bypass",
    (
        Claim(
            "freeze-scope",
            "The request is governed by the release-freeze deploy rule.",
            TARGET_ID,
        ),
        Claim(
            "bypass",
            "The deploy can start without a linked rollback plan.",
            TARGET_ID,
        ),
    ),
)
SUPPORTED_DEPLOY = Answer(
    "supported-deploy",
    (
        Claim(
            "freeze-scope",
            "The request is governed by the release-freeze deploy rule.",
            TARGET_ID,
        ),
        Claim(
            "approval",
            "Incident commander approval is required before rollout.",
            TARGET_ID,
        ),
        Claim(
            "rollback-plan",
            "A linked rollback plan is required before rollout.",
            TARGET_ID,
        ),
    ),
)
PARTIAL_DEPLOY = Answer(
    "partial-deploy",
    (
        Claim(
            "freeze-scope",
            "The request is governed by the release-freeze deploy rule.",
            TARGET_ID,
        ),
        Claim(
            "approval",
            "Incident commander approval is required before rollout.",
            TARGET_ID,
        ),
    ),
)
EMPTY_ANSWER = Answer("empty", ())

print("Unsafe claims:", [claim.claim_id for claim in UNSAFE_BYPASS.claims])
print("Supported claims:", [claim.claim_id for claim in SUPPORTED_DEPLOY.claims])
print("Partial claims:", [claim.claim_id for claim in PARTIAL_DEPLOY.claims])
print("Empty claims:", [claim.claim_id for claim in EMPTY_ANSWER.claims])
Output
Unsafe claims: ['freeze-scope', 'bypass']
Supported claims: ['freeze-scope', 'approval', 'rollback-plan']
Partial claims: ['freeze-scope', 'approval']
Empty 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:

ClaimEvidence relationshipSupported?
freeze-scopeThe rule explicitly covers this service and freezeyes
bypassThe rule requires the plan; the answer says it can be absentno

One of two unsafe claims is supported, so faithfulness is 1/21/2. RAGAS formalizes this ratio after an LLM extracts atomic statements and evaluates which ones are logically inferred from context.[2]

Verifying whether context EE supports claim cjc_j is formally modeled as a Natural Language Inference (NLI) task. Treating the admitted context EE as the premise and atomic claim cjc_j as the hypothesis, the evaluator (an NLI cross-encoder or calibrated LLM judge) assigns one of three categorical relations:

  1. Entailment (E  ⟹  cjE \implies c_j): The context logically necessitates the claim, including all scopes and conditions.
  2. Contradiction (E  ⟹  ¬cjE \implies \neg c_j): The context directly conflicts with the claim. Here, DEPLOY-17 requires a linked rollback plan; the answer asserts the plan can be absent.
  3. Neutral / Not Established (E̸  ⟹  cj∧E̸  ⟹  ¬cjE \not\implies c_j \land E \not\implies \neg c_j): 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:

faithfulness⁡=∑j=1mI(entailed(cj,E))m\operatorname{faithfulness} = \frac{\sum_{j=1}^m \mathbb{I}(\text{entailed}(c_j, E))}{m}

where mm 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 lexical similarity alone.

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.

claim-faithfulness-gate.py
REVIEWED_SUPPORT = {
    "The request is governed by the release-freeze deploy rule.": (
        TARGET_ID, "deploy-policy/2026-06-01", "freeze-scope"
    ),
    "Incident commander approval is required before rollout.": (
        TARGET_ID, "deploy-policy/2026-06-01", "approval"
    ),
    "A linked rollback plan is required before rollout.": (
        TARGET_ID, "deploy-policy/2026-06-01", "rollback-plan"
    ),
}
REVIEWED_UNSUPPORTED = {"The deploy can start without a linked rollback plan."}

def source_supports(claim: Claim, chunk: EvidenceChunk) -> bool:
    annotation = REVIEWED_SUPPORT.get(claim.text)
    return (
        annotation is not None
        and annotation[:2] == (chunk.chunk_id, chunk.version)
        and REPLAY_SNAPSHOT.get(chunk.chunk_id) == chunk
    )

def claim_supported_by_context(claim: Claim, trace: RagTrace) -> bool:
    return any(
        source_supports(claim, EVIDENCE[chunk_id])
        for chunk_id in trace.selected_context_ids
    )

def faithfulness(answer: Answer, trace: RagTrace) -> float:
    if any(
        claim.text not in REVIEWED_SUPPORT and claim.text not in REVIEWED_UNSUPPORTED
        for claim in answer.claims
    ):
        raise ValueError("Unadjudicated claim: review before scoring")
    supported = sum(
        claim_supported_by_context(claim, trace) for claim in answer.claims
    )
    return supported / len(answer.claims) if answer.claims else 0.0

print(f"unsafe-bypass faithfulness: {faithfulness(UNSAFE_BYPASS, TRACE):.2f}")
print(
    "supported-deploy faithfulness: "
    f"{faithfulness(SUPPORTED_DEPLOY, TRACE):.2f}"
)
print(f"empty faithfulness: {faithfulness(EMPTY_ANSWER, TRACE):.2f}")
assert faithfulness(UNSAFE_BYPASS, TRACE) == 0.5
assert faithfulness(SUPPORTED_DEPLOY, TRACE) == 1.0
assert faithfulness(EMPTY_ANSWER, TRACE) == 0.0
Output
unsafe-bypass faithfulness: 0.50
supported-deploy faithfulness: 1.00
empty faithfulness: 0.00

The 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. An empty score is not a justified refusal.

The citation is the remaining question. Does the attached chunk ID establish the sentence, or only name a document?

Verify that each citation supports its claim

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:

  1. Coverage: Does every policy claim cite a source?
  2. 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.

citation-support-gate.py
MIS_CITED = Answer(
    "mis-cited",
    tuple(
        replace(claim, citation_id="payment-service-rollback-runbook")
        for claim in SUPPORTED_DEPLOY.claims
    ),
)

def citation_coverage(answer: Answer) -> float:
    cited = sum(
        isinstance(claim.citation_id, str) and bool(claim.citation_id.strip())
        for claim in answer.claims
    )
    return cited / len(answer.claims) if answer.claims else 0.0

def citation_support(answer: Answer, trace: RagTrace) -> float:
    supported = 0
    for claim in answer.claims:
        if claim.citation_id not in trace.selected_context_ids:
            continue
        if source_supports(claim, EVIDENCE[claim.citation_id]):
            supported += 1
    return supported / len(answer.claims) if answer.claims else 0.0

print(f"unsafe citation coverage: {citation_coverage(UNSAFE_BYPASS):.2f}")
print(f"unsafe citation support: {citation_support(UNSAFE_BYPASS, TRACE):.2f}")
print(f"mis-cited answer faithfulness: {faithfulness(MIS_CITED, TRACE):.2f}")
print(f"mis-cited citation support: {citation_support(MIS_CITED, TRACE):.2f}")
assert citation_coverage(UNSAFE_BYPASS) == 1.0
assert citation_support(UNSAFE_BYPASS, TRACE) == 0.5
assert faithfulness(MIS_CITED, TRACE) == 1.0
assert citation_support(MIS_CITED, TRACE) == 0.0
Output
unsafe citation coverage: 1.00
unsafe citation support: 0.50
mis-cited answer faithfulness: 1.00
mis-cited citation support: 0.00

In 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 is not selected context and does not support those claims. Adding that unrelated runbook to context would leave its citations irrelevant to Maya's question.

A cited sentence can be fully supported and still omit a condition Maya needs. Did the answer finish the policy?

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:

answer-point-coverage.py
def supported_point_coverage(answer: Answer, trace: RagTrace, gold: GoldCase) -> float:
    if not gold.required_points:
        raise ValueError("Point coverage needs nonempty required points")
    supported_points = {
        REVIEWED_SUPPORT[claim.text][2]
        for claim in answer.claims
        if claim_supported_by_context(claim, trace)
    }
    return len(supported_points & gold.required_points) / len(gold.required_points)

unsafe_coverage = supported_point_coverage(UNSAFE_BYPASS, TRACE, GOLD)
supported_coverage = supported_point_coverage(SUPPORTED_DEPLOY, TRACE, GOLD)
partial_coverage = supported_point_coverage(PARTIAL_DEPLOY, TRACE, GOLD)
print(f"unsafe supported point coverage: {unsafe_coverage:.2f}")
print(f"supported answer point coverage: {supported_coverage:.2f}")
print(f"partial supported point coverage: {partial_coverage:.2f}")
assert unsafe_coverage == 1 / 3
assert supported_coverage == 1.0
assert partial_coverage == 2 / 3
Output
unsafe supported point coverage: 0.33
supported answer point coverage: 1.00
partial supported point coverage: 0.67

Point 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 2/32/3 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.

When declining to answer is correct

Suppose Maya asks whether an executive escalation waives the rollback requirement. The admitted rule contains no such exception. "The available policy lists no 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?ResponseInterpretation
yesyesComplete, supported answerTask success
nonoExplain the evidence limitJustified abstention
yesnoExplain the evidence limitSafe response, retrieval/selection failure
yesyesRefuse without a reasonUnnecessary abstention

Replay that distinction with five reviewed outcomes. corpus_answerable is an annotation about the permitted, current corpus for this caller, not everything the organization stores. context_sufficient describes the evidence actually admitted. The response labels come from a reviewer; a model cannot certify its own refusal merely by calling it an abstention.

abstention-outcome-replay.py
from dataclasses import dataclass

@dataclass(frozen=True)
class ReviewedOutcome:
    name: str
    corpus_answerable: bool
    context_sufficient: bool
    response: str

def outcome_labels(case: ReviewedOutcome) -> tuple[bool, bool, bool]:
    if case.context_sufficient and not case.corpus_answerable:
        raise ValueError("Sufficient context contradicts the corpus label")
    if case.response not in {"complete-supported", "bounded-abstention", "unsupported"}:
        raise ValueError("Response needs independent review")
    supported_answer = case.response == "complete-supported" and case.context_sufficient
    safe_response = supported_answer or case.response == "bounded-abstention"
    evidence_path_ok = not case.corpus_answerable or case.context_sufficient
    task_success = supported_answer or (
        not case.corpus_answerable and case.response == "bounded-abstention"
    )
    return safe_response, evidence_path_ok, task_success

cases = (
    ReviewedOutcome("complete answer", True, True, "complete-supported"),
    ReviewedOutcome("waiver not established", False, False, "bounded-abstention"),
    ReviewedOutcome("missed rule, safe refusal", True, False, "bounded-abstention"),
    ReviewedOutcome("rule available, unnecessary refusal", True, True, "bounded-abstention"),
    ReviewedOutcome("no waiver exists anywhere", False, False, "unsupported"),
)
for case in cases:
    safe, evidence_ok, success = outcome_labels(case)
    print(f"{case.name}: safe={safe}, evidence_path_ok={evidence_ok}, task_success={success}")
assert outcome_labels(cases[2]) == (True, False, False)
assert outcome_labels(cases[3]) == (True, True, False)
assert outcome_labels(cases[4]) == (False, True, False)
Output
complete answer: safe=True, evidence_path_ok=True, task_success=True
waiver not established: safe=True, evidence_path_ok=True, task_success=True
missed rule, safe refusal: safe=True, evidence_path_ok=False, task_success=False
rule available, unnecessary refusal: safe=True, evidence_path_ok=True, task_success=False
no waiver exists anywhere: safe=False, evidence_path_ok=True, task_success=False

Here, safe means only that the reviewed response avoids unsupported policy claims; it doesn't certify general safety. evidence_path_ok=True on an unanswerable case means there was no labeled answer-bearing evidence to miss, not that every retrieval decision was optimal. A bounded refusal and an unsupported universal claim need different labels even when both contain no affirmative waiver.

The main harness below remains scoped to Maya's answerable requirements question. This separate replay demonstrates abstention accounting without treating empty context as an authorization failure or a perfect faithfulness score. A production suite must validate the actual empty-context trace, source-access decisions, and response text, then report unsupported answering and unnecessary abstention separately.

Attribute the first failure

When a RAG answer fails, start with one useful question: where did its evidence path first break? If you score the system as one black box, you won't know whether to inspect retrieval, reranking, or the answer itself.

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:

  1. Admissibility: Did unauthorized or stale evidence enter the pipeline?
  2. Candidate retrieval: Did hybrid search place the required chunk in initial candidates?
  3. Context selection: Did reranking keep the chunk in the admitted prompt context?
  4. Claim presence: Are there policy claims to evaluate?
  5. Claim review: Does the fixed answer key cover their exact text?
  6. Answer faithfulness: Are emitted claims supported by admitted evidence?
  7. Citation support: Do the attached sources support their claims?
  8. 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, extraction, and API response to identify where the text went missing.
  • Unreviewed text needs adjudication; the answer key can't distinguish a new contradiction from a new valid paraphrase.
  • For an invented bypass, inspect generation and its support labels. Test lower temperature or another model against the same cases before adopting the change.
  • A wrong citation calls for inspecting source attachment and validation.
  • A missing required point calls for inspecting answer coverage and the rubric.
Three answer claims each have a solid line to the DEPLOY-17 rule in the prompt, which supports them, and a dashed line to a rollback runbook that was never selected, which they cite. The checks run in order: the selected context includes DEPLOY-17 and passes, faithfulness passes at 3 of 3, citation support fails at 0 of 3 as the first failure, and completeness stays unscored.
The replay traces two separate links: DEPLOY-17 supports the claims, while each citation names an unselected runbook. Citation support is the first failed gate; completeness remains unscored.
first-failed-stage.py
RETRIEVAL_MISS = replace(
    TRACE,
    first_stage_ids=("payment-service-rollback-runbook", "frontend-docs-deploy-rule"),
    rerank_input_ids=("payment-service-rollback-runbook", "frontend-docs-deploy-rule"),
    reranked_ids=("payment-service-rollback-runbook", "frontend-docs-deploy-rule"),
    selected_context_ids=("payment-service-rollback-runbook",),
    selected_versions=("payment-rollback/2026-05-20",),
)
SELECTION_MISS = replace(
    TRACE,
    selected_context_ids=("payment-service-rollback-runbook",),
    selected_versions=("payment-rollback/2026-05-20",),
)

def first_failed_stage(trace: RagTrace, answer: Answer, gold: GoldCase) -> str:
    if not admissible_evidence_path(trace, gold):
        return "admissibility"
    if coverage(trace.first_stage_ids, gold.required_source_ids) < 1.0:
        return "candidate retrieval"
    if coverage(trace.selected_context_ids, gold.required_source_ids) < 1.0:
        return "context selection"
    if not answer.claims:
        return "claim presence"
    if any(
        claim.text not in REVIEWED_SUPPORT and claim.text not in REVIEWED_UNSUPPORTED
        for claim in answer.claims
    ):
        return "claim review"
    if faithfulness(answer, trace) < 1.0:
        return "answer faithfulness"
    if citation_support(answer, trace) < 1.0:
        return "citation support"
    if supported_point_coverage(answer, trace, gold) < 1.0:
        return "answer completeness"
    return "pass"

diagnoses = {
    "missing candidate": first_failed_stage(RETRIEVAL_MISS, SUPPORTED_DEPLOY, GOLD),
    "dropped context": first_failed_stage(SELECTION_MISS, SUPPORTED_DEPLOY, GOLD),
    "invented bypass": first_failed_stage(TRACE, UNSAFE_BYPASS, GOLD),
    "wrong citation": first_failed_stage(TRACE, MIS_CITED, GOLD),
    "empty answer": first_failed_stage(TRACE, EMPTY_ANSWER, GOLD),
    "missing rollback point": first_failed_stage(TRACE, PARTIAL_DEPLOY, GOLD),
    "supported answer": first_failed_stage(TRACE, SUPPORTED_DEPLOY, GOLD),
}
for variant, stage in diagnoses.items():
    print(f"{variant}: {stage}")
assert diagnoses["missing candidate"] == "candidate retrieval"
assert diagnoses["dropped context"] == "context selection"
assert diagnoses["invented bypass"] == "answer faithfulness"
assert diagnoses["wrong citation"] == "citation support"
assert diagnoses["empty answer"] == "claim presence"
assert diagnoses["missing rollback point"] == "answer completeness"
assert diagnoses["supported answer"] == "pass"
Output
missing candidate: candidate retrieval
dropped context: context selection
invented bypass: answer faithfulness
wrong citation: citation support
empty answer: claim presence
missing rollback point: answer completeness
supported answer: pass

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

evaluator-counterexamples.py
negated = replace(
    SUPPORTED_DEPLOY.claims[1], text="Incident commander approval is not required."
)
spoofed_text = Answer("negated", (negated,))
spoofed_point = Answer(
    "renamed-approval",
    PARTIAL_DEPLOY.claims + (replace(SUPPORTED_DEPLOY.claims[1], claim_id="rollback-plan"),),
)
paraphrase = Answer("unreviewed-paraphrase", (
    Claim("approval", "Approval from the incident commander is mandatory.", TARGET_ID),
))
print("Negated known claim:", first_failed_stage(TRACE, spoofed_text, GOLD))
print("Renamed duplicate:", first_failed_stage(TRACE, spoofed_point, GOLD))
print("Valid but unreviewed paraphrase:", first_failed_stage(TRACE, paraphrase, GOLD))
assert first_failed_stage(TRACE, spoofed_text, GOLD) == "claim review"
assert first_failed_stage(TRACE, spoofed_point, GOLD) == "answer completeness"
assert first_failed_stage(TRACE, paraphrase, GOLD) == "claim review"
assert supported_point_coverage(spoofed_point, TRACE, GOLD) == 2 / 3
Output
Negated known claim: claim review
Renamed duplicate: answer completeness
Valid but unreviewed paraphrase: claim review

An unreviewed contradiction and an unreviewed correct paraphrase both require review. This fixture routes both to review; after a reviewer distinguishes them, 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.

source-and-citation-counterexamples.py
original_chunk = EVIDENCE[TARGET_ID]
EVIDENCE[TARGET_ID] = replace(original_chunk, text="Edited policy under an unchanged version")
try:
    print("Edited source diagnosis:", first_failed_stage(TRACE, SUPPORTED_DEPLOY, GOLD))
    assert first_failed_stage(TRACE, SUPPORTED_DEPLOY, GOLD) == "admissibility"
    assert not source_supports(SUPPORTED_DEPLOY.claims[1], EVIDENCE[TARGET_ID])
finally:
    EVIDENCE[TARGET_ID] = original_chunk

blank_citations = Answer(
    "blank-citations",
    tuple(replace(claim, citation_id=" ") for claim in SUPPORTED_DEPLOY.claims),
)
wrong_question = replace(TRACE, question="Does a different service have a freeze exemption?")
print(f"Blank citation coverage: {citation_coverage(blank_citations):.1f}")
print("Changed question admissible:", admissible_evidence_path(wrong_question, GOLD))
assert citation_coverage(blank_citations) == 0.0
assert first_failed_stage(TRACE, blank_citations, GOLD) == "citation support"
assert not admissible_evidence_path(wrong_question, GOLD)
Output
Edited source diagnosis: admissibility
Blank citation coverage: 0.0
Changed question admissible: False

Make 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 :

answer-release-regression.py
@dataclass(frozen=True)
class ReleaseCase:
    name: str
    trace: RagTrace
    answer: Answer
    should_release: bool

def can_release(trace: RagTrace, answer: Answer, gold: GoldCase) -> bool:
    return first_failed_stage(trace, answer, gold) == "pass"

RELEASE_CASES = (
    ReleaseCase("supported answer", TRACE, SUPPORTED_DEPLOY, True),
    ReleaseCase("unsupported bypass", TRACE, UNSAFE_BYPASS, False),
    ReleaseCase("wrong citation", TRACE, MIS_CITED, False),
    ReleaseCase("empty answer", TRACE, EMPTY_ANSWER, False),
    ReleaseCase("dropped source", SELECTION_MISS, SUPPORTED_DEPLOY, False),
    ReleaseCase("missing rollback point", TRACE, PARTIAL_DEPLOY, False),
)
regression_passes = 0
for case in RELEASE_CASES:
    observed = can_release(case.trace, case.answer, GOLD)
    regression_passes += observed == case.should_release
    print(f"{case.name}: release={observed}, expected={case.should_release}")
print(f"Regression checks passed: {regression_passes}/{len(RELEASE_CASES)}")
assert regression_passes == len(RELEASE_CASES)
Output
supported answer: release=True, expected=True
unsupported bypass: release=False, expected=False
wrong citation: release=False, expected=False
empty answer: release=False, expected=False
dropped source: release=False, expected=False
missing rollback point: release=False, expected=False
Regression checks passed: 6/6

Release behavior is now concrete: pass the supported response and reject the five controlled defects.

Slice results before trusting an average

A larger should include release-freeze deploys, incident hotfixes, schema migrations, and data . Aggregate quality can remain high while a costly policy slice regresses.

slice-level-report.py
@dataclass(frozen=True)
class LabeledOutcome:
    workflow: str
    released_correctly: bool

    def __post_init__(self) -> None:
        if not isinstance(self.workflow, str) or not self.workflow.strip():
            raise ValueError("workflow must be a nonempty string")
        if type(self.released_correctly) is not bool:
            raise ValueError("outcome must be a Boolean")

OUTCOMES = (
    LabeledOutcome("release-freeze", True),
    LabeledOutcome("release-freeze", False),
    LabeledOutcome("incident-hotfix", True),
    LabeledOutcome("incident-hotfix", True),
    LabeledOutcome("schema-migration", False),
)
by_workflow: dict[str, list[bool]] = defaultdict(list)
for outcome in OUTCOMES:
    by_workflow[outcome.workflow].append(outcome.released_correctly)

overall = sum(outcome.released_correctly for outcome in OUTCOMES) / len(OUTCOMES)
print(f"Overall pass rate: {overall:.0%}")
for workflow, results in sorted(by_workflow.items()):
    print(f"{workflow}: {sum(results)}/{len(results)} ({sum(results) / len(results):.0%})")
assert overall == 0.6
assert sum(by_workflow["schema-migration"]) == 0
Output
Overall pass rate: 60%
incident-hotfix: 2/2 (100%)
release-freeze: 1/2 (50%)
schema-migration: 0/1 (0%)
release-report.py
REQUIRED_FIXTURE_SLICES = frozenset({"release-freeze", "incident-hotfix", "schema-migration"})

def slice_regression_clear(results_by_workflow: dict[str, list[bool]]) -> bool:
    # The local 95/100 rule is exact count arithmetic, not a population bound.
    if not REQUIRED_FIXTURE_SLICES.issubset(results_by_workflow):
        return False
    for workflow in REQUIRED_FIXTURE_SLICES:
        results = results_by_workflow[workflow]
        if (
            not isinstance(results, list)
            or not results
            or any(type(result) is not bool for result in results)
            or sum(results) * 100 < 95 * len(results)
        ):
            return False
    return True

release_report = {
    "service_version": "policy-answerer-v4-eval",
    "source_trace": TRACE.case_id,
    "required_policy_version": EVIDENCE[TARGET_ID].version,
    "checks": {
        "admissible_evidence_path": admissible_evidence_path(TRACE, GOLD),
        "supported_answer_released": can_release(TRACE, SUPPORTED_DEPLOY, GOLD),
        "known_bad_answers_blocked": all(
            not can_release(case.trace, case.answer, GOLD)
            for case in RELEASE_CASES
            if not case.should_release
        ),
        "slice_regression_clear": slice_regression_clear(by_workflow),
    },
}
print("Release checks:", release_report["checks"])
print("Release allowed:", all(release_report["checks"].values()))
assert not all(release_report["checks"].values())
Output
Release checks: {'admissible_evidence_path': True, 'supported_answer_released': True, 'known_bad_answers_blocked': True, 'slice_regression_clear': False}
Release allowed: False

The 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, NN counts answers with mi>0m_i>0 atomic claims; report empty-claims cases and abstentions separately rather than silently assigning an undefined ratio. Two averages answer different questions:

  1. Pooled-claim faithfulness (micro-average): All claims from all answers are dumped into a single pool: Faithfulness⁡pooled=∑i=1N∑j=1miI(entailed(ci,j,Ei))∑i=1Nmi\operatorname{Faithfulness}_{\text{pooled}} = \frac{\sum_{i=1}^N \sum_{j=1}^{m_i} \mathbb{I}(\text{entailed}(c_{i,j}, E_i))}{\sum_{i=1}^N m_i}
  2. Mean per-answer faithfulness (macro-average): Each answer's faithfulness ratio is computed individually, and those ratios are averaged across queries: Faithfulness⁡macro=1N∑i=1N(∑j=1miI(entailed(ci,j,Ei))mi)\operatorname{Faithfulness}_{\text{macro}} = \frac{1}{N} \sum_{i=1}^N \left( \frac{\sum_{j=1}^{m_i} \mathbb{I}(\text{entailed}(c_{i,j}, E_i))}{m_i} \right)

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:

Faithfulness⁡pooled=9+09+1=910=90%\operatorname{Faithfulness}_{\text{pooled}} = \frac{9 + 0}{9 + 1} = \frac{9}{10} = 90\%

Under mean per-answer aggregation:

Faithfulness⁡macro=1.0+0.02=50%\operatorname{Faithfulness}_{\text{macro}} = \frac{1.0 + 0.0}{2} = 50\%

The fraction of nonempty answers whose claims are all supported is:

ClaimSupportPass⁡=1+02=50%\operatorname{ClaimSupportPass} = \frac{1 + 0}{2} = 50\%

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.

claim-versus-answer-aggregation.py
annotated_counts = [(9, 9), (0, 1)]  # (supported, emitted), hypothetical cases
pooled = sum(supported for supported, _ in annotated_counts) / sum(
    emitted for _, emitted in annotated_counts
)
per_answer = sum(supported / emitted for supported, emitted in annotated_counts) / len(annotated_counts)
claim_support_pass = sum(supported == emitted for supported, emitted in annotated_counts) / len(annotated_counts)
print(f"Pooled claim support: {pooled:.0%}")
print(f"Mean per-answer support: {per_answer:.0%}")
print(f"All-claims-supported fraction: {claim_support_pass:.0%}")
print(f"Partial answer: all claims supported={faithfulness(PARTIAL_DEPLOY, TRACE) == 1.0}, "
      f"task complete={supported_point_coverage(PARTIAL_DEPLOY, TRACE, GOLD) == 1.0}")
assert (pooled, per_answer, claim_support_pass) == (0.9, 0.5, 0.5)
assert not can_release(TRACE, PARTIAL_DEPLOY, GOLD)
assert not slice_regression_clear({})
assert not slice_regression_clear({workflow: [] for workflow in REQUIRED_FIXTURE_SLICES})
Output
Pooled claim support: 90%
Mean per-answer support: 50%
All-claims-supported fraction: 50%
Partial answer: all claims supported=True, task complete=False

Choose 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 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 similarity to questions generated from the answer, and context relevance from extracted relevant sentences. "Reference-free" means the metric needs no gold answer. It still judges claims against retrieved evidence.[2] ARES trains judges on synthetic examples and uses human labels with prediction-powered inference to estimate aggregate evaluation scores with . Those intervals quantify aggregate system-level estimates. Evaluate each answer against its evidence and labels.[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 .

Those approaches don't remove the need for this trace design. An automated judge still needs:

InputWhy the judge needs it
Question and expected workflowDecide whether the answer addressed the request
Admitted context and versionDecide whether claims are grounded in allowed evidence
Atomic claims and citationsExplain which claim failed and which source was cited
Human-reviewed calibration setDetect 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. Authorization and trace integrity stay independent of any later semantic judge. What still has to pass before this answerer serves another freeze-deploy question?

Release gates for policy-answerer-v4

Before this answerer serves another freeze-deploy question:

GateMinimum evidenceFirst fix when it fails
AdmissibilityRetrieved, reranked, and selected chunk IDs; versions; /freshness decisionEvidence filtering
Candidate recallRequired chunk in retrieved candidates, query, or retrieval lane
Context admissionSufficient admitted evidence; measure distracting context separatelyReranker or context gate
FaithfulnessSupported claim ledgerPrompt, abstention, or generation policy
Citation supportClaim-to-source validationCitation attachment or validator
CompletenessRequired points covered, or justified abstention on an unanswerable caseAnswer policy and coverage rubric
Slice healthWorkflow-level regression reportBlock release and debug affected slice

Don't compress these gates into one quality percentage. A dashboard should tell an engineer what to repair first.

Use the first failed check to choose the next inspection. A supported omission is a different miss from a contradiction, and an average can hide a broken policy slice. The answer key still cannot score a paraphrase it has never seen.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A refund-policy trace returns a restricted customer-credit note across the caller boundary, then drops it before selecting the correct public policy. Which first result matches the full-path contract?
2.An archived answer cites policy revision r7. Today r8 reverses that rule, while a replay store resolves the original r7 bytes and generation trace. Which evaluation distinguishes the two questions?
3.Required useful source IDs are A and B. Candidates are A, B, C and D; selected context is A, C and D. All four IDs are permitted and current. What are candidate recall, context recall and selected-set precision?
4.An answer emits five adjudicated claims. Four are supported, but those four cover only three distinct points among five required points. Citations are valid. What are faithfulness, potential completeness and the first failed answer check?
5.Four claims are supported by selected evidence. Three cite the correct supporting source, and the fourth has a blank citation. What are faithfulness, citation coverage and citation support before repairing that attachment?
6.A new answer says that sign-off from the incident commander is mandatory. Its wording is absent from the reviewed exact key, but it carries a familiar approval claim ID. What should the exact-key harness do?
7.A caller cannot access a policy that would answer a waiver question. The permitted current corpus has no answer, and selected context is insufficient. A reviewed response explains that limit and refers the caller to the policy owner. How does the declared abstention rubric classify it?
8.Two nonempty answers have supported/emitted claim counts of 6/8 and 1/2. What are pooled-claim faithfulness and mean per-answer faithfulness?
9.A local gate requires nonempty freeze, hotfix and migration slices, each at least 95% correct. The report has freeze 19/20, hotfix 39/40 and no migration observations. What release decision follows?
10.A support judge is tuned on 80 supported answers and no unsupported answers. The team now wants to estimate false acceptance on a frozen test. Which repair supplies the missing evidence while preserving the evaluation boundaries?

10 questions remaining.

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.