File size: 22,669 Bytes
aff03ff
 
 
16d760d
aff03ff
 
 
 
16d760d
aff03ff
 
 
 
16d760d
aff03ff
 
16d760d
 
aff03ff
16d760d
aff03ff
16d760d
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
 
 
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
16d760d
 
aff03ff
 
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
 
 
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
 
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16d760d
 
aff03ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
"""Extract null-margin calibration (EVAL ONLY): choose ``options.null_margin`` on held-out data.

A nullable/optional extract leaf is null when ``best_span_logprob < null_logprob + margin``
(:func:`deg.infer.extract_result`). The user's rule: a made-up answer costs twice an
omission, so the operating point minimises

    cost(m) = 2 * made_up_rate(unanswerable leaves) + omission_rate(answerable leaves)

on HELD-OUT data (never the deg-suite v1 test items), and must not collapse to
always-null. Steps:

    # held-out sets: SQuAD v2 dev paragraphs NOT in suite-v1 (suite contexts excluded and
    # SuiteGuard-checked) + datagen val-split records (full-off, extract-real-v1, v3-long)
    python -m deg.bench.null_margin build --out results/deg/null-margin-v1
    # one model pass per set: per top-level nullable/optional extract leaf, the best valid
    # span (value, log-prob) and the null log-prob
    CUDA_VISIBLE_DEVICES=<uuid> python -m deg.bench.null_margin dump --ckpt <ckpt> \\
        --label deg2b-1-step-0004000 --out results/deg/null-margin-v1
    # offline sweep -> curve + chosen margin per label
    python -m deg.bench.null_margin sweep --out results/deg/null-margin-v1
    # made-up / wrong-null rates of suite-v1 prediction dirs (before/after)
    python -m deg.bench.null_margin rates --system <suite label> [...]

Only top-level leaves are dumped (their pointer is unique per record); the margin itself
applies to every nullable/optional extract leaf, also inside array items. The dump probes
each leaf at margins -inf and +inf beside the real (margin 0) call, which decodes the same
head outputs, so the sweep is exact: at margin m a leaf is the best valid span when its
log-prob >= null + m, else null (checked against the margin-0 output for every leaf).
"""

from __future__ import annotations

import argparse
import dataclasses
import hashlib
import json
import math
from collections.abc import Iterable
from pathlib import Path
from typing import Any

OUT = Path("/shared/rwkv/results/deg/null-margin-v1")
SQUAD_DEV = Path("/data/deg/external/suite-v1/squad_v2/validation.parquet")
DATAGEN = Path("/data/deg/datagen")
DATAGEN_SOURCES = ("full-off", "extract-real-v1", "v3-long")
SQUAD_Q_PER_PARA = 4
HELDOUT_SQUAD_PARAS = 300
BIG = 1e30  # a probe margin: never null (-BIG) / always null (+BIG)
GRID = tuple(round(-3 + 0.1 * i, 2) for i in range(131))  # -3.0 .. 10.0 nats
HALLUCINATION_COST = 2.0
MAX_OMISSION = 0.5  # an operating point that drops half the answerable leaves is a collapse


def sha1(s: str) -> str:
    return hashlib.sha1(s.encode()).hexdigest()


def write_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> int:
    path.parent.mkdir(parents=True, exist_ok=True)
    n = 0
    with path.open("w", encoding="utf-8") as fh:
        for r in rows:
            fh.write(json.dumps(r, ensure_ascii=False) + "\n")
            n += 1
    return n


def read_jsonl(path: Path) -> list[dict[str, Any]]:
    return [json.loads(x) for x in path.read_text(encoding="utf-8").split("\n") if x.strip()]


# ------------------------------------------------------------------------ held-out sets


