File size: 6,821 Bytes
2e818da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
"""Labelled-evaluation quality scoring for the RAG observability benchmark.

Implements exactly the "Labelled evaluation quality measures" from the plan
preamble against this benchmark's real candidate shape (`document_id` +
`chunk_index`, as `ChromaDBClient.query_observed` / `_get_chunks` actually
return them -- see `app/websockets/handlers.py::_get_chunks`). `page_hit` from
the preamble's canonical list becomes `section_hit` here: this corpus is plain
text, not paginated PDFs, and the plan preamble explicitly allows substituting
sections for pages in that case.

Two entry points, matching the task-4 brief's two example tests:

* `score_retrieval(label, candidates)` -- pure ranking-quality scoring over an
  already-successful candidate list. Knows nothing about *why* a retrieval
  produced its candidates, only whether they're relevant.
* `score_outcome(label, outcome)` -- the full labelled-quality result for one
  `RetrievalOutcome`, preserving the empty-vs-error distinction the whole
  telemetry contract exists to keep visible: a fetch that legitimately found
  nothing (`success_empty`) is never scored the same as a fetch that failed
  outright (`error_fallback`). Delegates to `score_retrieval` only on the
  success path.

Emits nothing -- pure functions over inputs already in memory, called by
`runner.py` once per measured query invocation.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from app.benchmarks.rag_observability.corpus import QueryLabel, SectionIndex
from app.observability.contracts import RetrievalOutcome


@dataclass(frozen=True)
class RetrievalQualityResult:
    """Ranking-quality result over a candidate list alone (no outcome context)."""

    document_hit: bool
    # None when the label has no expected_sections (not applicable). When not
    # None, this is an ANY-match: True if at least one candidate's section is
    # in `label.expected_sections`, not a requirement that every expected
    # section be matched.
    section_hit: bool | None
    reciprocal_rank: float


@dataclass(frozen=True)
class QualityResult:
    """The full canonical labelled-quality row for one scored query invocation."""

    document_hit: bool
    section_hit: bool | None  # any-expected-section-matched, not all -- see RetrievalQualityResult
    reciprocal_rank: float
    citation_validity: bool | None  # None when the label doesn't require a citation
    empty_result: bool
    retrieval_error: bool


def score_retrieval(label: QueryLabel, candidates: list[dict[str, Any]]) -> RetrievalQualityResult:
    """Score a ranked candidate list against `label`'s expected documents/sections.

    `reciprocal_rank` is standard MRR: 1 / rank of the first candidate whose
    `document_id` is in `label.expected_documents` (1-indexed), or 0.0 if none
    match. `section_hit` only considers candidates that *also* matched an
    expected document -- a section match on an irrelevant document is not
    meaningful (see `test_section_hit_only_counts_among_expected_document_candidates`).
    """
    document_hit = False
    reciprocal_rank = 0.0
    section_hit: bool | None = None if not label.expected_sections else False

    section_index = _section_index() if label.expected_sections else None

    for rank, candidate in enumerate(candidates, start=1):
        doc_id = candidate.get("document_id")
        if doc_id not in label.expected_documents:
            continue
        if not document_hit:
            document_hit = True
            reciprocal_rank = 1.0 / rank
        if section_index is not None:
            section = section_index.section_for_chunk(doc_id, candidate.get("chunk_index"))
            if section in label.expected_sections:
                # Any-match semantics: one hit anywhere in the ranked list is
                # enough. This never resets to False and never requires every
                # entry in `label.expected_sections` to be covered.
                section_hit = True

    return RetrievalQualityResult(
        document_hit=document_hit,
        section_hit=section_hit,
        reciprocal_rank=reciprocal_rank,
    )


def score_outcome(label: QueryLabel, outcome: RetrievalOutcome) -> QualityResult:
    """Score one full `RetrievalOutcome`, preserving empty-vs-error distinctly.

    A failed retrieval (`retrieval_error=True`) is scored as a total miss on
    every ranking measure without ever being conflated with a legitimate empty
    result -- an error and an empty result are different rows in the canonical
    contract and must stay that way here too.
    """
    if outcome.retrieval_error:
        return QualityResult(
            document_hit=False,
            section_hit=None,
            reciprocal_rank=0.0,
            citation_validity=None,
            empty_result=False,
            retrieval_error=True,
        )

    ranking = score_retrieval(label, outcome.candidates)
    citation_validity = _score_citation_validity(label, outcome, ranking)

    return QualityResult(
        document_hit=ranking.document_hit,
        section_hit=ranking.section_hit,
        reciprocal_rank=ranking.reciprocal_rank,
        citation_validity=citation_validity,
        empty_result=outcome.empty_result,
        retrieval_error=False,
    )


def _score_citation_validity(
    label: QueryLabel, outcome: RetrievalOutcome, ranking: RetrievalQualityResult
) -> bool | None:
    """Whether a citation-required query has (or correctly lacks) grounding to cite.

    Not a claim about any LLM-generated citation text -- this benchmark only
    measures retrieval, never synthesis. It is a retrieval-observable proxy:
    for an answerable query, valid means the expected document was actually
    retrieved (`document_hit`); for a deliberately unanswerable query, valid
    means retrieval came back empty -- any non-empty result would be exactly
    the retrieval-side precondition for a downstream fabricated citation.
    """
    if not label.citation_required:
        return None
    if label.answerability == "unanswerable":
        return len(outcome.candidates) == 0
    return ranking.document_hit


_section_index_cache: SectionIndex | None = None


def _section_index() -> SectionIndex:
    """Lazily build (and cache) the corpus section index on first use.

    Scoring is called many times (once per measured repetition) against the
    same fixed corpus, so this avoids re-chunking every document on every
    query. Module-level cache is safe here: the benchmark process only ever
    loads one corpus version per run.
    """
    global _section_index_cache
    if _section_index_cache is None:
        from app.benchmarks.rag_observability.corpus import load_corpus

        _section_index_cache = SectionIndex(load_corpus())
    return _section_index_cache