ragaudit-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ragaudit/__init__.py +1 -0
- ragaudit/adapters/__init__.py +0 -0
- ragaudit/adapters/base.py +109 -0
- ragaudit/adapters/callable_adapter.py +15 -0
- ragaudit/cli.py +540 -0
- ragaudit/cli_easy.py +458 -0
- ragaudit/cli_v2.py +507 -0
- ragaudit/env.py +46 -0
- ragaudit/generate/__init__.py +0 -0
- ragaudit/generate/pipeline.py +486 -0
- ragaudit/generate/prompts.py +116 -0
- ragaudit/generate/testset_generator.py +146 -0
- ragaudit/generate/text.py +75 -0
- ragaudit/ingest/__init__.py +0 -0
- ragaudit/ingest/chunker.py +53 -0
- ragaudit/ingest/corpus.py +120 -0
- ragaudit/ingest/loader.py +49 -0
- ragaudit/ingest/versioning.py +92 -0
- ragaudit/llm/__init__.py +0 -0
- ragaudit/llm/client.py +86 -0
- ragaudit/llm/factory.py +70 -0
- ragaudit/llm/json_output.py +72 -0
- ragaudit/llm/mock.py +552 -0
- ragaudit/models/__init__.py +0 -0
- ragaudit/models/adapter.py +39 -0
- ragaudit/models/document.py +28 -0
- ragaudit/models/evaluation.py +70 -0
- ragaudit/models/question.py +152 -0
- ragaudit/models/run.py +44 -0
- ragaudit/models/scoring.py +82 -0
- ragaudit/models/testset.py +30 -0
- ragaudit/models/tune.py +56 -0
- ragaudit/report/__init__.py +0 -0
- ragaudit/report/generator.py +155 -0
- ragaudit/report/plain_summary.py +122 -0
- ragaudit/report/templates/_v2_tooltip.jinja +43 -0
- ragaudit/report/templates/generate_report.html.jinja +175 -0
- ragaudit/report/templates/report.html.jinja +252 -0
- ragaudit/report/templates/style.css +1095 -0
- ragaudit/report/templates/tune_report.html.jinja +158 -0
- ragaudit/report/templates/v2.css +286 -0
- ragaudit/report/v2.py +380 -0
- ragaudit/runner/__init__.py +0 -0
- ragaudit/runner/ablation.py +46 -0
- ragaudit/runner/evaluate.py +325 -0
- ragaudit/runner/executor.py +98 -0
- ragaudit/runner/locked.py +37 -0
- ragaudit/runner/summary.py +45 -0
- ragaudit/scaffold.py +252 -0
- ragaudit/scoring/__init__.py +0 -0
- ragaudit/scoring/correctness.py +94 -0
- ragaudit/scoring/groundedness.py +86 -0
- ragaudit/scoring/retrieval.py +42 -0
- ragaudit/scoring/text_match.py +51 -0
- ragaudit/scoring/verdict.py +14 -0
- ragaudit/storage/__init__.py +0 -0
- ragaudit/storage/db.py +81 -0
- ragaudit/storage/lock.py +36 -0
- ragaudit/storage/repository.py +216 -0
- ragaudit/storage/store.py +536 -0
- ragaudit/tune/__init__.py +7 -0
- ragaudit/tune/devset.py +16 -0
- ragaudit/tune/loop.py +208 -0
- ragaudit/tune/search_space.py +140 -0
- ragaudit/tune/strategies.py +197 -0
- ragaudit/yaml_io.py +199 -0
- ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
- ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
- ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
- ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
- ragaudit_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""v2 evaluation: run questions through a configured pipeline, score them, cache the results.
|
|
2
|
+
|
|
3
|
+
Per question: recall@k and reciprocal rank against the gold chunk_ids,
|
|
4
|
+
answer correctness from v1's LLM judge, a failure class (pass /
|
|
5
|
+
retrieval_miss / generation_miss), and v1's verdict (a correct answer
|
|
6
|
+
without the gold chunk is a LUCKY_PASS).
|
|
7
|
+
|
|
8
|
+
Results are cached under (question_id, config_hash, index_version), so a
|
|
9
|
+
question is never re-run for the same pipeline config on the same corpus.
|
|
10
|
+
Which questions run is decided by `select_questions`:
|
|
11
|
+
|
|
12
|
+
- the config was never evaluated on this split: run everything (`full`);
|
|
13
|
+
- the corpus (index_version) changed but the config didn't: run all new
|
|
14
|
+
questions plus a seeded random sample of N old ones (`regression`),
|
|
15
|
+
because new data mostly breaks old questions by adding distractors, and
|
|
16
|
+
a sample catches that without re-running thousands of questions;
|
|
17
|
+
- nothing changed: every result comes from the cache, and any question
|
|
18
|
+
added since is run (`cached`).
|
|
19
|
+
|
|
20
|
+
This module never reads the test split itself; test-split evaluation goes
|
|
21
|
+
through ragaudit.runner.locked, which unlocks it and calls in here.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import random
|
|
28
|
+
import time
|
|
29
|
+
import uuid
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
|
|
35
|
+
from ragaudit.models.adapter import Adapter, RAGResponse
|
|
36
|
+
from ragaudit.models.evaluation import EvalSummary, FailureClass, QuestionResult
|
|
37
|
+
from ragaudit.models.question import Question, Split, utc_now
|
|
38
|
+
from ragaudit.models.scoring import AblationResult, CorrectnessResult, Verdict
|
|
39
|
+
from ragaudit.scoring.text_match import DEFAULT_MATCH_THRESHOLD, first_matching_context
|
|
40
|
+
from ragaudit.scoring.verdict import compute_verdict
|
|
41
|
+
from ragaudit.storage.store import Store
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
DEFAULT_K = 5
|
|
46
|
+
DEFAULT_REGRESSION_SAMPLE = 50
|
|
47
|
+
SCORING_VERSION = 1
|
|
48
|
+
"""Bump when scoring changes meaningfully, so cached results from the old scoring aren't reused."""
|
|
49
|
+
|
|
50
|
+
CorrectnessJudge = Callable[[str, str, str], CorrectnessResult]
|
|
51
|
+
"""question, gold_answer, candidate_answer -> CorrectnessResult (v1's judge_correctness, bound to a client)."""
|
|
52
|
+
|
|
53
|
+
NoContextQuery = Callable[[str], str]
|
|
54
|
+
"""question -> answer with no retrieval at all, for ablation."""
|
|
55
|
+
|
|
56
|
+
ADAPTER_ERROR_JUDGE_MODEL = "n/a (adapter raised)"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Selection(BaseModel):
|
|
60
|
+
mode: str
|
|
61
|
+
questions: list[Question]
|
|
62
|
+
n_new: int = 0
|
|
63
|
+
n_sampled_old: int = 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def file_sha256(path: str | Path) -> str:
|
|
67
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def compute_config_hash(
|
|
71
|
+
config: dict, adapter_sha256: str, judge_model: str, k: int, match_threshold: float | None = None
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Identity of everything that can change a result: pipeline config, adapter code, judge model, k.
|
|
74
|
+
|
|
75
|
+
match_threshold is set only for --match-by text, so id-mode hashes (and
|
|
76
|
+
their cached results) are the same as before text matching existed.
|
|
77
|
+
"""
|
|
78
|
+
payload = {
|
|
79
|
+
"config": config,
|
|
80
|
+
"adapter_sha256": adapter_sha256,
|
|
81
|
+
"judge_model": judge_model,
|
|
82
|
+
"k": k,
|
|
83
|
+
"scoring_version": SCORING_VERSION,
|
|
84
|
+
}
|
|
85
|
+
if match_threshold is not None:
|
|
86
|
+
payload["match"] = {"by": "text", "threshold": match_threshold}
|
|
87
|
+
return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode("utf-8")).hexdigest()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def score_retrieval_at_k(gold_chunk_ids: list[str], retrieved_chunk_ids: list[str], k: int) -> tuple[float, float, bool]:
|
|
91
|
+
"""(recall@k, reciprocal rank, every gold chunk retrieved) over the first k retrieved ids."""
|
|
92
|
+
top_k = retrieved_chunk_ids[:k]
|
|
93
|
+
gold = set(gold_chunk_ids)
|
|
94
|
+
found = gold & set(top_k)
|
|
95
|
+
recall = len(found) / len(gold) if gold else 0.0
|
|
96
|
+
first_rank = next((rank for rank, chunk_id in enumerate(top_k) if chunk_id in gold), None)
|
|
97
|
+
reciprocal_rank = 1.0 / (first_rank + 1) if first_rank is not None else 0.0
|
|
98
|
+
return recall, reciprocal_rank, bool(gold) and found == gold
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def score_retrieval_by_text_at_k(
|
|
102
|
+
gold_texts: list[str], contexts: list[str], k: int, threshold: float = DEFAULT_MATCH_THRESHOLD
|
|
103
|
+
) -> tuple[float, float, bool]:
|
|
104
|
+
"""Like score_retrieval_at_k, but a gold chunk counts as retrieved when one of the first k contexts overlaps its text."""
|
|
105
|
+
top_k = contexts[:k]
|
|
106
|
+
ranks = [first_matching_context(text, top_k, threshold)[0] for text in gold_texts]
|
|
107
|
+
found = [rank for rank in ranks if rank is not None]
|
|
108
|
+
recall = len(found) / len(gold_texts) if gold_texts else 0.0
|
|
109
|
+
reciprocal_rank = 1.0 / (min(found) + 1) if found else 0.0
|
|
110
|
+
return recall, reciprocal_rank, bool(gold_texts) and len(found) == len(gold_texts)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def classify(retrieval_hit: bool, is_correct: bool) -> FailureClass:
|
|
114
|
+
if not retrieval_hit:
|
|
115
|
+
return FailureClass.RETRIEVAL_MISS
|
|
116
|
+
return FailureClass.PASS if is_correct else FailureClass.GENERATION_MISS
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def evaluate_question(
|
|
120
|
+
question: Question,
|
|
121
|
+
adapter: Adapter,
|
|
122
|
+
judge: CorrectnessJudge,
|
|
123
|
+
k: int,
|
|
124
|
+
chunk_texts: dict[str, str] | None = None,
|
|
125
|
+
match_threshold: float = DEFAULT_MATCH_THRESHOLD,
|
|
126
|
+
) -> QuestionResult:
|
|
127
|
+
"""Run one question through the adapter and score it. An adapter exception is a scored failure, not a crash.
|
|
128
|
+
|
|
129
|
+
With chunk_texts (chunk_id -> text), retrieval is matched by text against
|
|
130
|
+
the returned contexts (--match-by text) instead of by chunk id.
|
|
131
|
+
"""
|
|
132
|
+
started = time.perf_counter()
|
|
133
|
+
try:
|
|
134
|
+
response = adapter.query(question.question)
|
|
135
|
+
error = None
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
logger.warning("Adapter raised for question_id=%s: %s", question.question_id, exc)
|
|
138
|
+
response, error = RAGResponse(answer=""), str(exc)
|
|
139
|
+
latency_ms = response.latency_ms if response.latency_ms is not None else (time.perf_counter() - started) * 1000
|
|
140
|
+
|
|
141
|
+
if chunk_texts is None:
|
|
142
|
+
recall, reciprocal_rank, hit = score_retrieval_at_k(question.chunk_ids, response.retrieved_chunk_ids, k)
|
|
143
|
+
else:
|
|
144
|
+
missing = [chunk_id for chunk_id in question.chunk_ids if chunk_id not in chunk_texts]
|
|
145
|
+
if missing:
|
|
146
|
+
raise ValueError(f"chunk(s) {missing} are not in the corpus passed with --match-by text")
|
|
147
|
+
gold_texts = [chunk_texts[chunk_id] for chunk_id in question.chunk_ids]
|
|
148
|
+
recall, reciprocal_rank, hit = score_retrieval_by_text_at_k(gold_texts, response.contexts, k, match_threshold)
|
|
149
|
+
if error is None:
|
|
150
|
+
correctness = judge(question.question, question.gold_answer, response.answer)
|
|
151
|
+
else:
|
|
152
|
+
correctness = CorrectnessResult(
|
|
153
|
+
is_correct=False, rationale=f"adapter raised: {error}", judge_model=ADAPTER_ERROR_JUDGE_MODEL
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return QuestionResult(
|
|
157
|
+
question_id=question.question_id,
|
|
158
|
+
split=question.split.value,
|
|
159
|
+
answer=response.answer,
|
|
160
|
+
retrieved_chunk_ids=response.retrieved_chunk_ids[:k],
|
|
161
|
+
recall_at_k=recall,
|
|
162
|
+
reciprocal_rank=reciprocal_rank,
|
|
163
|
+
retrieval_hit=hit,
|
|
164
|
+
correctness=correctness,
|
|
165
|
+
failure_class=classify(hit, correctness.is_correct),
|
|
166
|
+
verdict=compute_verdict(is_correct=correctness.is_correct, retrieval_hit=hit),
|
|
167
|
+
adapter_error=error,
|
|
168
|
+
latency_ms=round(latency_ms, 2),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def select_questions(
|
|
173
|
+
store: Store,
|
|
174
|
+
questions: list[Question],
|
|
175
|
+
split: Split,
|
|
176
|
+
config_hash: str,
|
|
177
|
+
index_version: str,
|
|
178
|
+
regression_sample: int = DEFAULT_REGRESSION_SAMPLE,
|
|
179
|
+
seed: int = 0,
|
|
180
|
+
) -> Selection:
|
|
181
|
+
"""Pick which active questions this evaluation covers (see the module docstring)."""
|
|
182
|
+
last_run = store.last_eval_run(config_hash, split)
|
|
183
|
+
if last_run is None:
|
|
184
|
+
return Selection(mode="full", questions=questions, n_new=len(questions))
|
|
185
|
+
|
|
186
|
+
seen = store.result_question_ids(config_hash, split)
|
|
187
|
+
new = [q for q in questions if q.question_id not in seen]
|
|
188
|
+
old = [q for q in questions if q.question_id in seen]
|
|
189
|
+
|
|
190
|
+
if last_run.index_version == index_version:
|
|
191
|
+
return Selection(mode="cached", questions=old + new, n_new=len(new))
|
|
192
|
+
|
|
193
|
+
rng = random.Random(f"{seed}:{index_version}")
|
|
194
|
+
sampled_ids = set(rng.sample(sorted(q.question_id for q in old), min(regression_sample, len(old))))
|
|
195
|
+
sampled = [q for q in old if q.question_id in sampled_ids]
|
|
196
|
+
return Selection(mode="regression", questions=new + sampled, n_new=len(new), n_sampled_old=len(sampled))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def summarize(
|
|
200
|
+
results: list[QuestionResult],
|
|
201
|
+
*,
|
|
202
|
+
split: Split,
|
|
203
|
+
config: dict,
|
|
204
|
+
config_hash: str,
|
|
205
|
+
index_version: str,
|
|
206
|
+
k: int,
|
|
207
|
+
selection: Selection,
|
|
208
|
+
n_active_in_split: int,
|
|
209
|
+
n_cached: int,
|
|
210
|
+
) -> EvalSummary:
|
|
211
|
+
n = len(results)
|
|
212
|
+
|
|
213
|
+
def rate(count: float) -> float:
|
|
214
|
+
return round(count / n, 4) if n else 0.0
|
|
215
|
+
|
|
216
|
+
failure_counts = {fc.value: 0 for fc in FailureClass}
|
|
217
|
+
verdict_counts = {v.value: 0 for v in Verdict}
|
|
218
|
+
for result in results:
|
|
219
|
+
failure_counts[result.failure_class.value] += 1
|
|
220
|
+
verdict_counts[result.verdict.value] += 1
|
|
221
|
+
|
|
222
|
+
lucky = [r for r in results if r.verdict == Verdict.LUCKY_PASS]
|
|
223
|
+
ablated = [r for r in lucky if r.ablation is not None]
|
|
224
|
+
return EvalSummary(
|
|
225
|
+
run_id=str(uuid.uuid4()),
|
|
226
|
+
created_at=utc_now(),
|
|
227
|
+
split=split.value,
|
|
228
|
+
config_hash=config_hash,
|
|
229
|
+
config=config,
|
|
230
|
+
index_version=index_version,
|
|
231
|
+
k=k,
|
|
232
|
+
mode=selection.mode,
|
|
233
|
+
n_questions=n,
|
|
234
|
+
n_active_in_split=n_active_in_split,
|
|
235
|
+
n_new=selection.n_new,
|
|
236
|
+
n_sampled_old=selection.n_sampled_old,
|
|
237
|
+
n_cached=n_cached,
|
|
238
|
+
n_run=n - n_cached,
|
|
239
|
+
score=round(failure_counts[FailureClass.PASS.value] / n * 100, 2) if n else 0.0,
|
|
240
|
+
answer_accuracy=rate(sum(r.correctness.is_correct for r in results)),
|
|
241
|
+
recall_at_k=rate(sum(r.recall_at_k for r in results)),
|
|
242
|
+
mrr=rate(sum(r.reciprocal_rank for r in results)),
|
|
243
|
+
failure_counts=failure_counts,
|
|
244
|
+
verdict_counts=verdict_counts,
|
|
245
|
+
judge_failures=sum(r.correctness.judge_parse_failed for r in results),
|
|
246
|
+
adapter_errors=sum(r.adapter_error is not None for r in results),
|
|
247
|
+
lucky_pass_confirmed=sum(r.ablation.confirms_lucky_pass for r in ablated) if ablated else None,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _ablate(result: QuestionResult, question: Question, no_context_query: NoContextQuery, judge: CorrectnessJudge) -> QuestionResult:
|
|
252
|
+
answer = no_context_query(question.question)
|
|
253
|
+
is_correct = judge(question.question, question.gold_answer, answer).is_correct
|
|
254
|
+
return result.model_copy(
|
|
255
|
+
update={"ablation": AblationResult(answer=answer, is_correct=is_correct, confirms_lucky_pass=is_correct)}
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def run_evaluation(
|
|
260
|
+
store: Store,
|
|
261
|
+
questions: list[Question],
|
|
262
|
+
split: Split,
|
|
263
|
+
adapter: Adapter,
|
|
264
|
+
judge: CorrectnessJudge,
|
|
265
|
+
*,
|
|
266
|
+
config: dict,
|
|
267
|
+
config_hash: str,
|
|
268
|
+
index_version: str,
|
|
269
|
+
k: int = DEFAULT_K,
|
|
270
|
+
regression_sample: int = DEFAULT_REGRESSION_SAMPLE,
|
|
271
|
+
seed: int = 0,
|
|
272
|
+
full: bool = False,
|
|
273
|
+
no_context_query: NoContextQuery | None = None,
|
|
274
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
275
|
+
chunk_texts: dict[str, str] | None = None,
|
|
276
|
+
match_threshold: float = DEFAULT_MATCH_THRESHOLD,
|
|
277
|
+
) -> tuple[EvalSummary, list[QuestionResult]]:
|
|
278
|
+
"""Evaluate the selected questions (cached results reused), save the run, return summary and results.
|
|
279
|
+
|
|
280
|
+
`questions` must all be from `split`; callers pass dev questions
|
|
281
|
+
directly and test questions only via ragaudit.runner.locked. Pass
|
|
282
|
+
no_context_query to ablate LUCKY_PASS results (v1's confirmation step).
|
|
283
|
+
"""
|
|
284
|
+
if any(q.split != split for q in questions):
|
|
285
|
+
raise ValueError(f"run_evaluation got questions outside the {split.value} split")
|
|
286
|
+
|
|
287
|
+
if full:
|
|
288
|
+
selection = Selection(mode="full", questions=questions, n_new=len(questions))
|
|
289
|
+
else:
|
|
290
|
+
selection = select_questions(store, questions, split, config_hash, index_version, regression_sample, seed)
|
|
291
|
+
|
|
292
|
+
results: list[QuestionResult] = []
|
|
293
|
+
n_cached = 0
|
|
294
|
+
for index, question in enumerate(selection.questions, start=1):
|
|
295
|
+
result = store.load_result(question.question_id, config_hash, index_version)
|
|
296
|
+
if result is None:
|
|
297
|
+
result = evaluate_question(question, adapter, judge, k, chunk_texts, match_threshold)
|
|
298
|
+
store.save_result(result, config_hash, index_version)
|
|
299
|
+
else:
|
|
300
|
+
n_cached += 1
|
|
301
|
+
if no_context_query is not None and result.verdict == Verdict.LUCKY_PASS and result.ablation is None:
|
|
302
|
+
result = _ablate(result, question, no_context_query, judge)
|
|
303
|
+
store.save_result(result, config_hash, index_version)
|
|
304
|
+
results.append(result)
|
|
305
|
+
if on_progress is not None:
|
|
306
|
+
on_progress(index, len(selection.questions))
|
|
307
|
+
|
|
308
|
+
summary = summarize(
|
|
309
|
+
results,
|
|
310
|
+
split=split,
|
|
311
|
+
config=config,
|
|
312
|
+
config_hash=config_hash,
|
|
313
|
+
index_version=index_version,
|
|
314
|
+
k=k,
|
|
315
|
+
selection=selection,
|
|
316
|
+
n_active_in_split=len(questions),
|
|
317
|
+
n_cached=n_cached,
|
|
318
|
+
)
|
|
319
|
+
store.save_eval_run(summary)
|
|
320
|
+
return summary, results
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def evaluate_dev(store: Store, adapter: Adapter, judge: CorrectnessJudge, **kwargs) -> tuple[EvalSummary, list[QuestionResult]]:
|
|
324
|
+
"""Evaluate the active dev split."""
|
|
325
|
+
return run_evaluation(store, store.dev_questions(), Split.DEV, adapter, judge, **kwargs)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Runs a TestSet through an Adapter and scores every case."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from ragaudit.models.adapter import Adapter, RAGResponse
|
|
7
|
+
from ragaudit.models.scoring import CorrectnessResult, GroundednessResult, RetrievalResult, ScoredCase
|
|
8
|
+
from ragaudit.models.testset import TestCase, TestSet
|
|
9
|
+
from ragaudit.scoring.retrieval import score_retrieval
|
|
10
|
+
from ragaudit.scoring.verdict import compute_verdict
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
CorrectnessJudge = Callable[[str, str, str], CorrectnessResult]
|
|
15
|
+
"""question, expected_answer, candidate_answer -> CorrectnessResult"""
|
|
16
|
+
|
|
17
|
+
GroundednessJudge = Callable[[str, list[str]], GroundednessResult]
|
|
18
|
+
"""answer, contexts -> GroundednessResult"""
|
|
19
|
+
|
|
20
|
+
RetrievalScorer = Callable[[TestCase, RAGResponse], RetrievalResult]
|
|
21
|
+
"""test case, adapter response -> RetrievalResult. Defaults to matching by chunk id."""
|
|
22
|
+
|
|
23
|
+
ADAPTER_ERROR_JUDGE_MODEL = "n/a (adapter raised)"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def score_by_id(test_case: TestCase, response: RAGResponse) -> RetrievalResult:
|
|
27
|
+
return score_retrieval(test_case.source_chunk_id, response.retrieved_chunk_ids)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _failure_scored_case(test_case: TestCase, error: Exception, retrieval_scorer: RetrievalScorer) -> ScoredCase:
|
|
31
|
+
"""Build a ScoredCase for a TestCase whose adapter.query() call raised."""
|
|
32
|
+
empty_response = RAGResponse(answer="", retrieved_chunk_ids=[], contexts=[], raw={"error": str(error)})
|
|
33
|
+
retrieval = retrieval_scorer(test_case, empty_response)
|
|
34
|
+
correctness = CorrectnessResult(
|
|
35
|
+
is_correct=False,
|
|
36
|
+
rationale=f"adapter raised: {error}",
|
|
37
|
+
judge_model=ADAPTER_ERROR_JUDGE_MODEL,
|
|
38
|
+
)
|
|
39
|
+
groundedness = GroundednessResult(
|
|
40
|
+
is_grounded=False,
|
|
41
|
+
rationale="adapter raised, no answer to judge",
|
|
42
|
+
judge_model=ADAPTER_ERROR_JUDGE_MODEL,
|
|
43
|
+
)
|
|
44
|
+
verdict = compute_verdict(is_correct=False, retrieval_hit=retrieval.hit)
|
|
45
|
+
return ScoredCase(
|
|
46
|
+
test_id=test_case.test_id,
|
|
47
|
+
response=empty_response,
|
|
48
|
+
correctness=correctness,
|
|
49
|
+
retrieval=retrieval,
|
|
50
|
+
groundedness=groundedness,
|
|
51
|
+
verdict=verdict,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_test_set(
|
|
56
|
+
test_set: TestSet,
|
|
57
|
+
adapter: Adapter,
|
|
58
|
+
correctness_judge: CorrectnessJudge,
|
|
59
|
+
groundedness_judge: GroundednessJudge,
|
|
60
|
+
retrieval_scorer: RetrievalScorer = score_by_id,
|
|
61
|
+
) -> list[ScoredCase]:
|
|
62
|
+
"""Run every TestCase in test_set through adapter and score it.
|
|
63
|
+
|
|
64
|
+
retrieval_scorer decides retrieval hits: by chunk id (the default) or,
|
|
65
|
+
for `--match-by text`, by overlap of the returned contexts with the gold
|
|
66
|
+
chunk's text.
|
|
67
|
+
|
|
68
|
+
A TestCase whose adapter.query() call raises is recorded as a failed
|
|
69
|
+
ScoredCase (correctness/groundedness both False) instead of aborting
|
|
70
|
+
the run — one broken question must not kill a 500-case run.
|
|
71
|
+
"""
|
|
72
|
+
scored_cases: list[ScoredCase] = []
|
|
73
|
+
|
|
74
|
+
for test_case in test_set.test_cases:
|
|
75
|
+
try:
|
|
76
|
+
response = adapter.query(test_case.question)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
logger.warning("Adapter raised for test_id=%s: %s", test_case.test_id, exc)
|
|
79
|
+
scored_cases.append(_failure_scored_case(test_case, exc, retrieval_scorer))
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
retrieval = retrieval_scorer(test_case, response)
|
|
83
|
+
correctness = correctness_judge(test_case.question, test_case.expected_answer, response.answer)
|
|
84
|
+
groundedness = groundedness_judge(response.answer, response.contexts)
|
|
85
|
+
verdict = compute_verdict(is_correct=correctness.is_correct, retrieval_hit=retrieval.hit)
|
|
86
|
+
|
|
87
|
+
scored_cases.append(
|
|
88
|
+
ScoredCase(
|
|
89
|
+
test_id=test_case.test_id,
|
|
90
|
+
response=response,
|
|
91
|
+
correctness=correctness,
|
|
92
|
+
retrieval=retrieval,
|
|
93
|
+
groundedness=groundedness,
|
|
94
|
+
verdict=verdict,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return scored_cases
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The final evaluation path: the only code allowed to read test-split gold answers.
|
|
2
|
+
|
|
3
|
+
Everything here unlocks the test split for exactly as long as it needs,
|
|
4
|
+
and hands back aggregate scores rather than questions, answers or
|
|
5
|
+
per-question results, so a caller (in particular `ragaudit tune`) learns
|
|
6
|
+
how well a config did on the test set without ever seeing what the test
|
|
7
|
+
set contains. Per-question test results are still cached in the store,
|
|
8
|
+
behind the same lock, so the report can be rebuilt.
|
|
9
|
+
|
|
10
|
+
The pipeline under test does see the test *questions*: it has to answer
|
|
11
|
+
them. That's inherent to evaluating it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ragaudit.models.adapter import Adapter
|
|
15
|
+
from ragaudit.models.evaluation import EvalSummary
|
|
16
|
+
from ragaudit.models.question import Question, Split
|
|
17
|
+
from ragaudit.runner.evaluate import CorrectnessJudge, run_evaluation
|
|
18
|
+
from ragaudit.storage.lock import _unlock_test_split
|
|
19
|
+
from ragaudit.storage.store import Store
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_test_questions(store: Store) -> list[Question]:
|
|
23
|
+
"""Active test-split questions, with gold answers. Private to this module."""
|
|
24
|
+
with _unlock_test_split():
|
|
25
|
+
return store.load_questions(split=Split.TEST)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def evaluate_test_split(store: Store, adapter: Adapter, judge: CorrectnessJudge, **kwargs) -> EvalSummary:
|
|
29
|
+
"""Evaluate the active test split once and return only the aggregate summary."""
|
|
30
|
+
with _unlock_test_split():
|
|
31
|
+
summary, _ = run_evaluation(store, _load_test_questions(store), Split.TEST, adapter, judge, **kwargs)
|
|
32
|
+
return summary
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def count_test_questions(store: Store) -> int:
|
|
36
|
+
"""Number of active test questions (ids only, no unlock needed)."""
|
|
37
|
+
return len(store.question_ids(Split.TEST))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Pure aggregation of ScoredCases into a RunSummary. No I/O, no LLM."""
|
|
2
|
+
|
|
3
|
+
from ragaudit.models.run import RunSummary
|
|
4
|
+
from ragaudit.models.scoring import ScoredCase, Verdict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def compute_run_summary(scored_cases: list[ScoredCase]) -> RunSummary:
|
|
8
|
+
"""Aggregate a list of ScoredCases into a RunSummary."""
|
|
9
|
+
total = len(scored_cases)
|
|
10
|
+
verdict_counts: dict[Verdict, int] = {verdict: 0 for verdict in Verdict}
|
|
11
|
+
groundedness_scores: list[float] = []
|
|
12
|
+
lucky_pass_cases: list[ScoredCase] = []
|
|
13
|
+
retrieval_hits = 0
|
|
14
|
+
correct = 0
|
|
15
|
+
judge_failures = 0
|
|
16
|
+
|
|
17
|
+
for case in scored_cases:
|
|
18
|
+
verdict_counts[case.verdict] += 1
|
|
19
|
+
if case.retrieval.hit:
|
|
20
|
+
retrieval_hits += 1
|
|
21
|
+
if case.correctness.is_correct:
|
|
22
|
+
correct += 1
|
|
23
|
+
if case.groundedness.score is not None:
|
|
24
|
+
groundedness_scores.append(case.groundedness.score)
|
|
25
|
+
if case.verdict == Verdict.LUCKY_PASS:
|
|
26
|
+
lucky_pass_cases.append(case)
|
|
27
|
+
if case.correctness.judge_parse_failed or case.groundedness.judge_parse_failed:
|
|
28
|
+
judge_failures += 1
|
|
29
|
+
|
|
30
|
+
ablation_confirmed_count = None
|
|
31
|
+
if lucky_pass_cases:
|
|
32
|
+
ablation_confirmed_count = sum(
|
|
33
|
+
1 for case in lucky_pass_cases if case.ablation is not None and case.ablation.confirms_lucky_pass
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return RunSummary(
|
|
37
|
+
total_cases=total,
|
|
38
|
+
verdict_counts=verdict_counts,
|
|
39
|
+
retrieval_hit_rate=(retrieval_hits / total) if total else 0.0,
|
|
40
|
+
answer_correctness_rate=(correct / total) if total else 0.0,
|
|
41
|
+
lucky_pass_rate=(verdict_counts[Verdict.LUCKY_PASS] / total) if total else 0.0,
|
|
42
|
+
mean_groundedness=(sum(groundedness_scores) / len(groundedness_scores)) if groundedness_scores else None,
|
|
43
|
+
ablation_confirmed_count=ablation_confirmed_count,
|
|
44
|
+
judge_failure_count=judge_failures,
|
|
45
|
+
)
|