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.
- retrieval_eval/__init__.py +114 -0
- retrieval_eval/cli.py +430 -0
- retrieval_eval/drift.py +265 -0
- retrieval_eval/gate.py +213 -0
- retrieval_eval/help_text.py +168 -0
- retrieval_eval/identity.py +57 -0
- retrieval_eval/judgments.py +300 -0
- retrieval_eval/metrics.py +426 -0
- retrieval_eval/models.py +219 -0
- retrieval_eval/py.typed +0 -0
- retrieval_eval/qrels.py +106 -0
- retrieval_eval/report.py +211 -0
- retrieval_eval/spec/README.md +31 -0
- retrieval_eval/spec/chunk-id.md +51 -0
- retrieval_eval/spec/drift.md +49 -0
- retrieval_eval/spec/fixtures/README.md +48 -0
- retrieval_eval/spec/fixtures/basic/expected.json +43 -0
- retrieval_eval/spec/fixtures/basic/judgments.jsonl +4 -0
- retrieval_eval/spec/fixtures/basic/run.jsonl +2 -0
- retrieval_eval/spec/fixtures/chunk-id/expected.json +32 -0
- retrieval_eval/spec/fixtures/drift/corpus.json +38 -0
- retrieval_eval/spec/fixtures/drift/expected.json +22 -0
- retrieval_eval/spec/fixtures/drift/judgments.jsonl +4 -0
- retrieval_eval/spec/fixtures/duplicate-ranking/expected.json +56 -0
- retrieval_eval/spec/fixtures/duplicate-ranking/judgments.jsonl +3 -0
- retrieval_eval/spec/fixtures/duplicate-ranking/run-duplicate-query.jsonl +2 -0
- retrieval_eval/spec/fixtures/duplicate-ranking/run-non-string-key.jsonl +1 -0
- retrieval_eval/spec/fixtures/duplicate-ranking/run.jsonl +2 -0
- retrieval_eval/spec/fixtures/merge/corpus.json +22 -0
- retrieval_eval/spec/fixtures/merge/expected.json +21 -0
- retrieval_eval/spec/fixtures/merge/judgments.jsonl +3 -0
- retrieval_eval/spec/fixtures/no-positives/expected.json +17 -0
- retrieval_eval/spec/fixtures/no-positives/judgments.jsonl +4 -0
- retrieval_eval/spec/fixtures/no-positives/run.jsonl +2 -0
- retrieval_eval/spec/fixtures/nothing-scored/expected.json +9 -0
- retrieval_eval/spec/fixtures/nothing-scored/judgments.jsonl +1 -0
- retrieval_eval/spec/fixtures/nothing-scored/run.jsonl +1 -0
- retrieval_eval/spec/fixtures/qrels/beir.tsv +4 -0
- retrieval_eval/spec/fixtures/qrels/expected.json +14 -0
- retrieval_eval/spec/fixtures/qrels/trec.qrels +4 -0
- retrieval_eval/spec/fixtures/strata/expected.json +40 -0
- retrieval_eval/spec/fixtures/strata/judgments.jsonl +5 -0
- retrieval_eval/spec/fixtures/strata/run.jsonl +5 -0
- retrieval_eval/spec/fixtures/stratum-order/expected.json +30 -0
- retrieval_eval/spec/fixtures/stratum-order/judgments.jsonl +5 -0
- retrieval_eval/spec/fixtures/stratum-order/run.jsonl +5 -0
- retrieval_eval/spec/fixtures/summarize/expected.json +53 -0
- retrieval_eval/spec/fixtures/unsorted-queries/expected.json +23 -0
- retrieval_eval/spec/fixtures/unsorted-queries/judgments.jsonl +3 -0
- retrieval_eval/spec/fixtures/unsound-judgments/expected.json +19 -0
- retrieval_eval/spec/fixtures/unsound-judgments/judgments.jsonl +3 -0
- retrieval_eval/spec/fixtures/unsound-judgments/run.jsonl +1 -0
- retrieval_eval/spec/judgments.schema.json +54 -0
- retrieval_eval/spec/report.schema.json +227 -0
- retrieval_eval/ui.py +71 -0
- retrieval_eval-0.1.0.dist-info/METADATA +372 -0
- retrieval_eval-0.1.0.dist-info/RECORD +60 -0
- retrieval_eval-0.1.0.dist-info/WHEEL +4 -0
- retrieval_eval-0.1.0.dist-info/entry_points.txt +2 -0
- 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())
|