def squad_records(rows: Iterable[dict[str, Any]], exclude_contexts: set[str], guard: Any,
                  limit: int) -> tuple[list[dict[str, Any]], dict[str, int]]:
    """SQuAD v2 rows (``context, id, question, title, answers``) -> suite-format records
    (one per paragraph, up to 4 questions as nullable string fields, sha1 order), skipping
    suite paragraphs and anything the suite guard flags."""
    paras: dict[str, list[dict[str, Any]]] = {}
    for row in rows:
        paras.setdefault(row["context"], []).append(row)
    out, skipped = [], {"suite_context": 0, "guard": 0, "few_questions": 0}
    for ctx in sorted(paras, key=sha1):
        if ctx in exclude_contexts:
            skipped["suite_context"] += 1
            continue
        if guard is not None and guard.match(ctx):
            skipped["guard"] += 1
            continue
        qs = sorted(paras[ctx], key=lambda q: sha1(q["id"]))[:SQUAD_Q_PER_PARA]
        if len(qs) < 3:
            skipped["few_questions"] += 1
            continue
        props, value, alts = {}, {}, {}
        for i, q in enumerate(qs):
            key = f"q{i + 1}"
            props[key] = {"type": ["string", "null"],
                          "description": q["question"] + " (copy the answer span; null if the "
                                                         "passage does not answer it)"}
            value[key] = q["answers"][0] if q["answers"] else None
            alts[f"/{key}"] = list(q["answers"])
        out.append({"uid": "squad2-heldout:" + sha1(ctx)[:16], "context": ctx,
                    "schema": {"type": "object", "properties": props, "required": list(props),
                               "additionalProperties": False},
                    "value": value, "alternatives": alts, "domain": f"squad2/{qs[0]['title']}",
                    "question_ids": [q["id"] for q in qs]})
        if len(out) == limit:
            break
    return out, skipped


def load_squad_dev(path: Path = SQUAD_DEV) -> list[dict[str, Any]]:
    import pandas as pd

    df = pd.read_parquet(path)
    return [{"context": r.context, "id": r.id, "question": r.question, "title": r.title,
             "answers": sorted({str(a) for a in r.answers["text"]},
                               key=list(r.answers["text"]).index)}
            for r in df.itertuples()]


def nullable_top_leaves(schema: Any) -> list[str]:
    """Pointers of extract leaves outside arrays (top-level or in nested objects) that may be
    null or omitted (on_null != forbid)."""
    from deg.schema import compile_schema

    return [f.pointer for f in compile_schema(schema).fields
            if f.mode.startswith("extract.") and "*" not in f.pointer and f.on_null != "forbid"]


def datagen_records(paths: dict[str, Path], guard: Any, jev_guard: Any,
                    exclude_uids: set[str]) -> tuple[list[dict[str, Any]], dict[str, int]]:
    """``split == "val"`` records with >= 1 nullable/optional top-level extract leaf, as
    bench records (``x-generate`` fields dropped: extract outputs do not depend on them)."""
    from deg.bench.score import drop_generate

    out, skipped = [], {"guard": 0, "jev_guard": 0, "suite_uid": 0, "no_nullable_leaf": 0}
    for name, path in paths.items():
        for line in path.open(encoding="utf-8"):
            r = json.loads(line)
            if r.get("split") != "val":
                continue
            if r["uid"] in exclude_uids:
                skipped["suite_uid"] += 1
                continue
            if guard is not None and guard.match(r["context"]):
                skipped["guard"] += 1
                continue
            if jev_guard is not None and jev_guard.match(r["context"]):
                skipped["jev_guard"] += 1
                continue
            schema = drop_generate(r["schema"])
            if not nullable_top_leaves(schema):
                skipped["no_nullable_leaf"] += 1
                continue
            out.append({"uid": f"{name}:{r['uid']}", "context": r["context"], "schema": schema,
                        "value": r["value"], "domain": r.get("domain")})
    return out, skipped


def suite_uids() -> set[str]:
    from deg.bench.suite import load_source

    return {r["uid"] for r in load_source("datagen-test")}


def build(out: Path, squad_paras: int = HELDOUT_SQUAD_PARAS) -> dict[str, Any]:
    from deg.bench.jevbench import ContaminationGuard
    from deg.bench.suite import SuiteGuard, load_source

    guard, jev = SuiteGuard(), ContaminationGuard()
    suite_ctx = {r["context"] for r in load_source("squad2")}
    suite_qids = {q for r in load_source("squad2") for q in r["question_ids"]}
    squad, sq_skip = squad_records(load_squad_dev(), suite_ctx, guard, squad_paras)
    assert not {q for r in squad for q in r["question_ids"]} & suite_qids
    dg, dg_skip = datagen_records({n: DATAGEN / n / "records.jsonl" for n in DATAGEN_SOURCES},
                                  guard, jev, suite_uids())
    manifest = {"squad2-heldout": {"records": write_jsonl(out / "heldout" / "squad2.jsonl", squad),
                                   "skipped": sq_skip, "suite_guard_texts": guard.loaded,
                                   "origin": "SQuAD v2 dev paragraphs not in deg-suite v1"},
                "datagen-val": {"records": write_jsonl(out / "heldout" / "datagen-val.jsonl", dg),
                                "skipped": dg_skip,
                                "origin": "split=val records of " + ", ".join(DATAGEN_SOURCES)}}
    for name in ("squad2", "datagen-val"):
        p = out / "heldout" / f"{name}.jsonl"
        manifest["squad2-heldout" if name == "squad2" else name]["sha256"] = hashlib.sha256(
            p.read_bytes()).hexdigest()
    (out / "heldout" / "manifest.json").write_text(json.dumps(manifest, indent=1) + "\n")
    return manifest


