retrieval-eval 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 (60) hide show
  1. retrieval_eval/__init__.py +114 -0
  2. retrieval_eval/cli.py +430 -0
  3. retrieval_eval/drift.py +265 -0
  4. retrieval_eval/gate.py +213 -0
  5. retrieval_eval/help_text.py +168 -0
  6. retrieval_eval/identity.py +57 -0
  7. retrieval_eval/judgments.py +300 -0
  8. retrieval_eval/metrics.py +426 -0
  9. retrieval_eval/models.py +219 -0
  10. retrieval_eval/py.typed +0 -0
  11. retrieval_eval/qrels.py +106 -0
  12. retrieval_eval/report.py +211 -0
  13. retrieval_eval/spec/README.md +31 -0
  14. retrieval_eval/spec/chunk-id.md +51 -0
  15. retrieval_eval/spec/drift.md +49 -0
  16. retrieval_eval/spec/fixtures/README.md +48 -0
  17. retrieval_eval/spec/fixtures/basic/expected.json +43 -0
  18. retrieval_eval/spec/fixtures/basic/judgments.jsonl +4 -0
  19. retrieval_eval/spec/fixtures/basic/run.jsonl +2 -0
  20. retrieval_eval/spec/fixtures/chunk-id/expected.json +32 -0
  21. retrieval_eval/spec/fixtures/drift/corpus.json +38 -0
  22. retrieval_eval/spec/fixtures/drift/expected.json +22 -0
  23. retrieval_eval/spec/fixtures/drift/judgments.jsonl +4 -0
  24. retrieval_eval/spec/fixtures/duplicate-ranking/expected.json +56 -0
  25. retrieval_eval/spec/fixtures/duplicate-ranking/judgments.jsonl +3 -0
  26. retrieval_eval/spec/fixtures/duplicate-ranking/run-duplicate-query.jsonl +2 -0
  27. retrieval_eval/spec/fixtures/duplicate-ranking/run-non-string-key.jsonl +1 -0
  28. retrieval_eval/spec/fixtures/duplicate-ranking/run.jsonl +2 -0
  29. retrieval_eval/spec/fixtures/merge/corpus.json +22 -0
  30. retrieval_eval/spec/fixtures/merge/expected.json +21 -0
  31. retrieval_eval/spec/fixtures/merge/judgments.jsonl +3 -0
  32. retrieval_eval/spec/fixtures/no-positives/expected.json +17 -0
  33. retrieval_eval/spec/fixtures/no-positives/judgments.jsonl +4 -0
  34. retrieval_eval/spec/fixtures/no-positives/run.jsonl +2 -0
  35. retrieval_eval/spec/fixtures/nothing-scored/expected.json +9 -0
  36. retrieval_eval/spec/fixtures/nothing-scored/judgments.jsonl +1 -0
  37. retrieval_eval/spec/fixtures/nothing-scored/run.jsonl +1 -0
  38. retrieval_eval/spec/fixtures/qrels/beir.tsv +4 -0
  39. retrieval_eval/spec/fixtures/qrels/expected.json +14 -0
  40. retrieval_eval/spec/fixtures/qrels/trec.qrels +4 -0
  41. retrieval_eval/spec/fixtures/strata/expected.json +40 -0
  42. retrieval_eval/spec/fixtures/strata/judgments.jsonl +5 -0
  43. retrieval_eval/spec/fixtures/strata/run.jsonl +5 -0
  44. retrieval_eval/spec/fixtures/stratum-order/expected.json +30 -0
  45. retrieval_eval/spec/fixtures/stratum-order/judgments.jsonl +5 -0
  46. retrieval_eval/spec/fixtures/stratum-order/run.jsonl +5 -0
  47. retrieval_eval/spec/fixtures/summarize/expected.json +53 -0
  48. retrieval_eval/spec/fixtures/unsorted-queries/expected.json +23 -0
  49. retrieval_eval/spec/fixtures/unsorted-queries/judgments.jsonl +3 -0
  50. retrieval_eval/spec/fixtures/unsound-judgments/expected.json +19 -0
  51. retrieval_eval/spec/fixtures/unsound-judgments/judgments.jsonl +3 -0
  52. retrieval_eval/spec/fixtures/unsound-judgments/run.jsonl +1 -0
  53. retrieval_eval/spec/judgments.schema.json +54 -0
  54. retrieval_eval/spec/report.schema.json +227 -0
  55. retrieval_eval/ui.py +71 -0
  56. retrieval_eval-0.1.0.dist-info/METADATA +372 -0
  57. retrieval_eval-0.1.0.dist-info/RECORD +60 -0
  58. retrieval_eval-0.1.0.dist-info/WHEEL +4 -0
  59. retrieval_eval-0.1.0.dist-info/entry_points.txt +2 -0
  60. retrieval_eval-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,114 @@
