ragvidence 0.1.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 (40) hide show
  1. ragvidence/__init__.py +4 -0
  2. ragvidence/cli.py +373 -0
  3. ragvidence/comparison.py +35 -0
  4. ragvidence/config.py +16 -0
  5. ragvidence/data.py +72 -0
  6. ragvidence/diagnosis.py +40 -0
  7. ragvidence/evaluation/__init__.py +19 -0
  8. ragvidence/evaluation/evaluator.py +54 -0
  9. ragvidence/evaluation/metrics.py +131 -0
  10. ragvidence/exercises/__init__.py +1 -0
  11. ragvidence/exercises/heading_context/README.md +5 -0
  12. ragvidence/exercises/heading_context/integrity.json +4 -0
  13. ragvidence/exercises/heading_context/questions.jsonl +12 -0
  14. ragvidence/exercises/heading_context/sources.jsonl +8 -0
  15. ragvidence/generation.py +60 -0
  16. ragvidence/health.py +67 -0
  17. ragvidence/importer.py +132 -0
  18. ragvidence/models.py +101 -0
  19. ragvidence/providers/__init__.py +12 -0
  20. ragvidence/providers/base.py +12 -0
  21. ragvidence/providers/fastembed.py +19 -0
  22. ragvidence/providers/remote.py +114 -0
  23. ragvidence/reporting/__init__.py +6 -0
  24. ragvidence/reporting/html.py +15 -0
  25. ragvidence/reporting/markdown.py +72 -0
  26. ragvidence/reporting/payload.py +32 -0
  27. ragvidence/reporting/templates/report.html.j2 +689 -0
  28. ragvidence/representation.py +25 -0
  29. ragvidence/runner.py +83 -0
  30. ragvidence/strategies/__init__.py +6 -0
  31. ragvidence/strategies/base.py +12 -0
  32. ragvidence/strategies/bm25.py +49 -0
  33. ragvidence/strategies/semantic.py +86 -0
  34. ragvidence/verification.py +115 -0
  35. ragvidence/workspace.py +33 -0
  36. ragvidence-0.1.0.dist-info/METADATA +213 -0
  37. ragvidence-0.1.0.dist-info/RECORD +40 -0
  38. ragvidence-0.1.0.dist-info/WHEEL +4 -0
  39. ragvidence-0.1.0.dist-info/entry_points.txt +2 -0
  40. ragvidence-0.1.0.dist-info/licenses/LICENSE.md +74 -0