# ------------------------------------------------------------------------------- dump


class LeafProbe:
    """Wraps :func:`deg.infer.extract_result`: beside the real call, records for each
    top-level nullable/optional leaf the best valid span (margin -inf) and the null
    outcome (margin +inf)."""

    def __init__(self) -> None:
        self.rows: list[dict[str, Any]] = []
        self._orig: Any = None

    def __enter__(self) -> LeafProbe:
        from deg import infer

        self._orig = orig = infer.extract_result

        def probed(fld: Any, ctx_text: str, ctx: Any, start: Any, end: Any, temperature: Any,
                   options: Any) -> Any:
            res = orig(fld, ctx_text, ctx, start, end, temperature, options)
            if fld.on_null != "forbid" and "*" not in fld.pointer:
                best = orig(fld, ctx_text, ctx, start, end, temperature,
                            dataclasses.replace(options, null_margin=-BIG))
                null = orig(fld, ctx_text, ctx, start, end, temperature,
                            dataclasses.replace(options, null_margin=BIG))
                span_lp = (_log(best.meta["confidence"]) if best.value is not None
                           else -math.inf)
                self.rows.append({"ptr": fld.pointer, "value": res.value,
                                  "span_value": best.value, "span_lp": span_lp,
                                  "null_lp": _log(null.meta["confidence"])})
            return res

        infer.extract_result = probed
        return self

    def __exit__(self, *exc: Any) -> None:
        from deg import infer

        infer.extract_result = self._orig


def _log(p: float) -> float:
    return math.log(p) if p > 0 else -math.inf


def leaf_gold(rec: dict[str, Any], ptr: str) -> tuple[str, list[Any]]:
    """("unans", []) when the gold is null/absent, else ("ans", accepted answers)."""
    if "alternatives" in rec and ptr in rec["alternatives"]:
        alts = rec["alternatives"][ptr]
        return ("ans", alts) if alts else ("unans", [])
    gold: Any = rec["value"]
    for part in ptr.split("/")[1:]:  # nested object leaves (no arrays: top-level dumps)
        key = part.replace("~1", "/").replace("~0", "~")
        gold = gold.get(key) if isinstance(gold, dict) else None
    return ("unans", []) if gold is None else ("ans", [gold])


def dump(ckpt: str, label: str, out: Path, sets: list[str]) -> None:
    import torch

    from deg.infer import fill
    from deg.model import load_model
    from deg.request import FillOptions

    model = load_model(Path(ckpt), device="cuda")
    for name in sets:
        recs = read_jsonl(out / "heldout" / f"{name}.jsonl")
        path = out / "dumps" / label / f"{name}.jsonl"
        path.parent.mkdir(parents=True, exist_ok=True)
        done = {r["uid"] for r in read_jsonl(path)} if path.exists() else set()
        with path.open("a", encoding="utf-8") as fh, LeafProbe() as probe:
            for i, rec in enumerate(recs):
                if rec["uid"] in done:
                    continue
                probe.rows = []
                err = None
                try:
                    fill(model, rec["context"], rec["schema"], FillOptions())
                except Exception as exc:  # noqa: BLE001 - recorded, excluded from the sweep
                    err = f"{type(exc).__name__}: {str(exc)[:160]}"
                    torch.cuda.empty_cache()
                leaves = []
                for row in probe.rows:
                    kind, alts = leaf_gold(rec, row["ptr"])
                    leaves.append({**row, "gold": kind, "alts": alts})
                fh.write(json.dumps({"uid": rec["uid"], "error": err, "leaves": leaves},
                                    ensure_ascii=False) + "\n")
                fh.flush()
                if (i + 1) % 50 == 0:
                    print(json.dumps({"label": label, "set": name, "done": i + 1,
                                      "of": len(recs)}), flush=True)
    print(json.dumps({"label": label, "dumped": sets, "ckpt": ckpt}), flush=True)


# ------------------------------------------------------------------------------ sweep


def is_null(leaf: dict[str, Any], m: float) -> bool:
    return leaf["span_value"] is None or leaf["span_lp"] < leaf["null_lp"] + m


def matches(value: Any, alts: list[Any]) -> bool:
    from deg.bench.score import normalize

    return value is not None and normalize(value) in {normalize(a) for a in alts}


