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.
Files changed (71) hide show
  1. ragaudit/__init__.py +1 -0
  2. ragaudit/adapters/__init__.py +0 -0
  3. ragaudit/adapters/base.py +109 -0
  4. ragaudit/adapters/callable_adapter.py +15 -0
  5. ragaudit/cli.py +540 -0
  6. ragaudit/cli_easy.py +458 -0
  7. ragaudit/cli_v2.py +507 -0
  8. ragaudit/env.py +46 -0
  9. ragaudit/generate/__init__.py +0 -0
  10. ragaudit/generate/pipeline.py +486 -0
  11. ragaudit/generate/prompts.py +116 -0
  12. ragaudit/generate/testset_generator.py +146 -0
  13. ragaudit/generate/text.py +75 -0
  14. ragaudit/ingest/__init__.py +0 -0
  15. ragaudit/ingest/chunker.py +53 -0
  16. ragaudit/ingest/corpus.py +120 -0
  17. ragaudit/ingest/loader.py +49 -0
  18. ragaudit/ingest/versioning.py +92 -0
  19. ragaudit/llm/__init__.py +0 -0
  20. ragaudit/llm/client.py +86 -0
  21. ragaudit/llm/factory.py +70 -0
  22. ragaudit/llm/json_output.py +72 -0
  23. ragaudit/llm/mock.py +552 -0
  24. ragaudit/models/__init__.py +0 -0
  25. ragaudit/models/adapter.py +39 -0
  26. ragaudit/models/document.py +28 -0
  27. ragaudit/models/evaluation.py +70 -0
  28. ragaudit/models/question.py +152 -0
  29. ragaudit/models/run.py +44 -0
  30. ragaudit/models/scoring.py +82 -0
  31. ragaudit/models/testset.py +30 -0
  32. ragaudit/models/tune.py +56 -0
  33. ragaudit/report/__init__.py +0 -0
  34. ragaudit/report/generator.py +155 -0
  35. ragaudit/report/plain_summary.py +122 -0
  36. ragaudit/report/templates/_v2_tooltip.jinja +43 -0
  37. ragaudit/report/templates/generate_report.html.jinja +175 -0
  38. ragaudit/report/templates/report.html.jinja +252 -0
  39. ragaudit/report/templates/style.css +1095 -0
  40. ragaudit/report/templates/tune_report.html.jinja +158 -0
  41. ragaudit/report/templates/v2.css +286 -0
  42. ragaudit/report/v2.py +380 -0
  43. ragaudit/runner/__init__.py +0 -0
  44. ragaudit/runner/ablation.py +46 -0
  45. ragaudit/runner/evaluate.py +325 -0
  46. ragaudit/runner/executor.py +98 -0
  47. ragaudit/runner/locked.py +37 -0
  48. ragaudit/runner/summary.py +45 -0
  49. ragaudit/scaffold.py +252 -0
  50. ragaudit/scoring/__init__.py +0 -0
  51. ragaudit/scoring/correctness.py +94 -0
  52. ragaudit/scoring/groundedness.py +86 -0
  53. ragaudit/scoring/retrieval.py +42 -0
  54. ragaudit/scoring/text_match.py +51 -0
  55. ragaudit/scoring/verdict.py +14 -0
  56. ragaudit/storage/__init__.py +0 -0
  57. ragaudit/storage/db.py +81 -0
  58. ragaudit/storage/lock.py +36 -0
  59. ragaudit/storage/repository.py +216 -0
  60. ragaudit/storage/store.py +536 -0
  61. ragaudit/tune/__init__.py +7 -0
  62. ragaudit/tune/devset.py +16 -0
  63. ragaudit/tune/loop.py +208 -0
  64. ragaudit/tune/search_space.py +140 -0
  65. ragaudit/tune/strategies.py +197 -0
  66. ragaudit/yaml_io.py +199 -0
  67. ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
  68. ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
  69. ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
  70. ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
  71. 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
+ )