File size: 21,946 Bytes
11ecc5b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""
WHY DID THE READER SCORE F1 0.36 / EM 0.004?

There are exactly three ways an extractive reader can fail, and they demand
three completely different fixes. Reporting one aggregate F1 cannot tell them
apart, so this script separates them:

  1. THE SPAN DOES NOT EXIST.
     The translated answer is a paraphrase, not a quote. No extractor can win.
     -> the architecture must generate, not extract. Retrain the plan, not the model.

  2. THE SPAN EXISTS BUT IN A PASSAGE THE READER DID NOT PICK.
     -> fix passage selection (retrieval / reranking), leave the span scorer alone.

  3. THE SPAN EXISTS IN THE PASSAGE THE READER PICKED, AND IT PICKED THE WRONG
     WORDS.
     -> fix the span scorer. This is the one a trained neural reader repairs.

THE LADDER (each rung is an upper bound on the one below it)

    oracle_any      best token-F1 over every contiguous span in ANY passage
    oracle_picked   ... restricted to the passage the reader actually chose
    reader          what the reader returned

    oracle_any - oracle_picked  = passage-selection loss   (failure mode 2)
    oracle_picked - reader      = span-selection loss      (failure mode 3)
    1 - oracle_any              = irreducible / abstractive (failure mode 1)

Run it on a few languages first; the span enumeration is O(words x span_len).

  python src/diagnose_reader.py --per-lang 400 --langs hi,ta,bn
  python src/diagnose_reader.py --per-lang 300          # all 14