1
+ """retrieval-eval: portable relevance judgments, deterministic retrieval metrics, and drift.
2
+
3
+ Zero runtime dependencies. No API key. Nothing here calls a model.
4
+
5
+ The Python and TypeScript implementations are peers, not ports: both prove themselves against
6
+ the same fixtures in ``spec/fixtures``.
7
+
8
+ >>> from retrieval_eval import drift, parse_corpus, parse_judgments
9
+ >>> judgments = parse_judgments(open("judgments.jsonl").read())
10
+ >>> corpus = parse_corpus(open("corpus.json").read())
11
+ >>> drift(judgments, corpus).summary.invalid_ratio
12
+ 0.75
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .drift import FixResult, drift, fix
18
+ from .gate import (
19
+ Gate,
20
+ GateEvaluation,
21
+ GateResult,
22
+ evaluate_gates,
23
+ parse_gate,
24
+ worse_status,
25
+ )
26
+ from .identity import chunk_id, normalize, text_sha
27
+ from .judgments import (
28
+ ValidationIssue,
29
+ ValidationResult,
30
+ parse_corpus,
31
+ parse_judgments,
32
+ parse_run,
33
+ serialize_judgments,
34
+ validate,
35
+ )
36
+ from .metrics import (
37
+ ScoreResult,
38
+ StratumScore,
39
+ WorstStratum,
40
+ dedupe,
41
+ query_metrics,
42
+ relevance_by_query,
43
+ score,
44
+ score_by_stratum,
45
+ summarize,
46
+ worst_stratum,
47
+ )
48
+ from .models import (
49
+ Corpus,
50
+ CorpusChunk,
51
+ DriftFinding,
52
+ DriftResult,
53
+ DriftSummary,
54
+ Judgment,
55
+ Measurement,
56
+ QueryMetrics,
57
+ RunEntry,
58
+ )
59
+ from .qrels import from_qrels, from_trec_run, to_qrels, to_trec_run
60
+ from .report import SPEC_VERSION, TOOL_NAME, TOOL_VERSION, Report, Verdict, build_report
61
+
62
+ __version__ = TOOL_VERSION
63
+
64
+ __all__ = [
65
+ "SPEC_VERSION",
66
+ "TOOL_NAME",
67
+ "TOOL_VERSION",
68
+ "Corpus",
69
+ "CorpusChunk",
70
+ "DriftFinding",
71
+ "DriftResult",
72
+ "DriftSummary",
73
+ "FixResult",
74
+ "Gate",
75
+ "GateEvaluation",
76
+ "GateResult",
77
+ "Judgment",
78
+ "Measurement",
79
+ "QueryMetrics",
80
+ "Report",
81
+ "RunEntry",
82
+ "ScoreResult",
83
+ "StratumScore",
84
+ "ValidationIssue",
85
+ "ValidationResult",
86
+ "Verdict",
87
+ "WorstStratum",
88
+ "__version__",
89
+ "build_report",
90
+ "chunk_id",
91
+ "dedupe",
92
+ "drift",
93
+ "evaluate_gates",
94
+ "fix",
95
+ "from_qrels",
96
+ "from_trec_run",
97
+ "normalize",
98
+ "parse_corpus",
99
+ "parse_gate",
100
+ "parse_judgments",
101
+ "parse_run",
102
+ "query_metrics",
103
+ "relevance_by_query",
104
+ "score",
105
+ "score_by_stratum",
106
+ "serialize_judgments",
107
+ "summarize",
108
+ "text_sha",
109
+ "to_qrels",
110
+ "to_trec_run",
111
+ "validate",
112
+ "worse_status",
113
+ "worst_stratum",
114
+ ]
retrieval_eval/cli.py ADDED
@@ -0,0 +1,430 @@
1
+ """Command line interface.
2
+
3
+ The same verbs, flags, output and exit codes as the TypeScript CLI, so a polyglot team writes
4
+ one CI step and a tutorial works in either language. `scripts/check_parity.py` runs both over
5
+ the shared fixtures and diffs the result.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import NoReturn
15
+
16
+ from .drift import drift, fix
17
+ from .gate import evaluate_gates, parse_gate, worse_status
18
+ from .help_text import COMMAND_HELP, COMMANDS, ROOT_HELP
19
+ from .judgments import parse_corpus, parse_judgments, parse_run, serialize_judgments, validate
20
+ from .metrics import StratumScore
21
+ from .models import DriftResult
22
+ from .qrels import from_qrels, to_qrels, to_trec_run
23
+ from .report import TOOL_VERSION, Report, build_report
24
+ from .ui import bar, heading, num, paint, pct, set_color
25
+
26
+ EXIT_OK = 0
27
+ EXIT_FAILED = 1
28
+ EXIT_USAGE = 2
29
+
30
+
31
+ class CliError(Exception):
32
+ """An input or invocation the user can fix. Never a traceback: see `main`."""
33
+
34
+ def __init__(self, message: str, hint: str | None = None) -> None:
35
+ """Record the message and, where there is one, the command that fixes it."""
36
+ super().__init__(message)
37
+ self.hint = hint
38
+
39
+
40
+ class _Parser(argparse.ArgumentParser):
41
+ """An argparse parser whose failures travel the same path as every other CLI error."""
42
+
43
+ def error(self, message: str) -> NoReturn:
44
+ missing = message.split(": expected one argument")
45
+ if len(missing) == 2 and missing[0].startswith("argument "):
46
+ flag = missing[0].removeprefix("argument ").split("/")[-1]
47
+ raise CliError(f"{flag} expects a value")
48
+ raise CliError(message)
49
+
50
+
51
+ def _build_parser() -> _Parser:
52
+ parser = _Parser(prog="retrieval-eval", add_help=False)
53
+ parser.add_argument("positionals", nargs="*")
54
+ for flag in ("judgments", "run", "corpus", "qrels", "baseline", "out", "to", "color"):
55
+ parser.add_argument(f"--{flag}")
56
+ parser.add_argument("-k", "--k", dest="k")
57
+ parser.add_argument("--threshold")
58
+ parser.add_argument("--gate", action="append", default=[])
59
+ parser.add_argument("--fix", action="store_true")
60
+ parser.add_argument("--as-chunk-ids", dest="as_chunk_ids", action="store_true")
61
+ parser.add_argument("--json", action="store_true")
62
+ parser.add_argument("-h", "--help", dest="help", action="store_true")
63
+ parser.add_argument("-v", "--version", dest="version", action="store_true")
64
+ return parser
65
+
66
+
67
+ def _report(error: Exception) -> int:
68
+ """Turn any failure into one readable line plus, where there is one, the way out of it."""
69
+ sys.stderr.write(f"{paint('red', 'error')} {error}\n")
70
+ hint = getattr(error, "hint", None)
71
+ if hint is not None:
72
+ sys.stderr.write(f"{paint('dim', ' try')} {hint}\n")
73
+ return EXIT_USAGE
74
+
75
+
76
+ def _read(path: str) -> str:
77
+ try:
78
+ return Path(path).read_text(encoding="utf-8")
79
+ except OSError as error:
80
+ reason = (error.strerror or str(error)).lower()
81
+ raise CliError(f"cannot read {path}: {reason}") from error
82
+
83
+
84
+ def _required(args: argparse.Namespace, command: str, flags: list[str]) -> None:
85
+ if any(getattr(args, flag.replace("-", "_")) is None for flag in flags):
86
+ joined = " and ".join(f"--{flag}" for flag in flags)
87
+ raise CliError(f"{command} needs {joined}", f"retrieval-eval {command} --help")
88
+
89
+
90
+ def _integer(raw: str | None, flag: str, fallback: int) -> int:
91
+ if raw is None:
92
+ return fallback
93
+ try:
94
+ value = int(raw)
95
+ except ValueError:
96
+ raise CliError(f"{flag} expects a positive integer, got '{raw}'") from None
97
+ if value < 1:
98
+ raise CliError(f"{flag} expects a positive integer, got '{raw}'")
99
+ return value
100
+
101
+
102
+ def _resolve_color(value: str | None) -> str:
103
+ if value is None:
104
+ return "auto"
105
+ if value in ("auto", "always", "never"):
106
+ return value
107
+ raise CliError(f"--color expects auto, always or never, got '{value}'")
108
+
109
+
110
+ def _closest(typo: str) -> str | None:
111
+ """The command a typo most likely meant, by edit distance, or None if none is close."""
112
+ best, best_distance = None, len(typo) + 1
113
+ for candidate in COMMANDS:
114
+ distance = _edit_distance(typo, candidate)
115
+ if distance < best_distance:
116
+ best, best_distance = candidate, distance
117
+ return best if best_distance <= 3 else None
118
+
119
+
120
+ def _edit_distance(a: str, b: str) -> int:
121
+ previous = list(range(len(b) + 1))
122
+ for i, left in enumerate(a, start=1):
123
+ current = [i]
124
+ for j, right in enumerate(b, start=1):
125
+ cost = 0 if left == right else 1
126
+ current.append(min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost))
127
+ previous = current
128
+ return previous[len(b)]
129
+
130
+
131
+ def _print_drift(result: DriftResult, total: int, *, fixing: bool) -> None:
132
+ summary = result.summary
133
+ out = ["\n"]
134
+ out.append(
135
+ f" {total} judgments · labeled @ fingerprint "
136
+ f"{paint('cyan', result.judgments_fingerprint or 'unknown')}\n"
137
+ )
138
+ out.append(
139
+ f" live corpus @ fingerprint "
140
+ f"{paint('cyan', result.corpus_fingerprint or 'unknown')}\n\n"
141
+ )
142
+
143
+ # Marks are padded before colouring: escape codes have width in the string, not on screen.
144
+ rows = [
145
+ ("ok", "green", summary.valid, "VALID", "chunk_id still present"),
146
+ ("!", "yellow", summary.re_anchorable, "RE-ANCHORABLE", "text moved to a new chunk_id"),
147
+ ("!", "yellow", summary.merged, "MERGED", "text absorbed into a coarser chunk"),
148
+ ("!", "yellow", summary.split, "SPLIT", "labeled text now spans 2+ chunks"),
149
+ ("x", "red", summary.orphaned, "ORPHANED", "source text or document is gone"),
150
+ ]
151
+ for mark, color, count, label, note in rows:
152
+ out.append(
153
+ f" {paint(color, mark.ljust(2))} {str(count).rjust(3)} "
154
+ f"{label.ljust(14)} {paint('dim', note)}\n"
155
+ )
156
+
157
+ if summary.invalid_ratio > 0:
158
+ recoverable = summary.re_anchorable + summary.merged
159
+ needs_human = summary.split + summary.orphaned
160
+ headline = (
161
+ f"{pct(summary.invalid_ratio)} of your judgment set no longer matches the live corpus."
162
+ )
163
+ detail = "Any metric computed against it is measuring two changes at once."
164
+ out.append(f"\n {paint('bold', headline)}\n")
165
+ out.append(f" {paint('dim', detail)}\n")
166
+ # Split the number so it is actionable rather than merely alarming.
167
+ breakdown = f"{recoverable} recoverable automatically · {needs_human} need a human"
168
+ out.append(f" {paint('dim', breakdown)}\n")
169
+ if recoverable > 0 and not fixing:
170
+ nxt = "retrieval-eval drift --fix, to re-anchor the recoverable labels"
171
+ out.append(f"\n {paint('dim', 'next')} {nxt}\n")
172
+ else:
173
+ clean = "Every label still points at the text it was written for."
174
+ out.append(f"\n {paint('green', clean)}\n")
175
+
176
+ sys.stdout.write("".join(out))
177
+
178
+
179
+ def _cmd_drift(args: argparse.Namespace) -> int:
180
+ _required(args, "drift", ["judgments", "corpus"])
181
+ judgments = parse_judgments(_read(args.judgments))
182
+ corpus = parse_corpus(_read(args.corpus))
183
+ result = drift(judgments, corpus)
184
+
185
+ if args.json:
186
+ sys.stdout.write(json.dumps(result.to_dict(), indent=2) + "\n")
187
+ else:
188
+ _print_drift(result, len(judgments), fixing=args.fix)
189
+
190
+ if args.fix:
191
+ fixed = fix(judgments, result)
192
+ Path(args.judgments).write_text(serialize_judgments(fixed.judgments), encoding="utf-8")
193
+ sys.stdout.write(
194
+ f"\n {paint('green', 'fixed')} re-anchored {fixed.reanchored} label(s) "
195
+ f"in {args.judgments}\n"
196
+ f" {paint('yellow', 'review')} {len(fixed.needs_review)} label(s) "
197
+ "still need a human\n"
198
+ )
199
+
200
+ # Non-zero when labels have decayed, so `drift` stands alone as a CI check.
201
+ return EXIT_FAILED if result.summary.invalid_ratio > 0 else EXIT_OK
202
+
203
+
204
+ def _print_score(report: Report) -> None:
205
+ out = [f"\n {report.judgments['queries']} queries · {report.judgments['labels']} labels"]
206
+ if report.judgments.get("human_labels") is not None:
207
+ out.append(
208
+ paint(
209
+ "dim",
210
+ f" ({report.judgments['human_labels']} human, "
211
+ f"{report.judgments.get('synthetic_labels', 0)} synthetic)",
212
+ )
213
+ )
214
+ out.append("\n\n")
215
+
216
+ if not report.metrics:
217
+ out.append(f" {paint('dim', 'no metric was computed')}\n")
218
+
219
+ for name, measurement in report.metrics.items():
220
+ ci = ""
221
+ if measurement.ci is not None:
222
+ ci = paint("dim", f" [{num(measurement.ci[0])}, {num(measurement.ci[1])}]")
223
+ out.append(f" {name.ljust(16)} {num(measurement.value)}{ci}\n")
224
+
225
+ if report.per_stratum:
226
+ names = list(report.metrics)
227
+ primary = next(
228
+ (name for name in names if name.startswith("recall@")), names[0] if names else ""
229
+ )
230
+
231
+ def stratum_value(stratum: StratumScore) -> float:
232
+ measurement = stratum.metrics.get(primary)
233
+ return measurement.value if measurement is not None else 0.0
234
+
235
+ entries = [
236
+ (name, stratum.n, stratum_value(stratum))
237
+ for name, stratum in report.per_stratum.items()
238
+ ]
239
+ # A stratum with no scored query has no number to compare, so it is listed after the
240
+ # ones that do and never marked worst.
241
+ ranked = sorted((row for row in entries if row[1] > 0), key=lambda row: (row[2], row[0]))
242
+ unscored = sorted(row for row in entries if row[1] == 0)
243
+
244
+ out.append(heading(f"{primary} by stratum" if primary else "strata"))
245
+ for index, (name, n, value) in enumerate(ranked):
246
+ flag = paint("yellow", " worst") if index == 0 and len(ranked) > 1 else ""
247
+ out.append(
248
+ f" {paint('dim', '·')} {name.ljust(20)} {num(value)} "
249
+ f"{paint('dim', bar(value))} {paint('dim', f'n={n}')}{flag}\n"
250
+ )
251
+ for name, _n, _value in unscored:
252
+ note = "not scored, no label at the relevance threshold"
253
+ out.append(f" {paint('dim', '·')} {name.ljust(20)} {paint('dim', note)}\n")
254
+
255
+ drift_info = report.judgments.get("drift")
256
+ if drift_info and drift_info.get("invalid_ratio", 0) > 0:
257
+ out.append(
258
+ f"\n {paint('yellow', 'warning')} "
259
+ f"{pct(drift_info['invalid_ratio'])} of judgments no longer match the corpus\n"
260
+ )
261
+
262
+ if report.verdict.gates:
263
+ out.append(heading("gates"))
264
+ for gate in report.verdict.gates:
265
+ mark = {
266
+ "PASS": paint("green", "ok"),
267
+ "FAIL": paint("red", "x "),
268
+ "INDETERMINATE": paint("yellow", "? "),
269
+ }[gate.status]
270
+ out.append(f" {mark} {gate.expression}\n")
271
+
272
+ for reason in report.verdict.reasons:
273
+ out.append(f" {paint('dim', f'→ {reason}')}\n")
274
+
275
+ color = {"PASS": "green", "FAIL": "red", "INDETERMINATE": "yellow"}[report.verdict.status]
276
+ out.append(f"\n {paint(color, paint('bold', report.verdict.status))}\n")
277
+ sys.stdout.write("".join(out))
278
+
279
+
280
+ def _cmd_score(args: argparse.Namespace) -> int:
281
+ _required(args, "score", ["judgments", "run"])
282
+ judgments = parse_judgments(_read(args.judgments))
283
+ run = parse_run(_read(args.run))
284
+ k = _integer(args.k, "-k", 10)
285
+ threshold = _integer(args.threshold, "--threshold", 1)
286
+
287
+ corpus = parse_corpus(_read(args.corpus)) if args.corpus else None
288
+ drift_result = drift(judgments, corpus) if corpus else None
289
+
290
+ report = build_report(
291
+ judgments,
292
+ run,
293
+ k=k,
294
+ threshold=threshold,
295
+ corpus=corpus,
296
+ drift_result=drift_result,
297
+ )
298
+
299
+ if args.gate:
300
+ gates = [parse_gate(expression) for expression in args.gate]
301
+ baseline = Report.from_dict(json.loads(_read(args.baseline))) if args.baseline else None
302
+ evaluated = evaluate_gates(report, gates, baseline)
303
+ # The base verdict already carries findings no gate looked at, such as a judgment set
304
+ # `validate` rejects, so a passing gate does not clear them.
305
+ report.verdict.status = worse_status(report.verdict.status, evaluated.status)
306
+ report.verdict.gates = evaluated.results
307
+ report.verdict.reasons = [*report.verdict.reasons, *evaluated.reasons]
308
+
309
+ if args.out:
310
+ Path(args.out).write_text(json.dumps(report.to_dict(), indent=2) + "\n", encoding="utf-8")
311
+
312
+ if args.json:
313
+ sys.stdout.write(json.dumps(report.to_dict(), indent=2) + "\n")
314
+ else:
315
+ _print_score(report)
316
+
317
+ return EXIT_OK if report.verdict.status == "PASS" else EXIT_FAILED
318
+
319
+
320
+ def _cmd_validate(args: argparse.Namespace) -> int:
321
+ _required(args, "validate", ["judgments"])
322
+ result = validate(parse_judgments(_read(args.judgments)))
323
+
324
+ if args.json:
325
+ sys.stdout.write(json.dumps(result.to_dict(), indent=2) + "\n")
326
+ return EXIT_OK if result.ok else EXIT_FAILED
327
+
328
+ out = [
329
+ f"\n {result.labels} labels · {result.queries} queries · {len(result.strata)} strata\n\n"
330
+ ]
331
+ if not result.issues:
332
+ out.append(f" {paint('green', 'ok')} no issues\n")
333
+ else:
334
+ for issue in result.issues:
335
+ mark = (
336
+ paint("red", "error ") if issue.severity == "error" else paint("yellow", "warning")
337
+ )
338
+ out.append(f" {mark} {paint('dim', f'[{issue.code}]')} {issue.message}\n")
339
+ sys.stdout.write("".join(out))
340
+ return EXIT_OK if result.ok else EXIT_FAILED
341
+
342
+
343
+ def _cmd_convert(args: argparse.Namespace) -> int:
344
+ if args.to is None:
345
+ raise CliError(
346
+ "convert needs --to qrels, trec-run or judgments",
347
+ "retrieval-eval convert --help",
348
+ )
349
+
350
+ if args.to == "judgments":
351
+ _required(args, "convert", ["qrels"])
352
+ judgments = from_qrels(_read(args.qrels), as_chunk_ids=args.as_chunk_ids)
353
+ sys.stdout.write(serialize_judgments(judgments))
354
+ return EXIT_OK
355
+
356
+ if args.to == "qrels":
357
+ _required(args, "convert", ["judgments"])
358
+ sys.stdout.write(to_qrels(parse_judgments(_read(args.judgments))))
359
+ return EXIT_OK
360
+
361
+ if args.to == "trec-run":
362
+ _required(args, "convert", ["run"])
363
+ sys.stdout.write(to_trec_run(parse_run(_read(args.run))))
364
+ return EXIT_OK
365
+
366
+ raise CliError(
367
+ f"unknown --to '{args.to}', expected qrels, trec-run or judgments",
368
+ "retrieval-eval convert --help",
369
+ )
370
+
371
+
372
+ _HANDLERS = {
373
+ "drift": _cmd_drift,
374
+ "score": _cmd_score,
375
+ "validate": _cmd_validate,
376
+ "convert": _cmd_convert,
377
+ }
378
+
379
+
380
+ def main(argv: list[str] | None = None) -> int:
381
+ """Run one command and return its exit code.
382
+
383
+ A parse or validation failure becomes one readable line, not a traceback: a measurement
384
+ tool that answers a malformed file with a stack trace teaches people not to trust its
385
+ other output either.
386
+ """
387
+ try:
388
+ args, extras = _build_parser().parse_known_args(argv)
389
+ except CliError as error:
390
+ set_color("auto")
391
+ return _report(error)
392
+
393
+ try:
394
+ set_color(_resolve_color(args.color))
395
+ unknown = next((token for token in extras if token.startswith("-")), None)
396
+ if unknown is not None:
397
+ raise CliError(f"unknown option '{unknown}'", "retrieval-eval --help")
398
+
399
+ if args.version:
400
+ sys.stdout.write(f"{TOOL_VERSION}\n")
401
+ return EXIT_OK
402
+
403
+ first = args.positionals[0] if args.positionals else None
404
+ command = (
405
+ args.positionals[1]
406
+ if first == "help" and len(args.positionals) > 1
407
+ else (None if first == "help" else first)
408
+ )
409
+
410
+ if command is None:
411
+ sys.stdout.write(f"{ROOT_HELP}\n")
412
+ # `help` and `--help` were asked for; a bare invocation was not.
413
+ return EXIT_OK if args.help or first == "help" else EXIT_USAGE
414
+ if command not in COMMANDS:
415
+ near = _closest(command)
416
+ raise CliError(
417
+ f"unknown command '{command}'",
418
+ f"retrieval-eval {near}" if near else "retrieval-eval --help",
419
+ )
420
+ if args.help or first == "help":
421
+ sys.stdout.write(f"{COMMAND_HELP[command]}\n")
422
+ return EXIT_OK
423
+
424
+ return _HANDLERS[command](args)
425
+ except (CliError, ValueError, json.JSONDecodeError) as error:
426
+ return _report(error)
427
+
428
+
429
+ if __name__ == "__main__":
430
+ raise SystemExit(main())