def point(leaves: list[dict[str, Any]], m: float) -> dict[str, Any]:
    ans = [x for x in leaves if x["gold"] == "ans"]
    un = [x for x in leaves if x["gold"] == "unans"]
    omit = sum(is_null(x, m) for x in ans)
    right = sum(not is_null(x, m) and matches(x["span_value"], x["alts"]) for x in ans)
    made_up = sum(not is_null(x, m) for x in un)
    mu = made_up / len(un) if un else 0.0
    om = omit / len(ans) if ans else 0.0
    return {"margin": m, "answerable_n": len(ans), "unanswerable_n": len(un),
            "made_up_rate": mu, "omission_rate": om,
            "answerable_correct": right / len(ans) if ans else None,
            "answerable_wrong_span": (len(ans) - omit - right) / len(ans) if ans else None,
            "unanswerable_null": 1 - mu,
            # suite-style balanced score (squad2 component definition)
            "balanced": ((right / len(ans) if ans else 0.0) + (1 - mu)) / 2,
            "cost": HALLUCINATION_COST * mu + om}


def check_margin0(leaves: list[dict[str, Any]]) -> int:
    """Leaves whose offline margin-0 decision differs from the real margin-0 output (0 =
    the sweep reproduces inference exactly)."""
    return sum((x["value"] is None) != is_null(x, 0.0)
               or (x["value"] is not None and x["value"] != x["span_value"]) for x in leaves)


def choose(curve: list[dict[str, Any]]) -> dict[str, Any]:
    """Min cost (the mean of the per-set costs) among non-collapsed points (omission <=
    MAX_OMISSION on every set); ties -> the margin closest to 0."""
    ok = [p for p in curve if all(s["omission_rate"] <= MAX_OMISSION for s in p["sets"].values())]
    return min(ok, key=lambda p: (round(p["cost"], 9), abs(p["margin"])))


def regold(rows: list[dict[str, Any]], heldout: Path) -> list[dict[str, Any]]:
    """The leaves of error-free dump rows, gold re-derived from the held-out records (the
    dump's own gold fields are not trusted: the sweep always uses :func:`leaf_gold`)."""
    recs = {r["uid"]: r for r in read_jsonl(heldout)}
    out = []
    for r in rows:
        if r["error"] is not None:
            continue
        for leaf in r["leaves"]:
            kind, alts = leaf_gold(recs[r["uid"]], leaf["ptr"])
            out.append({**leaf, "gold": kind, "alts": alts})
    return out


def sweep(out: Path, labels: list[str] | None = None) -> dict[str, Any]:
    result: dict[str, Any] = {"rule": f"cost = {HALLUCINATION_COST:g}*made_up_rate(unanswerable)"
                                      " + omission_rate(answerable), averaged over the held-out"
                                      " sets; omission <= "
                                      f"{MAX_OMISSION:g} on every set (no always-null collapse)",
                              "models": {}}
    for d in sorted((out / "dumps").iterdir()):
        if labels and d.name not in labels:
            continue
        sets = {p.stem: regold(read_jsonl(p), out / "heldout" / p.name)
                for p in sorted(d.glob("*.jsonl"))}
        curve = []
        for m in GRID:
            per = {k: point(v, m) for k, v in sets.items()}
            curve.append({"margin": m, "cost": sum(s["cost"] for s in per.values()) / len(per),
                          "sets": per})
        best = choose(curve)
        zero = next(p for p in curve if p["margin"] == 0)
        result["models"][d.name] = {
            "chosen_margin": best["margin"], "chosen": best, "margin0": zero,
            "margin0_mismatch": {k: check_margin0(v) for k, v in sets.items()},
            "errors": {p.stem: sum(r["error"] is not None for r in read_jsonl(p))
                       for p in sorted(d.glob("*.jsonl"))},
            "curve": curve}
    (out / "sweep.json").write_text(json.dumps(result, indent=1) + "\n")
    (out / "curve.md").write_text(curve_md(result))
    return result


def curve_md(result: dict[str, Any]) -> str:
    lines = ["# null-margin curve (held-out)", "", result["rule"], ""]
    for label, m in result["models"].items():
        names = list(m["chosen"]["sets"])
        lines += [f"## {label}", "", f"chosen margin **{m['chosen_margin']:g}** nats "
                  f"(margin-0 reproduction mismatches: {m['margin0_mismatch']}; "
                  f"errors: {m['errors']})", "",
                  "| margin | cost | " + " | ".join(
                      f"{n} made-up | {n} omit | {n} ans-correct | {n} balanced"
                      for n in names) + " |",
                  "|---|---|" + "---|" * (4 * len(names))]
        for p in m["curve"]:
            if round(p["margin"] * 10) % 5 and p["margin"] != m["chosen_margin"]:
                continue  # every 0.5 nats + the chosen point
            mark = " **<-**" if p["margin"] == m["chosen_margin"] else ""
            lines.append(f"| {p['margin']:g}{mark} | {p['cost']:.3f} | " + " | ".join(
                f"{s['made_up_rate']:.3f} | {s['omission_rate']:.3f} | "
                f"{s['answerable_correct']:.3f} | {s['balanced']:.3f}"
                for s in p["sets"].values()) + " |")
        lines.append("")
    return "\n".join(lines)


