File size: 8,928 Bytes
f8a5189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
#!/usr/bin/env python
"""Retrieval eval report for pull requests.

Two subcommands:

  run      Measure the v2 eval set (vector, bm25, hybrid; reranker off, offline
           hashing embedder) for the code and data under ``--root`` and write JSON.
  compare  Diff a base run against a head run, print a markdown report, and exit 1
           if any tracked metric dropped by more than ``--max-drop``.

``run`` only uses the package under ``--root``, so CI can measure the pull-request
base commit (checked out in a separate worktree) with this script from the head
commit. Retrieval is deterministic offline, so any drop beyond the tolerance is
a real change, not noise.

Usage:
  python scripts/eval_report.py run --root . --out head.json
  python scripts/eval_report.py run --root ../base --out base.json
  python scripts/eval_report.py compare --base base.json --head head.json --out report.md
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import sys
from pathlib import Path

MARKER = "<!-- lumen-eval-report -->"
MODES = ("vector", "bm25", "hybrid")
TRACKED = ("recall@k", "mrr", "ndcg@k")
LABELS = {"recall@k": "recall@5", "mrr": "MRR", "ndcg@k": "nDCG@5"}
K = 5


def data_fingerprint(root: Path) -> str:
    """Hash of the eval set and its documents, to tell whether the data changed."""
    data = root / "data" / "eval_v2"
    h = hashlib.sha256()
    for path in sorted((data / "docs").glob("*.md")):
        h.update(path.name.encode())
        h.update(path.read_bytes())
    h.update((data / "eval.jsonl").read_bytes())
    return h.hexdigest()[:16]


def run_benchmark(root: Path, k: int = K) -> dict:
    """Measure the v2 set using the lumen_rag package found under ``root``."""
    root = root.resolve()
    # Deterministic and offline: no API embedder, no cross-encoder.
    os.environ.pop("OPENAI_API_KEY", None)
    os.environ.pop("LUMEN_RERANKER", None)
    sys.path.insert(0, str(root))

    import lumen_rag
    from lumen_rag.embeddings import get_embedder
    from lumen_rag.eval import evaluate
    from lumen_rag.eval.harness import load_cases
    from lumen_rag.ingestion.loaders import load_file
    from lumen_rag.ingestion.pipeline import ingest_documents
    from lumen_rag.retrieval import Retriever
    from lumen_rag.store import VectorStore

    package_dir = Path(lumen_rag.__file__).resolve().parent
    if root not in package_dir.parents:
        raise RuntimeError(f"measured {package_dir}, which is not under --root {root}")

    data = root / "data" / "eval_v2"
    cases = load_cases(data / "eval.jsonl")
    embedder = get_embedder()
    store = VectorStore(dim=embedder.dim)
    docs = [load_file(p) for p in sorted((data / "docs").glob("*.md"))]
    ingest_documents(docs, store=store, embedder=embedder, chunk_size=120, overlap=20)
    retriever = Retriever(store, embedder, reranker=None)

    modes: dict[str, dict] = {}
    n_cases = n_unanswerable = 0
    for mode in MODES:
        report = evaluate(retriever, cases, k=k, mode=mode)
        n_cases, n_unanswerable = report.n_cases, report.n_unanswerable
        d = report.as_dict()
        modes[mode] = {
            "overall": {key: d[key] for key in ("recall@k", "precision@k", "mrr", "ndcg@k", "hit_rate")},
            "by_category": report.by_category,
        }
    return {
        "k": k,
        "n_cases": n_cases,
        "n_unanswerable": n_unanswerable,
        "data_fingerprint": data_fingerprint(root),
        "modes": modes,
    }


def _fmt_delta(delta: float, drop_limit: float) -> str:
    if abs(delta) < 0.0005:
        return "0.000"
    mark = "🔻" if delta < -drop_limit else ("▲" if delta > 0 else "▼")
    return f"{delta:+.3f} {mark}"


def compare(base: dict | None, head: dict, max_drop: float = 0.01) -> tuple[str, list[str], bool]:
    """Return ``(markdown, regressions, comparable)``.

    ``comparable`` is False when there is no baseline or the eval data changed
    between base and head; in that case deltas are shown but never gate a merge.
    """
    lines = [MARKER, "## Retrieval eval report", ""]
    regressions: list[str] = []

    if base is None:
        lines += [
            "⚠️ No baseline available (the base commit could not be measured), so there is nothing to compare against.",
            "",
        ]
        comparable = False
    elif base.get("data_fingerprint") != head.get("data_fingerprint"):
        lines += [
            (
                "⚠️ The eval set or its documents changed in this pull request, so the numbers below are **not "
                "comparable** with the base branch and do not block the merge."
            ),
            "",
        ]
        comparable = False
    else:
        comparable = True

    if comparable:
        for mode in MODES:
            for key in TRACKED:
                b = base["modes"][mode]["overall"][key]
                h = head["modes"][mode]["overall"][key]
                if b - h > max_drop:
                    regressions.append(f"{mode} {LABELS[key]}: {b:.3f} -> {h:.3f} ({h - b:+.3f})")
        if regressions:
            lines += [f"❌ **{len(regressions)} metric(s) dropped by more than {max_drop:g}:**", ""]
            lines += [f"- {r}" for r in regressions]
            lines += [""]
        else:
            lines += [f"✅ No metric dropped by more than {max_drop:g}.", ""]

    n = head["n_cases"]
    lines += [f"{n} answerable questions (+ {head['n_unanswerable']} unanswerable, not scored), k={head['k']}, offline hashing embedder, no reranker.", ""]

    show_base = base is not None
    header = ["Mode", "Metric"] + (["Base", "Head", "Δ"] if show_base else ["Head"])
    lines += ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
    for mode in MODES:
        for key in TRACKED:
            h = head["modes"][mode]["overall"][key]
            if show_base:
                b = base["modes"][mode]["overall"][key]
                lines.append(f"| {mode} | {LABELS[key]} | {b:.3f} | {h:.3f} | {_fmt_delta(h - b, max_drop)} |")
            else:
                lines.append(f"| {mode} | {LABELS[key]} | {h:.3f} |")
    lines.append("")

    categories = sorted({c for m in MODES for c in head["modes"][m]["by_category"]})
    lines += ["<details><summary>MRR by question category</summary>", ""]
    cat_header = ["Mode", "Category"] + (["Base", "Head", "Δ"] if show_base else ["Head"])
    lines += ["| " + " | ".join(cat_header) + " |", "|" + "---|" * len(cat_header)]
    for mode in MODES:
        for cat in categories:
            h = head["modes"][mode]["by_category"].get(cat, {}).get("mrr")
            if h is None:
                continue
            if show_base:
                b = base["modes"][mode]["by_category"].get(cat, {}).get("mrr")
                if b is None:
                    lines.append(f"| {mode} | {cat} | - | {h:.3f} | - |")
                else:
                    lines.append(f"| {mode} | {cat} | {b:.3f} | {h:.3f} | {_fmt_delta(h - b, max_drop)} |")
            else:
                lines.append(f"| {mode} | {cat} | {h:.3f} |")
    lines += ["", "</details>", ""]
    return "\n".join(lines), regressions, comparable


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = parser.add_subparsers(dest="cmd", required=True)

    run = sub.add_parser("run", help="measure the v2 eval set for the code under --root")
    run.add_argument("--root", default=".", help="repository root to measure (default: .)")
    run.add_argument("--out", required=True, help="JSON file to write")
    run.add_argument("--k", type=int, default=K)

    cmp_ = sub.add_parser("compare", help="diff base and head runs; exit 1 on regression")
    cmp_.add_argument("--base", help="base JSON (optional; omit if no baseline)")
    cmp_.add_argument("--head", required=True, help="head JSON")
    cmp_.add_argument("--max-drop", type=float, default=0.01, help="largest allowed absolute drop per metric")
    cmp_.add_argument("--out", help="write the markdown report here as well as stdout")

    args = parser.parse_args(argv)

    if args.cmd == "run":
        result = run_benchmark(Path(args.root), k=args.k)
        Path(args.out).write_text(json.dumps(result, indent=2), encoding="utf-8")
        print(f"wrote {args.out} ({result['n_cases']} answerable cases)")
        return 0

    head = json.loads(Path(args.head).read_text(encoding="utf-8"))
    base_path = Path(args.base) if args.base else None
    base = json.loads(base_path.read_text(encoding="utf-8")) if base_path and base_path.exists() else None
    markdown, regressions, _ = compare(base, head, args.max_drop)
    print(markdown)
    if args.out:
        Path(args.out).write_text(markdown, encoding="utf-8")
    return 1 if regressions else 0


if __name__ == "__main__":
    sys.exit(main())