ragvidence/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """RAGvidence."""
2
+
3
+ __version__ = "0.1.0"
4
+ __edition__ = "Free"
ragvidence/cli.py ADDED
@@ -0,0 +1,373 @@
1
+ """Command-line interface for local evidence-level RAG experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from ragvidence import __edition__, __version__
14
+ from ragvidence.comparison import compare_evaluations
15
+ from ragvidence.data import DatasetError, load_dataset
16
+ from ragvidence.diagnosis import diagnose as build_diagnosis
17
+ from ragvidence.evaluation.evaluator import evaluate_run
18
+ from ragvidence.generation import generate_answers
19
+ from ragvidence.health import run_health
20
+ from ragvidence.importer import import_retrieval_run
21
+ from ragvidence.models import ExperimentRun, StrategyConfig
22
+ from ragvidence.reporting import build_report, render_markdown
23
+ from ragvidence.reporting.html import render_html
24
+ from ragvidence.runner import run_experiment, write_run
25
+ from ragvidence.verification import run_verification
26
+ from ragvidence.workspace import create_workspace, restore_workspace
27
+
28
+ app = typer.Typer(
29
+ name="ragvi",
30
+ help="Diagnose RAG retrieval with evidence-level experiments.",
31
+ no_args_is_help=True,
32
+ )
33
+ workspace_app = typer.Typer(help="Manage recoverable experiment workspaces.")
34
+ app.add_typer(workspace_app, name="workspace")
35
+ console = Console()
36
+ DEFAULT_ROOT = Path("workspaces")
37
+
38
+
39
+ def fail(message: str) -> None:
40
+ """Exit with one actionable error and no internal traceback."""
41
+ console.print(f"[red]FAIL[/red] {message}")
42
+ raise typer.Exit(1)
43
+
44
+
45
+ def version_callback(value: bool) -> None:
46
+ if value:
47
+ console.print(__version__)
48
+ raise typer.Exit()
49
+
50
+
51
+ @app.callback()
52
+ def main(
53
+ version: Annotated[
54
+ bool | None, typer.Option("--version", callback=version_callback, is_eager=True)
55
+ ] = None,
56
+ ) -> None:
57
+ """RAGvidence CLI."""
58
+
59
+
60
+ @app.command()
61
+ def edition() -> None:
62
+ """Show the installed product edition and activation requirement."""
63
+ if __edition__ == "Free":
64
+ console.print("Free — no account or activation required")
65
+ else:
66
+ console.print(__edition__)
67
+
68
+
69
+ @app.command()
70
+ def health(root: Path = DEFAULT_ROOT) -> None:
71
+ """Check runtime, bundled data, workspace access and semantic support."""
72
+ checks = run_health(root)
73
+ table = Table("Status", "Check", "Detail")
74
+ colors = {"PASS": "green", "WARN": "yellow", "FAIL": "red"} # nosec B105
75
+ for check in checks:
76
+ table.add_row(f"[{colors[check.status]}]{check.status}[/]", check.name, check.detail)
77
+ console.print(table)
78
+ if any(item.status == "FAIL" for item in checks):
79
+ raise typer.Exit(1)
80
+
81
+
82
+ @app.command()
83
+ def verify() -> None:
84
+ """Run deterministic release and installation fixtures."""
85
+ results = run_verification()
86
+ table = Table("Status", "Fixture", "Detail")
87
+ colors = {"PASS": "green", "FAIL": "red"} # nosec B105
88
+ for result in results:
89
+ table.add_row(
90
+ f"[{colors[result.status]}]{result.status}[/]",
91
+ result.name,
92
+ result.detail,
93
+ )
94
+ console.print(table)
95
+ if any(item.status == "FAIL" for item in results):
96
+ raise typer.Exit(1)
97
+
98
+
99
+ @app.command()
100
+ def quickstart(root: Path = DEFAULT_ROOT) -> None:
101
+ """Create the guided exercise without overwriting existing work."""
102
+ try:
103
+ target = create_workspace(root, "heading-context")
104
+ except FileExistsError:
105
+ target = root / "heading-context"
106
+ console.print(f"[yellow]EXISTS[/yellow] {target}")
107
+ console.print(f"[green]READY[/green] {target}\nNext: ragvi validate {target}")
108
+
109
+
110
+ @app.command()
111
+ def tutorial(
112
+ root: Path = DEFAULT_ROOT,
113
+ name: Annotated[str, typer.Option(help="Name for the new tutorial workspace")] = "first-run",
114
+ ) -> None:
115
+ """Run the complete local BM25 tutorial and create a shareable HTML report."""
116
+ try:
117
+ workspace = create_workspace(root, name)
118
+ evaluations = []
119
+ for representation in ("body", "heading_context"):
120
+ config = StrategyConfig(
121
+ name=f"bm25-{representation}",
122
+ strategy="bm25",
123
+ representation=representation,
124
+ k=2,
125
+ )
126
+ console.print(f"Running [cyan]{config.name}[/cyan] at k={config.k}")
127
+ run = run_experiment(workspace, config)
128
+ run_path = workspace / "runs" / f"{config.name}.json"
129
+ write_run(run, run_path)
130
+ _, questions = load_dataset(workspace)
131
+ evaluation = evaluate_run(run, questions)
132
+ evaluation_path = workspace / "evaluations" / f"{config.name}.json"
133
+ evaluation_path.parent.mkdir(parents=True, exist_ok=True)
134
+ evaluation_path.write_text(json.dumps(evaluation, indent=2), encoding="utf-8")
135
+ evaluations.append(evaluation)
136
+
137
+ report_path = workspace / "reports" / "tutorial.html"
138
+ report_path.parent.mkdir(parents=True, exist_ok=True)
139
+ template = Path(__file__).parent / "reporting/templates/report.html.j2"
140
+ report_path.write_text(
141
+ render_html(build_report(evaluations[0], evaluations[1]), template),
142
+ encoding="utf-8",
143
+ )
144
+ except (DatasetError, FileExistsError, OSError, RuntimeError, ValueError) as exc:
145
+ if isinstance(exc, FileExistsError):
146
+ fail(
147
+ f"Tutorial workspace already exists: {root / name}. "
148
+ "Inspect it or choose another name with --name."
149
+ )
150
+ fail(str(exc))
151
+
152
+ console.print(
153
+ f"[green]COMPLETE[/green] Guided comparison finished.\n"
154
+ f"[bold]Report:[/bold] {report_path}\n"
155
+ f"[bold]Next:[/bold] Open the report, inspect changed questions, then read "
156
+ "the Getting started guide included with your download."
157
+ )
158
+
159
+
160
+ @app.command("init")
161
+ def init_workspace(name: str, root: Path = DEFAULT_ROOT) -> None:
162
+ """Create an editable experiment from the bundled template."""
163
+ try:
164
+ target = create_workspace(root, name)
165
+ except (FileExistsError, ValueError) as exc:
166
+ fail(str(exc))
167
+ console.print(f"[green]CREATED[/green] {target}")
168
+
169
+
170
+ @app.command()
171
+ def validate(workspace: Path) -> None:
172
+ """Validate data and evidence-reference integrity."""
173
+ try:
174
+ sources, questions = load_dataset(workspace)
175
+ except DatasetError as exc:
176
+ console.print(f"[red]FAIL[/red] {exc}")
177
+ raise typer.Exit(1) from exc
178
+ evidence = {item for source in sources for item in source.evidence_ids}
179
+ console.print(
180
+ f"[green]PASS[/green] {len(sources)} sources, "
181
+ f"{len(questions)} questions, {len(evidence)} evidence IDs"
182
+ )
183
+
184
+
185
+ @app.command()
186
+ def benchmark(
187
+ workspace: Path,
188
+ strategies: str = "bm25",
189
+ k: int = 2,
190
+ model: str = "BAAI/bge-small-en-v1.5",
191
+ provider: str = "fastembed",
192
+ ) -> None:
193
+ """Run body and heading-context representations."""
194
+ requested = [value.strip() for value in strategies.split(",") if value.strip()]
195
+ unsupported = set(requested) - {"bm25", "semantic"}
196
+ if unsupported:
197
+ raise typer.BadParameter(f"Unsupported strategies: {sorted(unsupported)}")
198
+ for strategy in requested:
199
+ for representation in ("body", "heading_context"):
200
+ name = f"{strategy}-{representation}"
201
+ config = StrategyConfig(
202
+ name=name,
203
+ strategy=strategy,
204
+ representation=representation,
205
+ k=k,
206
+ model=model if strategy == "semantic" else None,
207
+ provider=provider if strategy == "semantic" else None,
208
+ )
209
+ if strategy == "semantic" and provider != "fastembed":
210
+ console.print(
211
+ f"[yellow]DISCLOSURE[/yellow] {provider} will receive source text and queries."
212
+ )
213
+ console.print(f"Running [cyan]{name}[/cyan] at k={k}")
214
+ try:
215
+ run = run_experiment(workspace, config)
216
+ destination = workspace / "runs" / f"{name}.json"
217
+ write_run(run, destination)
218
+ except (DatasetError, FileExistsError, RuntimeError) as exc:
219
+ fail(str(exc))
220
+ console.print(f"[green]SAVED[/green] {destination}")
221
+
222
+
223
+ @app.command("import-run")
224
+ def import_run(
225
+ workspace: Path,
226
+ input_path: Path,
227
+ name: Annotated[str, typer.Option(help="Stable name for the external strategy")],
228
+ k: Annotated[int, typer.Option(min=1)] = 2,
229
+ ) -> None:
230
+ """Validate and normalize ranked JSONL from another RAG stack."""
231
+ try:
232
+ run = import_retrieval_run(workspace, input_path, name=name, k=k)
233
+ destination = workspace / "runs" / f"{name}.json"
234
+ write_run(run, destination)
235
+ except (DatasetError, FileExistsError) as exc:
236
+ fail(str(exc))
237
+ console.print(f"[green]SAVED[/green] {destination}")
238
+
239
+
240
+ @app.command()
241
+ def evaluate(workspace: Path, run_file: Path) -> None:
242
+ """Evaluate a run using reviewed evidence labels."""
243
+ try:
244
+ _, questions = load_dataset(workspace)
245
+ run = ExperimentRun.model_validate_json(run_file.read_text(encoding="utf-8"))
246
+ result = evaluate_run(run, questions)
247
+ except (DatasetError, OSError, ValueError, NotImplementedError) as exc:
248
+ fail(str(exc) or "Evaluation could not be completed")
249
+ destination = workspace / "evaluations" / f"{run.manifest.config.name}.json"
250
+ destination.parent.mkdir(parents=True, exist_ok=True)
251
+ if destination.exists():
252
+ fail(f"Refusing to overwrite: {destination}")
253
+ destination.write_text(json.dumps(result, indent=2), encoding="utf-8")
254
+ console.print(f"[green]SAVED[/green] {destination}")
255
+
256
+
257
+ @app.command()
258
+ def generate(workspace: Path, run_file: Path, output: Path) -> None:
259
+ """Generate optional answers with a user-configured provider."""
260
+ from ragvidence.config import Settings
261
+
262
+ settings = Settings()
263
+ console.print(
264
+ f"[yellow]DISCLOSURE[/yellow] {settings.generation_provider or 'unconfigured provider'} "
265
+ "will receive each question and its retrieved source text."
266
+ )
267
+ try:
268
+ run = ExperimentRun.model_validate_json(run_file.read_text(encoding="utf-8"))
269
+ result = generate_answers(workspace, run, settings)
270
+ except (OSError, ValueError, RuntimeError) as exc:
271
+ fail(str(exc))
272
+ if output.exists():
273
+ fail(f"Refusing to overwrite: {output}")
274
+ output.parent.mkdir(parents=True, exist_ok=True)
275
+ output.write_text(json.dumps(result, indent=2), encoding="utf-8")
276
+ console.print(f"[green]SAVED[/green] {output}")
277
+
278
+
279
+ @app.command()
280
+ def compare(baseline: Path, intervention: Path, output: Path) -> None:
281
+ """Compare two evaluated runs and retain all change classes."""
282
+ try:
283
+ result = compare_evaluations(
284
+ json.loads(baseline.read_text()), json.loads(intervention.read_text())
285
+ )
286
+ except (OSError, ValueError, json.JSONDecodeError, NotImplementedError) as exc:
287
+ fail(str(exc) or "Comparison could not be completed")
288
+ if output.exists():
289
+ fail(f"Refusing to overwrite: {output}")
290
+ output.parent.mkdir(parents=True, exist_ok=True)
291
+ output.write_text(json.dumps(result, indent=2), encoding="utf-8")
292
+ console.print_json(data=result["summary"])
293
+
294
+
295
+ @app.command()
296
+ def diagnose(comparison: Path) -> None:
297
+ """Explain an observable pattern and bounded next step."""
298
+ try:
299
+ result = build_diagnosis(json.loads(comparison.read_text(encoding="utf-8")))
300
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
301
+ fail(str(exc))
302
+ for key, value in result.items():
303
+ console.print(f"[bold]{key.replace('_', ' ').title()}:[/bold] {value}")
304
+
305
+
306
+ @app.command()
307
+ def inspect(
308
+ run_file: Path,
309
+ question: Annotated[str, typer.Option("--question", help="Stable question ID")],
310
+ ) -> None:
311
+ """Inspect ranked evidence for one question."""
312
+ try:
313
+ run = ExperimentRun.model_validate_json(run_file.read_text(encoding="utf-8"))
314
+ except (OSError, ValueError) as exc:
315
+ fail(str(exc))
316
+ query = next((item for item in run.queries if item.question_id == question), None)
317
+ if query is None:
318
+ raise typer.BadParameter(f"Question not found: {question}")
319
+ table = Table("Rank", "Source", "Evidence", "Score", "Preview")
320
+ for hit in query.hits:
321
+ table.add_row(
322
+ str(hit.rank),
323
+ hit.source_unit_id,
324
+ ", ".join(sorted(hit.evidence_ids)) or "—",
325
+ f"{hit.score:.4f}",
326
+ hit.preview,
327
+ )
328
+ console.print(table)
329
+
330
+
331
+ @app.command()
332
+ def report(
333
+ baseline: Path,
334
+ output: Path,
335
+ intervention: Annotated[Path | None, typer.Option()] = None,
336
+ format: Annotated[str, typer.Option()] = "markdown",
337
+ template: Annotated[Path | None, typer.Option()] = None,
338
+ ) -> None:
339
+ """Create JSON, Markdown or HTML from authoritative evaluation data."""
340
+ if output.exists():
341
+ fail(f"Refusing to overwrite: {output}")
342
+ if format not in {"json", "markdown", "html"}:
343
+ raise typer.BadParameter("format must be json, markdown, or html")
344
+ try:
345
+ baseline_data = json.loads(baseline.read_text(encoding="utf-8"))
346
+ intervention_data = (
347
+ json.loads(intervention.read_text(encoding="utf-8")) if intervention else None
348
+ )
349
+ payload = build_report(baseline_data, intervention_data)
350
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
351
+ fail(str(exc))
352
+ if format == "json":
353
+ content = json.dumps(payload, indent=2)
354
+ elif format == "markdown":
355
+ content = render_markdown(payload)
356
+ else:
357
+ selected = template or Path(__file__).parent / "reporting/templates/report.html.j2"
358
+ if not selected.is_file():
359
+ raise typer.BadParameter("The installed HTML report template is missing")
360
+ content = render_html(payload, selected)
361
+ output.parent.mkdir(parents=True, exist_ok=True)
362
+ output.write_text(content, encoding="utf-8")
363
+ console.print(f"[green]SAVED[/green] {output}")
364
+
365
+
366
+ @workspace_app.command("restore")
367
+ def workspace_restore(name: str, root: Path = DEFAULT_ROOT) -> None:
368
+ """Back up a workspace and replace it with a clean exercise."""
369
+ try:
370
+ target, backup = restore_workspace(root, name)
371
+ except (OSError, ValueError) as exc:
372
+ fail(str(exc))
373
+ console.print(f"[green]RESTORED[/green] {target}\n[yellow]BACKUP[/yellow] {backup}")
@@ -0,0 +1,35 @@
1
+ """Compare evaluated runs without hiding regressions."""
2
+
3
+ from typing import Any
4
+
5
+ from ragvidence.evaluation.metrics import classify_change
6
+
7
+
8
+ def compare_evaluations(baseline: dict[str, Any], intervention: dict[str, Any]) -> dict[str, Any]:
9
+ baseline_manifest = baseline["manifest"]
10
+ intervention_manifest = intervention["manifest"]
11
+ if baseline_manifest["dataset_hash"] != intervention_manifest["dataset_hash"]:
12
+ raise ValueError("Runs use different dataset hashes")
13
+ baseline_k = baseline_manifest["config"]["k"]
14
+ intervention_k = intervention_manifest["config"]["k"]
15
+ if baseline_k != intervention_k:
16
+ raise ValueError("Runs use different retrieval depths (k)")
17
+ before = {item["question_id"]: item for item in baseline["questions"]}
18
+ after = {item["question_id"]: item for item in intervention["questions"]}
19
+ if before.keys() != after.keys():
20
+ raise ValueError("Runs contain different question IDs")
21
+ changes = []
22
+ for question_id in sorted(before):
23
+ old = before[question_id]["sufficient_evidence"]
24
+ new = after[question_id]["sufficient_evidence"]
25
+ status = None if old is None or new is None else classify_change(old, new)
26
+ changes.append({"question_id": question_id, "classification": status})
27
+ return {
28
+ "baseline": baseline["manifest"]["config"],
29
+ "intervention": intervention["manifest"]["config"],
30
+ "summary": {
31
+ key: sum(item["classification"] == key for item in changes)
32
+ for key in ("fail_to_pass", "pass_to_fail", "pass_to_pass", "fail_to_fail")
33
+ },
34
+ "changes": changes,
35
+ }
ragvidence/config.py ADDED
@@ -0,0 +1,16 @@
1
+ """Explicit environment configuration with secret values kept redacted."""
2
+
3
+ from pydantic import SecretStr
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ model_config = SettingsConfigDict(env_prefix="RAGVIDENCE_", env_file=".env", extra="ignore")
9
+ embedding_provider: str = "fastembed"
10
+ embedding_model: str = "BAAI/bge-small-en-v1.5"
11
+ embedding_base_url: str | None = None
12
+ embedding_api_key: SecretStr | None = None
13
+ generation_provider: str | None = None
14
+ generation_model: str | None = None
15
+ generation_base_url: str | None = None
16
+ generation_api_key: SecretStr | None = None
ragvidence/data.py ADDED
@@ -0,0 +1,72 @@
1
+ """JSONL loading and cross-record integrity validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, ValidationError
9
+
10
+ from ragvidence.models import QuestionCase, SourceUnit
11
+
12
+
13
+ class DatasetError(ValueError):
14
+ """A dataset cannot be evaluated safely."""
15
+
16
+
17
+ def load_jsonl[ModelT: BaseModel](path: Path, model: type[ModelT]) -> list[ModelT]:
18
+ records: list[ModelT] = []
19
+ try:
20
+ lines = path.read_text(encoding="utf-8").splitlines()
21
+ except OSError as exc:
22
+ raise DatasetError(f"Cannot read {path}: {exc}") from exc
23
+ for line_number, raw in enumerate(lines, start=1):
24
+ if not raw.strip():
25
+ continue
26
+ try:
27
+ records.append(model.model_validate_json(raw))
28
+ except (ValidationError, ValueError) as exc:
29
+ raise DatasetError(f"{path}:{line_number}: {exc}") from exc
30
+ if not records:
31
+ raise DatasetError(f"{path}: expected at least one record")
32
+ return records
33
+
34
+
35
+ def load_dataset(root: Path) -> tuple[list[SourceUnit], list[QuestionCase]]:
36
+ sources = load_jsonl(root / "sources.jsonl", SourceUnit)
37
+ questions = load_jsonl(root / "questions.jsonl", QuestionCase)
38
+ validate_dataset(sources, questions)
39
+ return sources, questions
40
+
41
+
42
+ def validate_dataset(sources: list[SourceUnit], questions: list[QuestionCase]) -> None:
43
+ source_ids = [item.source_unit_id for item in sources]
44
+ question_ids = [item.question_id for item in questions]
45
+ duplicate_sources = _duplicates(source_ids)
46
+ duplicate_questions = _duplicates(question_ids)
47
+ if duplicate_sources:
48
+ raise DatasetError(f"Duplicate source_unit_id values: {sorted(duplicate_sources)}")
49
+ if duplicate_questions:
50
+ raise DatasetError(f"Duplicate question_id values: {sorted(duplicate_questions)}")
51
+
52
+ evidence = {evidence_id for source in sources for evidence_id in source.evidence_ids}
53
+ missing = {
54
+ evidence_id
55
+ for question in questions
56
+ for evidence_id in question.required_evidence_ids
57
+ if evidence_id not in evidence
58
+ }
59
+ if missing:
60
+ raise DatasetError(f"Questions reference unknown evidence IDs: {sorted(missing)}")
61
+
62
+
63
+ def canonical_dataset_bytes(sources: list[SourceUnit], questions: list[QuestionCase]) -> bytes:
64
+ value = {
65
+ "sources": [item.model_dump(mode="json") for item in sources],
66
+ "questions": [item.model_dump(mode="json") for item in questions],
67
+ }
68
+ return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
69
+
70
+
71
+ def _duplicates(values: list[str]) -> set[str]:
72
+ return {value for value in values if values.count(value) > 1}
@@ -0,0 +1,40 @@
1
+ """Small evidence-backed diagnostic rules for the guided comparison."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def diagnose(comparison: dict[str, Any]) -> dict[str, str]:
7
+ summary = comparison["summary"]
8
+ if summary["fail_to_pass"] and not summary["pass_to_fail"]:
9
+ return {
10
+ "pattern": "Missing structural context in the baseline representation",
11
+ "evidence": (
12
+ f"{summary['fail_to_pass']} failed queries passed after enrichment "
13
+ "with no regressions."
14
+ ),
15
+ "next_experiment": (
16
+ "Repeat on a frozen slice of your own corpus and inspect every changed rank."
17
+ ),
18
+ "confidence": "medium",
19
+ "limit": (
20
+ "This rule identifies correlation in a small labeled comparison, "
21
+ "not universal causation."
22
+ ),
23
+ }
24
+ if summary["pass_to_fail"]:
25
+ return {
26
+ "pattern": "Representation change introduced retrieval regressions",
27
+ "evidence": f"{summary['pass_to_fail']} previously successful queries now fail.",
28
+ "next_experiment": "Inspect regressed queries before adopting the intervention.",
29
+ "confidence": "high",
30
+ "limit": "The rule does not identify the underlying ranking mechanism.",
31
+ }
32
+ return {
33
+ "pattern": "No clear heading-context benefit at the selected depth",
34
+ "evidence": "The comparison contains no clean fail-to-pass signal.",
35
+ "next_experiment": (
36
+ "Inspect failures and test a different diagnosed intervention without changing labels."
37
+ ),
38
+ "confidence": "medium",
39
+ "limit": "Results apply only to this dataset, strategy and retrieval depth.",
40
+ }
@@ -0,0 +1,19 @@
1
+ """Evidence-level evaluation utilities."""
2
+
3
+ from ragvidence.evaluation.metrics import (
4
+ classify_change,
5
+ evidence_coverage_at_k,
6
+ first_relevant_rank,
7
+ precision_at_k,
8
+ reciprocal_rank,
9
+ sufficient_evidence_at_k,
10
+ )
11
+
12
+ __all__ = [
13
+ "classify_change",
14
+ "evidence_coverage_at_k",
15
+ "first_relevant_rank",
16
+ "precision_at_k",
17
+ "reciprocal_rank",
18
+ "sufficient_evidence_at_k",
19
+ ]
@@ -0,0 +1,54 @@
1
+ """Compose evidence-level metrics into a reproducible evaluation result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ragvidence.evaluation.metrics import (
8
+ evidence_coverage_at_k,
9
+ first_relevant_rank,
10
+ precision_at_k,
11
+ reciprocal_rank,
12
+ sufficient_evidence_at_k,
13
+ )
14
+ from ragvidence.models import ExperimentRun, QuestionCase
15
+
16
+
17
+ def evaluate_run(run: ExperimentRun, questions: list[QuestionCase]) -> dict[str, Any]:
18
+ by_id = {item.question_id: item for item in questions}
19
+ results = []
20
+ for query in run.queries:
21
+ question = by_id[query.question_id]
22
+ ranking = [hit.evidence_ids for hit in query.hits]
23
+ required = question.required_evidence_ids
24
+ results.append(
25
+ {
26
+ "question_id": query.question_id,
27
+ "split": question.split,
28
+ "answerable": question.answerable,
29
+ "required_evidence_ids": sorted(required),
30
+ "sufficient_evidence": sufficient_evidence_at_k(
31
+ required, ranking, run.manifest.config.k
32
+ ),
33
+ "evidence_coverage": evidence_coverage_at_k(
34
+ required, ranking, run.manifest.config.k
35
+ ),
36
+ "first_relevant_rank": first_relevant_rank(
37
+ required, ranking, run.manifest.config.k
38
+ ),
39
+ "reciprocal_rank": reciprocal_rank(required, ranking, run.manifest.config.k),
40
+ "precision_at_k": precision_at_k(required, ranking, run.manifest.config.k),
41
+ "hits": [hit.model_dump(mode="json") for hit in query.hits],
42
+ }
43
+ )
44
+ answerable = [item for item in results if item["answerable"]]
45
+ successes = sum(item["sufficient_evidence"] is True for item in answerable)
46
+ return {
47
+ "manifest": run.manifest.model_dump(mode="json"),
48
+ "summary": {
49
+ "successful": successes,
50
+ "answerable": len(answerable),
51
+ "sufficient_evidence_rate": successes / len(answerable) if answerable else None,
52
+ },
53
+ "questions": results,
54
+ }