# ------------------------------------------------------------------------ suite rates


def extract_null_rates(recs: list[dict[str, Any]], preds: dict[str, Any]) -> dict[str, Any]:
    """Made-up (gold null/absent, prediction non-null) and wrong-null (gold non-null,
    prediction null/absent) rates over the extract leaves of suite records. A record with an
    error counts every leaf as null (its value is missing)."""
    from deg.evaluate import _abstract, _flatten
    from deg.schema import compile_schema

    ans = un = made_up = wrong_null = 0
    for rec in recs:
        p = preds.get(rec["uid"])
        got = _flatten(p.value) if p is not None and p.value is not None else {}
        fields = {f.pointer: f for f in compile_schema(rec["schema"]).fields}
        gold = _flatten(rec["value"])
        if "alternatives" in rec:
            gold = {ptr: (alts[0] if alts else None) for ptr, alts in rec["alternatives"].items()}
        ptrs = set(gold) | {f.pointer for f in fields.values()
                            if "*" not in f.pointer and f.mode.startswith("extract.")}
        for ptr in ptrs:
            if ptr.endswith("#len"):
                continue
            f = fields.get(_abstract(ptr))
            if f is None or not f.mode.startswith("extract.") or f.on_null == "forbid":
                continue
            gv, pv = gold.get(ptr), got.get(ptr)
            if gv is None:
                un += 1
                made_up += pv is not None
            else:
                ans += 1
                wrong_null += pv is None
    return {"unanswerable_n": un, "answerable_n": ans,
            "made_up_rate": made_up / un if un else None,
            "wrong_null_rate": wrong_null / ans if ans else None}


def suite_rates(system: str, results: Path | None = None,
                sources: tuple[str, ...] = ("datagen-test", "squad2", "mixed")) -> dict[str, Any]:
    from deg.bench.run import load_preds
    from deg.bench.suite import RESULTS, load_source, system_dir

    sdir = system_dir(results or RESULTS, system)
    out = {}
    for src in sources:
        path = sdir / f"{src}.preds.jsonl"
        if path.exists():
            out[src] = extract_null_rates(load_source(src), load_preds(path, keep_errors=True))
    return out


def main(argv: list[str] | None = None) -> None:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    sub = ap.add_subparsers(dest="cmd", required=True)
    b = sub.add_parser("build")
    b.add_argument("--out", type=Path, default=OUT)
    b.add_argument("--squad-paras", type=int, default=HELDOUT_SQUAD_PARAS)
    d = sub.add_parser("dump")
    d.add_argument("--ckpt", required=True)
    d.add_argument("--label", required=True)
    d.add_argument("--out", type=Path, default=OUT)
    d.add_argument("--sets", nargs="*", default=["squad2", "datagen-val"])
    d.add_argument("--max-gpu-gb", type=float, default=None)
    s = sub.add_parser("sweep")
    s.add_argument("--out", type=Path, default=OUT)
    s.add_argument("--labels", nargs="*", default=None)
    r = sub.add_parser("rates")
    r.add_argument("--system", nargs="+", required=True)
    r.add_argument("--results", type=Path, default=None)
    args = ap.parse_args(argv)
    if args.cmd == "build":
        print(json.dumps(build(args.out, args.squad_paras), indent=1))
    elif args.cmd == "dump":
        if args.max_gpu_gb:
            import torch
            total = torch.cuda.get_device_properties(0).total_memory
            torch.cuda.set_per_process_memory_fraction(min(1.0, args.max_gpu_gb * 2**30 / total))
        dump(args.ckpt, args.label, args.out, args.sets)
    elif args.cmd == "sweep":
        res = sweep(args.out, args.labels)
        print(json.dumps({k: {"chosen_margin": v["chosen_margin"],
                              "cost": v["chosen"]["cost"], "cost0": v["margin0"]["cost"],
                              "mismatch": v["margin0_mismatch"]}
                          for k, v in res["models"].items()}, indent=1))
    else:
        print(json.dumps({s: suite_rates(s, args.results) for s in args.system}, indent=1))


if __name__ == "__main__":
    main()