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
ragaudit/report/v2.py ADDED
@@ -0,0 +1,380 @@
1
+ """Renders the v2 generate and tune reports as single, self-contained HTML files.
2
+
3
+ Same approach as the v1 report (report/generator.py): a Jinja template
4
+ with the canonical style.css inlined, plus `v2.css` for the few v2-only
5
+ components (charts, callouts). style.css itself is untouched, so v1
6
+ reports render byte-for-byte as before.
7
+
8
+ Charts are inline SVG built here, so the reports need no JavaScript
9
+ library; a small script adds hover/focus tooltips. Every number a chart
10
+ shows is also in a table, so nothing depends on hovering.
11
+ """
12
+
13
+ import html
14
+ from collections import Counter
15
+ from importlib import resources
16
+ from pathlib import Path
17
+
18
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
19
+
20
+ from ragaudit.generate.pipeline import GenerateSummary
21
+ from ragaudit.models.question import Question, QuestionStatus, Split
22
+ from ragaudit.models.tune import TuneResult
23
+ from ragaudit.report.generator import TEMPLATES_DIR, _load_style_css
24
+
25
+ REJECT_REASON_LABELS = {
26
+ "too_lexical": "Too lexical: still reused the passage's wording after one rewrite",
27
+ "relative_time": "Relative time: used words like \"recently\" or \"last quarter\"",
28
+ "unsupported": "Unsupported: the verifier couldn't find the fact or answer in the passage",
29
+ "unanswerable": "Unanswerable: the question can't be answered from the passage, or gives the answer away",
30
+ "ambiguous": "Ambiguous: more than one answer, or unclear what is asked",
31
+ "verifier_unparseable": "Verifier output unparseable: rejected to be safe",
32
+ "cross_split": "Cross-split: a multi-hop question whose chunks are in different splits",
33
+ }
34
+ FAILURE_CLASSES = [
35
+ ("pass", "Pass", "v2-fill-pass"),
36
+ ("generation_miss", "Generation miss", "v2-fill-gen"),
37
+ ("retrieval_miss", "Retrieval miss", "v2-fill-ret"),
38
+ ]
39
+
40
+ _W = 720
41
+
42
+
43
+ def _e(value) -> str:
44
+ return html.escape(str(value), quote=True)
45
+
46
+
47
+ def _load_v2_css() -> str:
48
+ try:
49
+ return resources.files("ragaudit.report").joinpath("templates", "v2.css").read_text(encoding="utf-8")
50
+ except (FileNotFoundError, ModuleNotFoundError, OSError) as exc:
51
+ raise RuntimeError("Could not load ragaudit/report/templates/v2.css from package data.") from exc
52
+
53
+
54
+ def _env() -> Environment:
55
+ return Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)), autoescape=select_autoescape(["html", "jinja"]))
56
+
57
+
58
+ def _is_mock(*models: str) -> bool:
59
+ return any(model.startswith("mock") for model in models if model)
60
+
61
+
62
+ def _bar(x: float, y: float, w: float, h: float, cls: str, tip: str, *, end: str = "top", r: float = 4) -> str:
63
+ """A bar with a rounded data end and a square baseline end, carrying a tooltip."""
64
+ r = max(0.0, min(r, w / 2, h / 2))
65
+ if end == "top":
66
+ d = (
67
+ f"M{x:.1f},{y + h:.1f} V{y + r:.1f} Q{x:.1f},{y:.1f} {x + r:.1f},{y:.1f} "
68
+ f"H{x + w - r:.1f} Q{x + w:.1f},{y:.1f} {x + w:.1f},{y + r:.1f} V{y + h:.1f} Z"
69
+ )
70
+ elif end == "right":
71
+ d = (
72
+ f"M{x:.1f},{y:.1f} H{x + w - r:.1f} Q{x + w:.1f},{y:.1f} {x + w:.1f},{y + r:.1f} "
73
+ f"V{y + h - r:.1f} Q{x + w:.1f},{y + h:.1f} {x + w - r:.1f},{y + h:.1f} H{x:.1f} Z"
74
+ )
75
+ else:
76
+ d = f"M{x:.1f},{y:.1f} H{x + w:.1f} V{y + h:.1f} H{x:.1f} Z"
77
+ return f'<path class="v2-mark {cls}" d="{d}" data-tip="{_e(tip)}" tabindex="0"></path>'
78
+
79
+
80
+ def _svg(height: int, body: list[str], label: str) -> str:
81
+ return (
82
+ f'<svg class="v2-chart" viewBox="0 0 {_W} {height}" role="img" aria-label="{_e(label)}" '
83
+ f'preserveAspectRatio="xMidYMid meet">{"".join(body)}</svg>'
84
+ )
85
+
86
+
87
+ def _nice_step(raw: float) -> int:
88
+ """The smallest 1/2/5 x 10^n step at or above raw (minimum 1)."""
89
+ magnitude = 1
90
+ while True:
91
+ for factor in (1, 2, 5):
92
+ if factor * magnitude >= raw:
93
+ return factor * magnitude
94
+ magnitude *= 10
95
+
96
+
97
+ def _y_grid(left: int, right: int, top: int, bottom: int, ticks: list[float], vmax: float, fmt=str) -> list[str]:
98
+ parts = []
99
+ for tick in ticks:
100
+ y = bottom - (bottom - top) * tick / vmax
101
+ parts.append(f'<line class="v2-grid" x1="{left}" x2="{right}" y1="{y:.1f}" y2="{y:.1f}"></line>')
102
+ parts.append(f'<text class="v2-tick" x="{left - 8}" y="{y + 4:.1f}" text-anchor="end">{_e(fmt(tick))}</text>')
103
+ return parts
104
+
105
+
106
+ # --- generate report charts --------------------------------------------------
107
+
108
+
109
+ def overlap_histogram(values: list[float], threshold: float, bins: int = 10) -> tuple[str, list[dict]]:
110
+ """Histogram of final lexical overlaps; bins at or below the threshold are the accent, above are muted."""
111
+ counts = [0] * bins
112
+ for value in values:
113
+ index = 0 if value <= 1 / bins else min(bins - 1, int(-(-value * bins // 1)) - 1)
114
+ counts[index] += 1
115
+ rows = []
116
+ for i, count in enumerate(counts):
117
+ low, high = i / bins, (i + 1) / bins
118
+ rows.append({"range": f"{'[' if i == 0 else '('}{low:.1f}, {high:.1f}]", "count": count, "kept": high <= threshold + 1e-9})
119
+
120
+ left, right, top, bottom = 44, _W - 16, 28, 230
121
+ peak = max(max(counts), 1)
122
+ step = _nice_step(peak / 4)
123
+ vmax = step * -(-peak // step)
124
+ ticks = [step * i for i in range(int(vmax // step) + 1)]
125
+ band = (right - left) / bins
126
+ body = _y_grid(left, right, top, bottom, ticks, vmax)
127
+ for i, row in enumerate(rows):
128
+ h = (bottom - top) * row["count"] / vmax
129
+ if h > 0:
130
+ cls = "v2-fill-accent" if row["kept"] else "v2-fill-muted"
131
+ verdict = "kept" if row["kept"] else "above threshold: rejected as too_lexical"
132
+ body.append(_bar(left + i * band + 1, bottom - h, band - 2, h, cls, f"{row['count']} question(s)\noverlap {row['range']}, {verdict}"))
133
+ for i in range(bins + 1):
134
+ if i % 2 == 0:
135
+ body.append(f'<text class="v2-tick" x="{left + i * band:.1f}" y="{bottom + 18}" text-anchor="middle">{i / bins:.1f}</text>')
136
+ tx = left + (right - left) * threshold
137
+ body.append(f'<line class="v2-rule" x1="{tx:.1f}" x2="{tx:.1f}" y1="{top - 10}" y2="{bottom}"></line>')
138
+ body.append(f'<text class="v2-label" x="{tx + 6:.1f}" y="{top - 2}">threshold {threshold:g}</text>')
139
+ body.append(f'<text class="v2-axis-title" x="{(left + right) / 2:.1f}" y="{bottom + 36}" text-anchor="middle">share of the question\'s content words found in its source chunk</text>')
140
+ return _svg(272, body, "Histogram of lexical overlap"), rows
141
+
142
+
143
+ def reason_bars(reasons: dict[str, int]) -> str:
144
+ items = sorted(reasons.items(), key=lambda item: (-item[1], item[0]))
145
+ if not items:
146
+ return ""
147
+ row_h, top, left, right = 34, 8, 170, _W - 60
148
+ vmax = max(count for _, count in items)
149
+ body = []
150
+ for i, (reason, count) in enumerate(items):
151
+ y = top + i * row_h
152
+ w = (right - left) * count / vmax
153
+ body.append(f'<text class="v2-label" x="{left - 10}" y="{y + 16}" text-anchor="end">{_e(reason)}</text>')
154
+ body.append(_bar(left, y + 4, w, 18, "v2-fill-accent", f"{count} rejected\n{REJECT_REASON_LABELS.get(reason, reason)}", end="right"))
155
+ body.append(f'<text class="v2-value" x="{left + w + 8:.1f}" y="{y + 17}">{count}</text>')
156
+ return _svg(top + row_h * len(items), body, "Rejected questions by reason")
157
+
158
+
159
+ # --- tune report charts ------------------------------------------------------
160
+
161
+
162
+ def trial_chart(result: TuneResult) -> str:
163
+ trials = result.trials
164
+ left, right, top, bottom = 44, _W - 24, 24, 230
165
+ n = len(trials)
166
+ span = max(n - 1, 1)
167
+
168
+ def x_of(index: int) -> float:
169
+ return left + 12 + (right - left - 24) * index / span if n > 1 else (left + right) / 2
170
+
171
+ def y_of(score: float) -> float:
172
+ return bottom - (bottom - top) * score / 100
173
+
174
+ body = _y_grid(left, right, top, bottom, [0, 25, 50, 75, 100], 100)
175
+ best_so_far, running = [], -1.0
176
+ for trial in trials:
177
+ running = max(running, trial.dev.score)
178
+ best_so_far.append(running)
179
+ if n > 1:
180
+ points = []
181
+ for i, score in enumerate(best_so_far):
182
+ if i:
183
+ points.append(f"{x_of(i):.1f},{y_of(best_so_far[i - 1]):.1f}")
184
+ points.append(f"{x_of(i):.1f},{y_of(score):.1f}")
185
+ body.append(f'<polyline class="v2-line-ref" points="{" ".join(points)}"></polyline>')
186
+ for trial in trials:
187
+ cx, cy = x_of(trial.index), y_of(trial.dev.score)
188
+ changed = ", ".join(f"{k}={v}" for k, v in trial.config.items() if result.baseline.config.get(k) != v) or "baseline config"
189
+ tip = f"{trial.dev.score:.1f} dev\ntrial {trial.index} ({trial.source})\n{changed}"
190
+ cls = "v2-dot v2-dot-best" if trial.index == result.best_index else "v2-dot"
191
+ body.append(f'<circle class="{cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="5"></circle>')
192
+ body.append(f'<circle class="v2-hit" cx="{cx:.1f}" cy="{cy:.1f}" r="12" data-tip="{_e(tip)}" tabindex="0"></circle>')
193
+ for index, name in ((result.baseline_index, "baseline"), (result.best_index, "best")):
194
+ if name == "best" and index == result.baseline_index:
195
+ continue
196
+ trial = trials[index]
197
+ anchor = "start" if x_of(index) < (left + right) / 2 else "end"
198
+ dx = 10 if anchor == "start" else -10
199
+ body.append(
200
+ f'<text class="v2-label" x="{x_of(index) + dx:.1f}" y="{y_of(trial.dev.score) - 10:.1f}" '
201
+ f'text-anchor="{anchor}">{name} {trial.dev.score:.1f}</text>'
202
+ )
203
+ step = max(1, n // 10)
204
+ for i in range(0, n, step):
205
+ body.append(f'<text class="v2-tick" x="{x_of(i):.1f}" y="{bottom + 18}" text-anchor="middle">{i}</text>')
206
+ body.append(f'<text class="v2-axis-title" x="{(left + right) / 2:.1f}" y="{bottom + 36}" text-anchor="middle">trial</text>')
207
+ return _svg(272, body, "Dev score per trial")
208
+
209
+
210
+ def dev_test_columns(result: TuneResult) -> str:
211
+ groups = [("Baseline", result.baseline.dev.score, result.baseline_test.score), ("Best", result.best.dev.score, result.best_test.score)]
212
+ left, right, top, bottom = 44, _W - 16, 28, 210
213
+ body = _y_grid(left, right, top, bottom, [0, 25, 50, 75, 100], 100)
214
+ band = (right - left) / len(groups)
215
+ for g, (name, dev, test) in enumerate(groups):
216
+ center = left + band * (g + 0.5)
217
+ for offset, value, cls, split in ((-32, dev, "v2-fill-dev", "dev"), (8, test, "v2-fill-test", "locked test")):
218
+ h = (bottom - top) * value / 100
219
+ x = center + offset
220
+ if h > 0:
221
+ body.append(_bar(x, bottom - h, 24, h, cls, f"{value:.1f} {split}\n{name.lower()} config"))
222
+ body.append(f'<text class="v2-value" x="{x + 12:.1f}" y="{bottom - h - 6:.1f}" text-anchor="middle">{value:.1f}</text>')
223
+ body.append(f'<text class="v2-label" x="{center:.1f}" y="{bottom + 20}" text-anchor="middle">{name}</text>')
224
+ return _svg(232, body, "Dev and test score, baseline and best")
225
+
226
+
227
+ def failure_stack(rows: list[tuple[str, dict[str, int]]]) -> str:
228
+ row_h, top, left, right = 40, 6, 130, _W - 16
229
+ body = []
230
+ for i, (label, counts) in enumerate(rows):
231
+ y = top + i * row_h
232
+ total = sum(counts.get(key, 0) for key, _, _ in FAILURE_CLASSES) or 1
233
+ body.append(f'<text class="v2-label" x="{left - 10}" y="{y + 17}" text-anchor="end">{_e(label)}</text>')
234
+ x = left
235
+ segments = [(key, name, cls, counts.get(key, 0)) for key, name, cls in FAILURE_CLASSES if counts.get(key, 0)]
236
+ for j, (key, name, cls, count) in enumerate(segments):
237
+ w = (right - left) * count / total
238
+ last = j == len(segments) - 1
239
+ draw_w = w - (0 if last else 2)
240
+ share = count / total * 100
241
+ body.append(_bar(x, y + 2, max(draw_w, 0.5), 24, cls, f"{count} ({share:.0f}%)\n{name}, {label}", end="right" if last else "none"))
242
+ text = f"{count}"
243
+ if draw_w > len(text) * 7 + 12:
244
+ body.append(f'<text class="v2-inside" x="{x + draw_w / 2:.1f}" y="{y + 18}" text-anchor="middle">{text}</text>')
245
+ x += w
246
+ return _svg(top + row_h * len(rows), body, "Failure classes before and after tuning")
247
+
248
+
249
+ # --- page assembly -----------------------------------------------------------
250
+
251
+
252
+ def _write(template_name: str, output_path: str | Path, **context) -> Path:
253
+ output_path = Path(output_path)
254
+ page = _env().get_template(template_name).render(style_css=_load_style_css(), v2_css=_load_v2_css(), **context)
255
+ output_path.parent.mkdir(parents=True, exist_ok=True)
256
+ output_path.write_text(page, encoding="utf-8")
257
+ return output_path
258
+
259
+
260
+ def _question_row(q: Question) -> dict:
261
+ return {
262
+ "question": q.question,
263
+ "answer": q.gold_answer,
264
+ "fact": q.fact,
265
+ "chunks": ", ".join(q.chunk_ids),
266
+ "type": q.question_type.value,
267
+ "overlap": f"{q.lexical_overlap:.2f}" if q.lexical_overlap is not None else "n/a",
268
+ "reason": q.reject_reason,
269
+ "draft": q.metadata.get("draft_question"),
270
+ }
271
+
272
+
273
+ def generate_generate_report(
274
+ summary: GenerateSummary,
275
+ totals: dict,
276
+ accepted_examples: list[Question],
277
+ rejected_examples: list[Question],
278
+ output_path: str | Path,
279
+ ) -> Path:
280
+ """Render a generate run. Accepted examples must be dev-split only; the caller guarantees it."""
281
+ if any(q.split != Split.DEV for q in [*accepted_examples, *rejected_examples]):
282
+ raise ValueError("generate report examples must come from the dev split only")
283
+ histogram, histogram_rows = overlap_histogram(summary.lexical_overlaps, summary.lexical_threshold)
284
+ reasons = sorted(summary.reject_reasons.items(), key=lambda item: (-item[1], item[0]))
285
+ checked = summary.generated + summary.rejected
286
+ rejected_by_reason: dict[str, list[dict]] = {}
287
+ for q in rejected_examples:
288
+ rejected_by_reason.setdefault(q.reject_reason or "unknown", []).append(_question_row(q))
289
+
290
+ return _write(
291
+ "generate_report.html.jinja",
292
+ output_path,
293
+ summary=summary,
294
+ totals=totals["by_split_status"],
295
+ is_mock=_is_mock(summary.generator_model, summary.verifier_model),
296
+ checked=checked,
297
+ pass_pct=round(summary.generated / checked * 100, 1) if checked else 0.0,
298
+ top_reason=reasons[0] if reasons else None,
299
+ reasons=[{"reason": r, "count": c, "label": REJECT_REASON_LABELS.get(r, r)} for r, c in reasons],
300
+ reason_chart=reason_bars(summary.reject_reasons),
301
+ histogram=histogram,
302
+ histogram_rows=histogram_rows,
303
+ overlap_median=sorted(summary.lexical_overlaps)[len(summary.lexical_overlaps) // 2] if summary.lexical_overlaps else None,
304
+ accepted=[_question_row(q) for q in accepted_examples],
305
+ rejected_by_reason=rejected_by_reason,
306
+ )
307
+
308
+
309
+ def generate_tune_report(result: TuneResult, output_path: str | Path) -> Path:
310
+ if result.baseline_test is None or result.best_test is None:
311
+ raise ValueError("tune result has no final test evaluation; it can't be reported")
312
+ baseline, best = result.baseline, result.best
313
+ knobs = [
314
+ {"knob": knob, "baseline": baseline.config.get(knob), "best": best.config.get(knob), "changed": baseline.config.get(knob) != best.config.get(knob)}
315
+ for knob in result.search_space
316
+ ]
317
+ sources = Counter(t.source for t in result.trials)
318
+ trial_rows = [
319
+ {
320
+ "index": t.index,
321
+ "source": t.source,
322
+ "score": t.dev.score,
323
+ "counts": t.dev.failure_counts,
324
+ "changed": ", ".join(f"{k}={v}" for k, v in t.config.items() if baseline.config.get(k) != v) or "(baseline)",
325
+ "rationale": t.rationale or "",
326
+ "is_best": t.index == result.best_index,
327
+ }
328
+ for t in result.trials
329
+ ]
330
+ stack_rows = [
331
+ ("Baseline, dev", baseline.dev.failure_counts),
332
+ ("Best, dev", best.dev.failure_counts),
333
+ ("Baseline, test", result.baseline_test.failure_counts),
334
+ ("Best, test", result.best_test.failure_counts),
335
+ ]
336
+ return _write(
337
+ "tune_report.html.jinja",
338
+ output_path,
339
+ result=result,
340
+ baseline=baseline,
341
+ best=best,
342
+ is_mock=_is_mock(result.judge_model),
343
+ improvement=round(result.best_test.score - result.baseline_test.score, 1),
344
+ dev_improvement=round(best.dev.score - baseline.dev.score, 1),
345
+ trial_chart=trial_chart(result),
346
+ dev_test_chart=dev_test_columns(result),
347
+ failure_chart=failure_stack(stack_rows),
348
+ stack_rows=stack_rows,
349
+ failure_classes=FAILURE_CLASSES,
350
+ knobs=knobs,
351
+ trial_rows=trial_rows,
352
+ sources=dict(sources),
353
+ )
354
+
355
+
356
+ def select_generate_examples(store, summary: GenerateSummary, limit: int = 10) -> tuple[list[Question], list[Question]]:
357
+ """Accepted and rejected examples, both from the dev split only.
358
+
359
+ Splits are per chunk, so even a rejected test-split question would show
360
+ text from a test chunk; the report never does.
361
+ """
362
+ accepted = [q for q in store.dev_questions() if q.created_at >= summary.created_at] or store.dev_questions()
363
+ rejected_all = [
364
+ q
365
+ for q in store.load_questions(split=Split.DEV, statuses=[QuestionStatus.REJECTED])
366
+ if q.created_at >= summary.created_at
367
+ ]
368
+ by_reason: dict[str, list[Question]] = {}
369
+ for q in sorted(rejected_all, key=lambda q: q.created_at):
370
+ by_reason.setdefault(q.reject_reason or "unknown", []).append(q)
371
+ rejected = [q for questions in by_reason.values() for q in questions[:3]]
372
+ accepted = sorted(accepted, key=lambda q: (q.question_type.value != "multi_hop", q.created_at))
373
+ picked = accepted[: limit // 2] + [q for q in accepted if q.question_type.value == "single"][limit // 2 : limit]
374
+ seen, unique = set(), []
375
+ for q in picked:
376
+ if q.question_id not in seen:
377
+ seen.add(q.question_id)
378
+ unique.append(q)
379
+ return unique[:limit], rejected
380
+
File without changes
@@ -0,0 +1,46 @@
1
+ """Re-runs LUCKY_PASS candidates with no context to empirically confirm them."""
2
+
3
+ from typing import Callable
4
+
5
+ from ragaudit.models.scoring import AblationResult, ScoredCase, Verdict
6
+ from ragaudit.models.testset import TestCase
7
+
8
+ NoContextQuery = Callable[[str], str]
9
+ """question -> answer, produced with retrieval/context disabled entirely."""
10
+
11
+ CorrectnessCheck = Callable[[str, str, str], bool]
12
+ """question, expected_answer, candidate_answer -> is_correct"""
13
+
14
+
15
+ def run_ablation(
16
+ scored_cases: list[ScoredCase],
17
+ test_cases: list[TestCase],
18
+ no_context_query: NoContextQuery,
19
+ check_correctness: CorrectnessCheck,
20
+ ) -> list[ScoredCase]:
21
+ """Populate AblationResult for LUCKY_PASS rows only.
22
+
23
+ For every ScoredCase whose verdict is LUCKY_PASS, re-asks the question
24
+ with no context at all. If the answer is still correct, retrieval was
25
+ contributing nothing — confirms_lucky_pass is True. All other verdicts
26
+ are returned unchanged (ablation stays None).
27
+ """
28
+ test_case_by_id = {tc.test_id: tc for tc in test_cases}
29
+ updated: list[ScoredCase] = []
30
+
31
+ for scored_case in scored_cases:
32
+ if scored_case.verdict != Verdict.LUCKY_PASS:
33
+ updated.append(scored_case)
34
+ continue
35
+
36
+ test_case = test_case_by_id[scored_case.test_id]
37
+ no_context_answer = no_context_query(test_case.question)
38
+ is_correct = check_correctness(test_case.question, test_case.expected_answer, no_context_answer)
39
+ ablation = AblationResult(
40
+ answer=no_context_answer,
41
+ is_correct=is_correct,
42
+ confirms_lucky_pass=is_correct,
43
+ )
44
+ updated.append(scored_case.model_copy(update={"ablation": ablation}))
45
+
46
+ return updated