"""
from __future__ import annotations

import argparse
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.evaluate_reader import iter_eval_rows, token_f1  # noqa: E402
from src.extractability import normalise  # noqa: E402
from src.chunkers.base import split_sentences, words_of  # noqa: E402
from src.reader import LexicalSpanReader  # noqa: E402
from src.router import Passage  # noqa: E402
from src.schema_utils import LANG_NAMES, default_root  # noqa: E402


# ------------------------------------------------------------------ oracle

def best_span_f1(passage: str, gold_tokens: list[str], max_extra: int = 8) -> tuple[float, str]:
    """
    Highest token-F1 achievable by ANY contiguous word span of `passage`, plus
    the span itself.

    Computed with an incremental overlap counter rather than by scoring every
    span from scratch: for a fixed start, extending the window by one word
    changes the overlap by at most one, so the whole sweep is O(W * L) cheap
    increments instead of O(W * L * |gold|) Counter intersections.

    max_extra bounds the window at |gold| + max_extra words. A span much longer
    than the answer cannot beat a tighter one -- precision falls faster than
    recall rises -- so the bound costs nothing and saves the quadratic term.
    """
    words = normalise(passage, True).split()
    if not words or not gold_tokens:
        return 0.0, ""
    need = Counter(gold_tokens)
    G = len(gold_tokens)
    limit = min(len(words), G + max_extra)

    best, best_at = 0.0, (0, 0)
    for i in range(len(words)):
        have: Counter = Counter()
        same = 0
        hi = min(i + limit, len(words))
        for j in range(i, hi):
            w = words[j]
            have[w] += 1
            if have[w] <= need.get(w, 0):
                same += 1
            if same:
                n = j - i + 1
                f1 = 2 * same / (n + G)          # == 2PR/(P+R) with P=same/n, R=same/G
                if f1 > best:
                    best, best_at = f1, (i, j + 1)
                    if best >= 1.0:
                        return 1.0, " ".join(words[i:j + 1])
    return best, " ".join(words[best_at[0]:best_at[1]])


# ------------------------------------------------------------------ run

def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--root", type=Path, default=None)
    ap.add_argument("--langs", default=None)
    ap.add_argument("--per-lang", type=int, default=400)
    ap.add_argument("--examples", type=int, default=6)
    ap.add_argument("--prior-weight", type=float, default=0.0,
                    help="how hard to trust Passage.score when choosing a sentence")
    ap.add_argument("--ablate", action="store_true",
                    help="sweep prior-weight instead of running one config")
    ap.add_argument("--real-scores", action="store_true",
                    help="re-score passages with bge-m3 instead of the gold label "
                         "(REQUIRED for an honest prior ablation -- see below)")
    ap.add_argument("--model", default=None)
    ap.add_argument("--answer-mode", default="span", choices=["span", "sentence", "both"],
                    help="'span' trims inside the winning sentence; 'sentence' returns "
                         "the whole sentence. Gold answers average 19.2 words -- about a "
                         "sentence -- so trimming may be costing F1. 'both' measures it.")
    args = ap.parse_args()

    root = args.root.expanduser().resolve() if args.root else default_root()
    langs = set(args.langs.split(",")) if args.langs else None
    print(f"==> data root: {root}")

    # ---------------------------------------------------------- ablation
    # The retrieval prior is either worth using or it is not; guessing which is
    # how the last two days got spent. Sweep it once, on cached rows, and read
    # the answer off the table.
    if args.ablate:
        rows = list(iter_eval_rows(root, langs, args.per_lang))
        rows = [r for r in rows if r[4]]                  # answerable only

        # ------------------------------------------------------------------
        # WHY --real-scores IS NOT OPTIONAL FOR A HONEST NUMBER
        #
        # iter_eval_rows builds Passage(score = 1.0 if is_selected else 0.5).
        # That score IS THE GOLD LABEL. Feeding it to the reader as a "retrieval
        # prior" and reporting the improvement would be measuring an ORACLE
        # reranker -- a number no deployed system can reach, and exactly the
        # kind of leak that makes a leaderboard entry collapse on the held-out
        # set.
        #
        # With the gold label     -> the ablation is an UPPER BOUND (headroom).
        # With --real-scores      -> bge-m3 cosine, i.e. what the pipeline
        #                            actually produces. That is the number to
        #                            build on.
        # Run both. The gap between them is what better reranking is worth.
        # ------------------------------------------------------------------
        if args.real_scores:
            import torch
            from src.evaluate_retrieval import Embedder
            model = args.model
            if model is None:
                hits = list((root / "hf_cache" / "hub").glob("models--BAAI--bge-m3/snapshots/*"))
                model = str(hits[0]) if hits else "BAAI/bge-m3"
            device = "cuda" if torch.cuda.is_available() else "cpu"
            print(f"==> re-scoring passages with {model} on {device}")
            emb = Embedder(model, device, batch=64, max_len=192)
            rescored = []
            for lang, qid, q, gold, a, psgs in rows:
                qv = emb.encode([q])
                pv = emb.encode([p.text for p in psgs])
                sims = (pv @ qv.T).squeeze(-1).float().tolist()
                rescored.append((lang, qid, q, gold, a,
                                 [Passage(p.chunk_id, p.text, float(s), p.lang)
                                  for p, s in zip(psgs, sims)]))
            rows = rescored
            print(f"==> scores are bge-m3 cosine (REAL). "
                  f"Numbers below are achievable, not an oracle.")
        else:
            print("\n  !! Passage.score here is the GOLD LABEL, not a retrieval score.")
            print("     These numbers are an UPPER BOUND on what reranking can buy.")
            print("     Re-run with --real-scores for the achievable figure.\n")

        print(f"\n==> ablating prior_weight over {len(rows):,} answerable queries")
        print(f"\n  {'prior_w':>9}{'reader F1':>12}{'exact match':>13}"
              f"{'read gold psg':>15}{'span len':>10}")
        print("  " + "-" * 57)
        modes = (["span", "sentence"] if args.answer_mode == "both"
                 else [args.answer_mode])
        modes = ["span", "sentence"] if args.answer_mode == "both" else [args.answer_mode]
        best = {}
        for mode in modes:
            if len(modes) > 1:
                print(f"\n  --- answer_mode={mode} ---")
                print(f"  {'prior_w':>9}{'reader F1':>12}{'exact match':>13}"
                      f"{'read gold psg':>15}{'span len':>10}")
                print("  " + "-" * 57)
            best_w, best_f1, base_f1 = 0.0, -1.0, None
            for w in (0.0, 0.25, 0.50, 0.75, 1.0):
                rd = LexicalSpanReader(prior_weight=w, answer_mode=mode)
                f1s = em = gold_hit = ratio = 0.0
                for lang, qid, q, gold, _a, psgs in rows:
                    gt = normalise(gold, True).split()
                    if not gt:
                        continue
                    sp = rd.read(q, psgs)
                    f = token_f1(sp.text, gold)
                    f1s += f
                    em += float(f >= 0.999)
                    top = max(psgs, key=lambda p: p.score)
                    gold_hit += float(sp.chunk_id == top.chunk_id)
                    ratio += len(normalise(sp.text, True).split()) / max(1, len(gt))
                n_r = max(1, len(rows))
                print(f"  {w:>9.2f}{f1s/n_r:>12.4f}{em/n_r:>13.4f}"
                      f"{gold_hit/n_r:>14.1%}{ratio/n_r:>10.2f}x")
                if base_f1 is None:
                    base_f1 = f1s / n_r          # the w=0 row of THIS run
                if f1s / n_r > best_f1:
                    best_w, best_f1 = w, f1s / n_r
            # Compare against the w=0 row of the SAME subset. A hardcoded
            # all-language baseline mixes populations and overstates the gain.
            print(f"\n  best prior_weight = {best_w:.2f}  (reader F1 {best_f1:.4f}, "
                  f"{100*(best_f1/max(1e-9, base_f1)-1):+.0f}% vs prior_weight 0 "
                  f"on these same {len(rows):,} queries = {base_f1:.4f})")
            best[mode] = (best_w, best_f1)

        if len(best) > 1:
            # Gold answers average 19.2 words -- about a sentence. Trimming to a
            # sub-span may be cutting sentences down to fragments to match
            # targets that are sentences. This is the line that settles it.
            sp_f1, se_f1 = best["span"][1], best["sentence"][1]
            print(f"\n  {'='*56}")
            print(f"  span     best F1 {sp_f1:.4f} @ prior {best['span'][0]:.2f}")
            print(f"  sentence best F1 {se_f1:.4f} @ prior {best['sentence'][0]:.2f}")
            win = "sentence" if se_f1 > sp_f1 else "span"
            print(f"  -> {win} wins by {abs(se_f1-sp_f1):.4f} F1")
            if win == "span":
                print("     NOTE: serving still uses answer_mode=sentence. A trimmed span")
                print("     starts mid-clause and stops mid-number; through TTS that is")
                print("     unusable regardless of F1. Report BOTH numbers.")
        print("\n  'read gold psg' = share of queries where the reader read the")
        print("  top-RETRIEVED passage. At prior_weight 0 this is what the reader")
        print("  happened to agree with; at 1.0 it is forced.")
        return 0

    mode = "span" if args.answer_mode == "both" else args.answer_mode
    reader = LexicalSpanReader(prior_weight=args.prior_weight, answer_mode=mode)
    print(f"==> reader   : {reader.name}  (prior_weight {args.prior_weight}, "
          f"answer_mode {mode})\n")

    agg: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
    examples: list[dict] = []
    n_total = 0

    for lang, qid, q, gold, answerable, psgs in iter_eval_rows(root, langs, args.per_lang):
        if not answerable:
            continue
        gold_tokens = normalise(gold, True).split()
        if not gold_tokens:
            continue

        span = reader.read(q, psgs)
        r_f1 = token_f1(span.text, gold)

        # oracle over every passage, and over the one the reader chose
        o_any, o_any_text = 0.0, ""
        o_picked = 0.0
        picked_id = span.chunk_id
        matched = False
        picked_psg = None
        for p in psgs:
            f1, txt = best_span_f1(p.text, gold_tokens)
            if f1 > o_any:
                o_any, o_any_text = f1, txt
            if p.chunk_id == picked_id:
                matched = True
                picked_psg = p
                o_picked = max(o_picked, f1)
        if not matched:          # reader abstained or returned an unattributed span
            o_picked = o_any

        # Split the span-selection loss one level further. "Wrong words" is two
        # different bugs wearing one number:
        #   picked the wrong SENTENCE   -> a ranking problem (query overlap picks
        #                                  the sentence that restates the question)
        #   right sentence, wrong CUT   -> a boundary problem (trim/extend)
        # Only the second is cheap. Measuring which one dominates costs one
        # best_span_f1 call over ~40 words.
        o_sent = o_picked
        if picked_psg is not None and span.start >= 0:
            sent_text, cursor = None, 0
            for sent in split_sentences(picked_psg.text):
                n = len(words_of(sent))
                if n and cursor <= span.start < cursor + n:
                    sent_text = sent
                    break
                cursor += n
            if sent_text:
                o_sent = best_span_f1(sent_text, gold_tokens)[0]

        a = agg[lang]
        a["n"] += 1
        a["reader_f1"] += r_f1
        a["oracle_picked"] += o_picked
        a["oracle_any"] += o_any
        a["oracle_sent"] += o_sent
        a["oracle_em"] += float(o_any >= 0.999)
        a["reader_em"] += float(r_f1 >= 0.999)
        # Span-length ratio separates "found the wrong place" from "found the
        # right place and did not know where to stop". A reader that returns
        # whole sentences sits at 3-5x with decent recall and dead precision --
        # which is EM ~0 with F1 ~0.4, exactly the shape we saw.
        n_pred, n_gold = len(normalise(span.text, True).split()), len(gold_tokens)
        a["len_ratio"] += n_pred / max(1, n_gold)
        a["pred_words"] += n_pred
        a["gold_words"] += n_gold
        n_total += 1

        if len(examples) < args.examples and o_any - r_f1 > 0.4:
            examples.append({"lang": lang, "query": q[:90], "gold": gold[:90],
                             "reader": span.text[:90], "oracle": o_any_text[:90],
                             "reader_f1": round(r_f1, 3), "oracle_f1": round(o_any, 3)})

    if not n_total:
        raise SystemExit("no answerable rows — check --langs / --root")

    # ---------------------------------------------------------- report
    W = 78
    print(f"\n{'='*W}\nPER-LANGUAGE LADDER  (answerable queries only)\n{'='*W}")
    print(f"  {'lang':6s}{'n':>7}{'reader':>9}{'oracle@':>9}{'oracle':>9}"
          f"{'passage':>10}{'span':>9}{'absent':>9}")
    print(f"  {'':6s}{'':>7}{'F1':>9}{'picked':>9}{'any':>9}{'loss':>10}{'loss':>9}{'':>9}")
    print("  " + "-" * (W - 2))

    tot = defaultdict(float)
    for lang in sorted(agg):
        a = agg[lang]
        n = a["n"]
        rf, op, oa = a["reader_f1"] / n, a["oracle_picked"] / n, a["oracle_any"] / n
        print(f"  {lang:6s}{int(n):>7,}{rf:>9.3f}{op:>9.3f}{oa:>9.3f}"
              f"{oa-op:>10.3f}{op-rf:>9.3f}{1-oa:>9.3f}")
        for k in ("n", "reader_f1", "oracle_picked", "oracle_sent", "oracle_any", "oracle_em",
                  "reader_em", "len_ratio", "pred_words", "gold_words"):
            tot[k] += a[k]

    n = tot["n"]
    rf, op, oa = tot["reader_f1"] / n, tot["oracle_picked"] / n, tot["oracle_any"] / n
    o_em, r_em = tot["oracle_em"] / n, tot["reader_em"] / n
    print("  " + "-" * (W - 2))
    print(f"  {'ALL':6s}{int(n):>7,}{rf:>9.3f}{op:>9.3f}{oa:>9.3f}"
          f"{oa-op:>10.3f}{op-rf:>9.3f}{1-oa:>9.3f}")

    os_ = tot["oracle_sent"] / n
    print(f"\n{'='*W}\nWHERE THE F1 GOES\n{'='*W}")
    absent, psg_loss, span_loss = 1 - oa, oa - op, op - rf
    total_loss = max(1e-9, absent + psg_loss + span_loss)
    for label, val, fix in (
        ("answer not in ANY passage (abstractive)", absent,
         "no extractor can fix this -- must generate"),
        ("in a passage the reader did not pick", psg_loss,
         "fix passage selection / reranking"),
        ("in the picked passage, wrong words", span_loss,
         "fix the span scorer -- a trained reader repairs this"),
    ):
        print(f"  {label:42s} {val:6.3f}   {100*val/total_loss:5.1f}% of the loss")
        print(f"  {'':42s} -> {fix}")

    # Split the "wrong words" bar into its two very different halves.
    sent_loss, bound_loss = op - os_, os_ - rf
    sub_tot = max(1e-9, sent_loss + bound_loss)
    print(f"\n  ...and 'wrong words' ({span_loss:.3f}) splits into:")
    print(f"  {'wrong SENTENCE in the right passage':42s} {sent_loss:6.3f}   "
          f"{100*sent_loss/sub_tot:5.1f}% of it")
    print(f"  {'':42s} -> ranking: query overlap picks the sentence that")
    print(f"  {'':42s}    RESTATES the question, not the one that answers it")
    print(f"  {'right sentence, wrong cut':42s} {bound_loss:6.3f}   "
          f"{100*bound_loss/sub_tot:5.1f}% of it")
    print(f"  {'':42s} -> boundary: trim/extend inside the sentence. CHEAP.")

    print(f"\n  exact-match ceiling (oracle) : {o_em:.3f}")
    print(f"  exact-match achieved (reader): {r_em:.3f}")
    if o_em > 0:
        print(f"  reader captures {100*r_em/o_em:.1f}% of the achievable exact matches")

    ratio = tot["len_ratio"] / n
    print(f"\n  span length vs gold : {ratio:.2f}x  "
          f"({tot['pred_words']/n:.1f} words returned vs {tot['gold_words']/n:.1f} in the answer)")
    if ratio > 2.0:
        print("  ^ THE READER IS RETURNING SENTENCES, NOT SPANS. Precision is capped at\n"
              f"    ~{1/ratio:.2f} before the span is even in the right place, and exact match\n"
              "    is structurally near zero. Fix the STOP boundary, not the search.")
    elif ratio < 0.6:
        print("  ^ spans are TRUNCATED -- the scorer stops too early and drops recall.")

    print(f"\n{'='*W}\nVERDICT\n{'='*W}")
    bars = {"absent (must generate)": absent,
            "wrong passage (rerank)": psg_loss,
            "wrong sentence (ranking)": sent_loss,
            "wrong cut (boundary)": bound_loss}
    top = max(bars, key=bars.get)
    fixable = {k: v for k, v in bars.items() if not k.startswith("absent")}
    top_fix = max(fixable, key=fixable.get)
    spread = max(fixable.values()) - min(fixable.values())

    print(f"  largest bar overall  : {top} ({bars[top]:.3f})")
    print(f"  largest FIXABLE bar  : {top_fix} ({fixable[top_fix]:.3f})")

    if absent >= max(fixable.values()):
        print(f"\n  THE BIGGEST SINGLE LOSS IS UNFIXABLE BY EXTRACTION ({absent:.3f}).")
        print(f"  The extraction ceiling is {oa:.3f} and no reader beats it. Before")
        print("  spending days on any bar below, decide whether the answering path")
        print("  should be extractive at all.")
    if spread < 0.06:
        print(f"\n  The three fixable bars are within {spread:.3f} of each other -- there is")
        print("  NO dominant fix. Sequential work on any one of them buys little on its")
        print("  own. Treat this as a signal to change the approach, not to grind.")
    else:
        print(f"\n  -> work {top_fix} first ({fixable[top_fix]:.3f}).")

    if examples:
        print(f"\n{'='*W}\nEXAMPLES WHERE THE ORACLE BEATS THE READER BY >0.4 F1\n{'='*W}")
        for e in examples:
            print(f"\n  [{e['lang']}]  reader {e['reader_f1']}  oracle {e['oracle_f1']}")
            print(f"    Q      : {e['query']}")
            print(f"    gold   : {e['gold']}")
            print(f"    reader : {e['reader']}")
            print(f"    oracle : {e['oracle']}")

    out = root / "results" / "reader_diagnosis.json"
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps({
        "n_answerable": int(n),
        "per_language": {
            lg: {"n": int(a["n"]),
                 "reader_f1": round(a["reader_f1"] / a["n"], 4),
                 "oracle_picked": round(a["oracle_picked"] / a["n"], 4),
                 "oracle_sent": round(a["oracle_sent"] / a["n"], 4),
                 "oracle_any": round(a["oracle_any"] / a["n"], 4),
                 "oracle_em": round(a["oracle_em"] / a["n"], 4),
                 "language": LANG_NAMES.get(lg, lg)}
            for lg, a in agg.items()},
        "overall": {"reader_f1": round(rf, 4), "oracle_picked": round(op, 4),
                    "oracle_sent": round(os_, 4),
                    "oracle_any": round(oa, 4), "oracle_em": round(o_em, 4),
                    "reader_em": round(r_em, 4),
                    "span_len_ratio": round(tot["len_ratio"] / n, 3),
                    "mean_pred_words": round(tot["pred_words"] / n, 2),
                    "mean_gold_words": round(tot["gold_words"] / n, 2)},
        "loss_decomposition": {"absent": round(absent, 4),
                               "passage_selection": round(psg_loss, 4),
                               "span_selection": round(span_loss, 4),
                               "sentence_selection": round(sent_loss, 4),
                               "boundary": round(bound_loss, 4)},
        "examples": examples,
    }, indent=2, ensure_ascii=False))
    print(f"\n==> wrote {out}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())