File size: 23,692 Bytes
e55c8a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python
"""
Produce the shared-task submission: predictions.zip containing predictions.jsonl.

Required line format (one JSON object per question):
    {"q_no":1,"subject":"Arts","category":"humanities","level":"easy","answer":"2"}

`answer` is a STRING holding the 1-based option number.

    python make_predictions.py \
        --data /workspace/data/TEST \
        --model /workspace/outputs/mcq_sft_base_v2/best_adapter \
        --out predictions.jsonl --zip predictions.zip

--data may be either a single folder of *.json files, or a root containing easy/ medium/ hard/
subfolders; the level comes from metadata.difficulty, falling back to the folder name.

Scoring: for every option, score the joint tokenisation of prompt+" N" and take the highest total
log-probability ("loglik"). This is deterministic, always yields a valid 1..N answer, and measured
highest on TEST/easy. Because the task is submitted as PREDICTIONS, we are free to choose the
decoding method - we are not restricted to the organisers' bare-digit logit comparison.

Reproducibility: the organisers re-run submitted code on their own hardware and only verified runs
enter the final ranking. Nothing here samples; seeds are fixed and generation is not used. Keep this
file, the adapter/model reference and the prompt builder together in the submitted code.
"""
import argparse
import glob
import json
import os
import re
import random
import sys
import time
import zipfile

os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")

# --- the organisers' prompt, verbatim from their util_prompt.py -------------------------------
SINHALA_INSTRUCTIONS = {
    "easy": "පහත ප්‍රශ්නයට 1, 2, 3, 4 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරන්න.\n\n",
    "medium": "පහත ප්‍රශ්නයට 1, 2, 3, 4  යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරාගන්න.\n\n",
    "hard": "පහත ප්‍රශ්නයට 1, 2, 3, 4, 5 යන පිළිතුරුවලින් නිවැරදි හෝ ඉතාමත් ගැළපෙන පිළිතුර තෝරාගන්න.\n\n",
}
ENGLISH_INSTRUCTIONS = {
    "easy": "Choose the correct or most appropriate answer from 1, 2, 3, 4 for the question below.\n\n",
    "medium": "Choose the correct or most appropriate answer from 1, 2, 3, 4 for the question below.\n\n",
    "hard": "Select the correct answer from 1, 2, 3, 4, 5 for the given question.\n\n",
}
LEVELS = ("easy", "medium", "hard")

# Prompt variants to ensemble over. Each is a real configuration the organisers' harness supports
# (sinhala/english x intro true/false), plus option rotations to cancel answer-position bias.
# "rot" rotates the DISPLAYED option order; predictions are mapped back to the original indices.
VARIANTS = {
    "si_intro":    {"lang": "si", "intro": True,  "rot": 0},
    "si_nointro":  {"lang": "si", "intro": False, "rot": 0},
    "en_intro":    {"lang": "en", "intro": True,  "rot": 0},
    "si_rot1":     {"lang": "si", "intro": True,  "rot": 1},
    "si_rot2":     {"lang": "si", "intro": True,  "rot": 2},
    "si_rot3":     {"lang": "si", "intro": True,  "rot": 3},
    "en_nointro":  {"lang": "en", "intro": False, "rot": 0},
    "en_rot1":     {"lang": "en", "intro": True,  "rot": 1},
    "si_nointro_r2": {"lang": "si", "intro": False, "rot": 2},
}
DEFAULT_ENSEMBLE = ["si_intro", "si_nointro", "en_intro", "si_rot1", "si_rot2"]


def fit_numbers(instruction, n):
    """The organisers hard-code "1, 2, 3, 4, 5" in the HARD instruction because the original
    SinhalaMMLU hard split had five options. SinhalaMMLU 2.0 dropped one incorrect option, so hard
    questions now have four. Telling the model to choose from five options when four are shown is
    strictly worse, so the number run is rewritten to match the actual count."""
    return re.sub(r"1,\s*2,\s*3,\s*4(?:,\s*5)?",
                  ", ".join(str(i + 1) for i in range(n)), instruction, count=1)


def official_prompt(level, subject_original, question, choices, intro_type=True):
    intro = f"මෙය {subject_original} විෂයයට අදාළ බහුවරණ ප්‍රශ්නයකි.\n" if intro_type else ""
    body = "\n".join(f"{i + 1}. {str(c).strip()}" for i, c in enumerate(choices))
    instr = fit_numbers(SINHALA_INSTRUCTIONS[level], len(choices))
    return intro + instr + f"ප්‍රශ්නය: {question}\n{body}\nපිළිතුර:"


def english_prompt(level, subject, question, choices, intro_type=True):
    """The organisers' prompt with language="english" (their english_intro uses the ENGLISH subject)."""
    intro = (f"This is a multiple choice question related to the subject {subject}. "
             "Given in Sinhala Language.\n") if intro_type else ""
    body = "\n".join(f"{i + 1}. {str(c).strip()}" for i, c in enumerate(choices))
    instr = fit_numbers(ENGLISH_INSTRUCTIONS[level], len(choices))
    return intro + instr + f"Question: {question}\n{body}\nAnswer:"


def rotate(choices, rot):
    """Displayed order; displayed position j holds original option (j + rot) % n."""
    n = len(choices)
    r = rot % n if n else 0
    return choices[r:] + choices[:r], r


def variant_prompt(row, spec):
    shown, r = rotate(row["choices"], spec["rot"])
    if spec["lang"] == "en":
        p = english_prompt(row["level"], row["subject"], row["question"], shown, spec["intro"])
    else:
        p = official_prompt(row["level"], row["subject_original"], row["question"], shown, spec["intro"])
    return p, r, len(shown)


def content_free_prompt(row, spec, filler="N/A"):
    """Same template and option count, but the question and every option replaced by a neutral
    filler. Scoring the digits here measures the model's a-priori preference among option POSITIONS,
    independent of content (contextual calibration / PMI-DC). Depends only on
    (variant, level, subject, n_choices), so it is cached across questions."""
    stub = dict(row)
    stub["question"] = filler
    stub["choices"] = [filler] * len(row["choices"])
    return variant_prompt(stub, spec)


def level_of(item, path, override=""):
    """metadata.difficulty, else a top-level 'level'/'difficulty' field, else the file name or any
    path component containing easy/medium/hard, else 'easy'."""
    if override:
        return override
    md = item.get("metadata") or {}
    for cand in (md.get("difficulty"), md.get("level"), item.get("level"), item.get("difficulty")):
        d = str(cand or "").strip().lower()
        if d in LEVELS:
            return d
    low = path.replace("\\", "/").lower()
    # substring match so "hard.json", "test_hard.jsonl" and ".../hard/..." all work
    hits = [lv for lv in LEVELS if lv in low]
    if len(hits) == 1:
        return hits[0]
    if hits:                                  # e.g. ".../easy/medium_extra.json": trust the last one
        return max(hits, key=low.rfind)
    return "easy"


def read_any(path):
    """A JSON array, a JSONL file, or a dict wrapping the list under a common key."""
    raw = open(path, encoding="utf-8-sig").read().strip()
    if not raw:
        return []
    if raw[0] == "[":
        return json.loads(raw)
    if raw[0] == "{":
        try:
            obj = json.loads(raw)
        except json.JSONDecodeError:
            obj = None
        if isinstance(obj, dict):
            for k in ("data", "questions", "items", "rows", "test"):
                if isinstance(obj.get(k), list):
                    return obj[k]
            return [obj]
        # not one object -> treat as JSONL
    out = []
    for line in raw.splitlines():
        line = line.strip()
        if line:
            out.append(json.loads(line))
    return out


def load_items(data_dir, level_override=""):
    if os.path.isfile(data_dir):
        files = [data_dir]
    else:
        files = sorted(set(glob.glob(os.path.join(data_dir, "**", "*.json"), recursive=True))
                       | set(glob.glob(os.path.join(data_dir, "**", "*.jsonl"), recursive=True)))
    if not files:
        sys.exit(f"no .json/.jsonl files found under {data_dir}")
    rows, skipped, per_file = [], 0, []
    for path in files:
        try:
            data = read_any(path)
        except Exception as e:                                           # noqa: BLE001
            print(f"[warn] {os.path.basename(path)}: {type(e).__name__}: {e}")
            continue
        if not isinstance(data, list) or not data:
            print(f"[warn] {os.path.basename(path)}: no question list found")
            continue
        before = len(rows)
        for it in data:
            md = it.get("metadata") or {}
            ch = [c for c in (it.get("choices") or []) if str(c).strip()]
            if len(ch) < 2:
                skipped += 1          # never drop silently: these still need a prediction line
            ans = it.get("answer")
            rows.append({
                "file": os.path.basename(path),
                "gold": ans if isinstance(ans, int) and 1 <= ans <= max(2, len(ch)) else None,
                "q_no": it.get("q_no"),
                "subject": it.get("subject"),
                "category": it.get("category"),
                "level": level_of(it, path, level_override),
                "question": str(it.get("question") or "").strip(),
                "choices": ch,
                "subject_original": md.get("subject_original") or it.get("subject"),
            })
        lv = {r["level"] for r in rows[before:]}
        per_file.append((os.path.basename(path), len(rows) - before, sorted(lv)))
    by_level = {}
    for r in rows:
        by_level[r["level"]] = by_level.get(r["level"], 0) + 1
    print(f"[data] {len(rows)} questions from {len(files)} file(s); by level: {by_level}")
    for name, n, lv in per_file:
        print(f"    {name:<44} {n:>6} questions   level -> {','.join(lv)}")
    missing = [lv for lv in LEVELS if lv not in by_level]
    if missing:
        print(f"[warn] no questions detected for: {missing}. If that is wrong, the level could not be "
              "read from metadata.difficulty or the file name - check the table above.")
    if skipped:
        print(f"[warn] {skipped} question(s) have fewer than 2 usable options; they still get a line")
    return rows


def common_prefix_len(a, b):
    n = 0
    for x, y in zip(a, b):
        if x != y:
            break
        n += 1
    return n


def predict(model, tok, rows, max_len, batch_seqs, variants, calibrate=False):
    """Deterministic prompt ensemble. For every (question, variant) the options are scored, converted
    to a log-softmax over the DISPLAYED positions (so variants with different prompt lengths are on a
    common scale), mapped back to the original option order, and summed across variants.
    No sampling, no generation: the same inputs always give the same predictions."""
    import math
    import torch
    model.eval()
    dev = next(model.parameters()).device
    pad = tok.pad_token_id if tok.pad_token_id is not None else 0

    items, meta = [], {}                 # (ri, vi, shown_pos, ids, answer_start) ; (ri,vi)->(rot,n)
    for ri, r in enumerate(rows):
        if len(r["choices"]) < 2:
            continue
        for vi, vname in enumerate(variants):
            prompt, rot, n = variant_prompt(r, VARIANTS[vname])
            meta[(ri, vi)] = (rot, n)
            p_ids = tok(prompt)["input_ids"]
            for j in range(n):
                f = tok(prompt + f" {j + 1}")["input_ids"]
                if len(f) > max_len:
                    f = f[-max_len:]
                items.append((ri, vi, j, f, max(1, common_prefix_len(p_ids, f))))

    # ---- calibration priors: one short prompt per (variant, level, subject, n_choices)
    prior_items, prior_key = [], {}
    if calibrate:
        for ri, r in enumerate(rows):
            if len(r["choices"]) < 2:
                continue
            for vi, vname in enumerate(variants):
                key = (vi, r["level"], r["subject_original"], len(r["choices"]))
                prior_key[(ri, vi)] = key
                if key in [k for _, k, _, _, _ in prior_items]:
                    continue
                cf, rot, n = content_free_prompt(r, VARIANTS[vname])
                p_ids = tok(cf)["input_ids"]
                for j in range(n):
                    f = tok(cf + f" {j + 1}")["input_ids"]
                    prior_items.append((len(prior_items), key, j, f, max(1, common_prefix_len(p_ids, f))))
        # de-duplicate: keep the first occurrence of each (key, j)
        seen, uniq = set(), []
        for _, key, j, f, k in prior_items:
            if (key, j) in seen:
                continue
            seen.add((key, j))
            uniq.append((key, j, f, k))
        prior_items = uniq
        print(f"[calibrate] {len(prior_items)} content-free candidates "
              f"({len({k for k, _, _, _ in prior_items})} distinct prompts)")

    print(f"[predict] {len(rows)} questions x {len(variants)} variant(s) = {len(items)} candidates")
    scores, t0 = {}, time.time()
    with torch.no_grad():
        for i in range(0, len(items), batch_seqs):
            chunk = items[i:i + batch_seqs]
            L = max(len(x[3]) for x in chunk)
            ids = torch.full((len(chunk), L), pad, dtype=torch.long)
            mask = torch.zeros((len(chunk), L), dtype=torch.long)
            for b, (_, _, _, sq, _) in enumerate(chunk):
                ids[b, :len(sq)] = torch.tensor(sq)
                mask[b, :len(sq)] = 1
            logits = model(input_ids=ids.to(dev), attention_mask=mask.to(dev)).logits
            for b, (ri, vi, j, sq, k) in enumerate(chunk):
                lp = logits[b, k - 1:len(sq) - 1].float().log_softmax(-1)
                tgt = ids[b, k:len(sq)].to(dev)
                scores[(ri, vi, j)] = lp.gather(-1, tgt[:, None]).sum().item()
            del logits
            if (i // max(1, batch_seqs)) % 100 == 0:
                done = min(i + batch_seqs, len(items))
                print(f"  scored {done}/{len(items)} ({time.time() - t0:.0f}s)", flush=True)

    priors = {}
    if calibrate and prior_items:
        with torch.no_grad():
            for i in range(0, len(prior_items), batch_seqs):
                chunk = prior_items[i:i + batch_seqs]
                L = max(len(x[2]) for x in chunk)
                ids = torch.full((len(chunk), L), pad, dtype=torch.long)
                mask = torch.zeros((len(chunk), L), dtype=torch.long)
                for b, (_, _, sq, _) in enumerate(chunk):
                    ids[b, :len(sq)] = torch.tensor(sq)
                    mask[b, :len(sq)] = 1
                logits = model(input_ids=ids.to(dev), attention_mask=mask.to(dev)).logits
                for b, (key, j, sq, k) in enumerate(chunk):
                    lp = logits[b, k - 1:len(sq) - 1].float().log_softmax(-1)
                    tgt = ids[b, k:len(sq)].to(dev)
                    priors[(key, j)] = lp.gather(-1, tgt[:, None]).sum().item()
                del logits
        print(f"[calibrate] priors computed in {time.time() - t0:.0f}s")

    preds, fallback, agree = [], 0, 0
    for ri, r in enumerate(rows):
        n = len(r["choices"])
        if n < 2:
            preds.append(1)
            fallback += 1
            continue
        total, used, per_variant = [0.0] * n, 0, []
        for vi in range(len(variants)):
            if (ri, vi) not in meta:
                continue
            rot, nv = meta[(ri, vi)]
            raw = [scores.get((ri, vi, j)) for j in range(nv)]
            if any(x is None for x in raw):
                continue
            m = max(raw)                                     # log-softmax over displayed positions
            z = m + math.log(sum(math.exp(x - m) for x in raw))
            adj = [raw[j] - z for j in range(nv)]
            if calibrate:
                key = prior_key.get((ri, vi))
                pr = [priors.get((key, j)) for j in range(nv)]
                if all(x is not None for x in pr):
                    pm = max(pr)
                    pz = pm + math.log(sum(math.exp(x - pm) for x in pr))
                    adj = [adj[j] - (pr[j] - pz) for j in range(nv)]   # subtract the position prior
            best_j = max(range(nv), key=lambda j: adj[j])
            per_variant.append(1 + (best_j + rot) % n)
            for j in range(nv):
                total[(j + rot) % n] += adj[j]                 # displayed j == original (j+rot)%n
            used += 1
        if used == 0:
            preds.append(1)
            fallback += 1
            continue
        preds.append(1 + max(range(n), key=lambda i: total[i]))
        if len(set(per_variant)) == 1:
            agree += 1

    if fallback:
        print(f"[warn] {fallback} question(s) fell back to answer 1")
    if len(variants) > 1:
        print(f"[predict] all variants agreed on {agree}/{len(rows)} ({agree / max(1, len(rows)):.1%}) "
              "- the rest are where the ensemble changes the answer")
    print(f"[predict] done in {time.time() - t0:.0f}s")
    return preds, None


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--data", required=True, help="folder of test *.json files, or a root with easy/medium/hard")
    ap.add_argument("--model", required=True, help="adapter dir, merged dir, or HF repo")
    ap.add_argument("--out", default="predictions.jsonl")
    ap.add_argument("--zip", default="predictions.zip", help="'' to skip zipping")
    ap.add_argument("--level", default="", choices=["", *LEVELS], help="force the level for every item")
    ap.add_argument("--max_seq_length", type=int, default=1024)
    ap.add_argument("--eval_batch", type=int, default=16)
    ap.add_argument("--calibrate", action="store_true",
                    help="contextual calibration: measure the model's a-priori preference among "
                         "option POSITIONS on a content-free prompt and subtract it. ~50 extra "
                         "forward passes in total; standard fix for log-likelihood MCQ position bias.")
    ap.add_argument("--score", action="store_true",
                    help="if the input carries gold 'answer' values (e.g. your val3.jsonl), report "
                         "accuracy as well. Use this to compare variant sets on HELD-OUT data "
                         "instead of spending Codabench submissions.")
    ap.add_argument("--ensemble", nargs="*", default=None,
                    help=f"prompt variants to average over (default: {' '.join(DEFAULT_ENSEMBLE)}). "
                         f"Choices: {' '.join(VARIANTS)}. Pass 'si_intro' alone for single-prompt scoring.")
    ap.add_argument("--seed", type=int, default=3407)
    ap.add_argument("--dry_run", action="store_true", help="show the prompt and exit, no model load")
    args = ap.parse_args()
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(encoding="utf-8")
        except Exception:                                                # noqa: BLE001
            pass

    random.seed(args.seed)
    variants = args.ensemble if args.ensemble else list(DEFAULT_ENSEMBLE)
    unknown = [v for v in variants if v not in VARIANTS]
    if unknown:
        sys.exit(f"unknown variant(s) {unknown}; choose from {list(VARIANTS)}")
    print(f"[ensemble] {len(variants)} variant(s): {variants}")
    rows = load_items(args.data, args.level)

    if args.dry_run:
        r = next((x for x in rows if len(x["choices"]) >= 2), None)
        if r:
            for v in variants:
                prompt, rot, _ = variant_prompt(r, VARIANTS[v])
                print(f"\n--- variant {v} (rot={rot}) ---")
                print(prompt)
            print("--- end ---")
        return

    import torch
    torch.manual_seed(args.seed)
    from unsloth import FastModel
    print(f"[model] loading {args.model}")
    model, processor = FastModel.from_pretrained(model_name=args.model, dtype=None,
                                                 max_seq_length=args.max_seq_length,
                                                 load_in_4bit=False, full_finetuning=False)
    tok = getattr(processor, "tokenizer", processor)
    if tok.pad_token is None:
        tok.pad_token = tok.eos_token

    preds, _ = predict(model, tok, rows, args.max_seq_length, args.eval_batch, variants,
                       calibrate=args.calibrate)

    with open(args.out, "w", encoding="utf-8") as f:
        for r, p in zip(rows, preds):
            f.write(json.dumps({"q_no": r["q_no"], "subject": r["subject"],
                                "category": r["category"], "level": r["level"],
                                "answer": str(p)}, ensure_ascii=False) + "\n")
    print(f"[out] {len(rows)} lines -> {args.out}")

    # self-checks: the submission is rejected or mis-scored if any of these are wrong
    lines = [json.loads(l) for l in open(args.out, encoding="utf-8") if l.strip()]
    assert len(lines) == len(rows), "line count does not match the question count"
    bad_keys = [i for i, d in enumerate(lines)
                if set(d) != {"q_no", "subject", "category", "level", "answer"}]
    bad_ans = [i for i, d in enumerate(lines)
               if not (isinstance(d["answer"], str) and d["answer"] in {"1", "2", "3", "4", "5"})]
    bad_lvl = [i for i, d in enumerate(lines) if d["level"] not in LEVELS]
    for name, bad in [("wrong key set", bad_keys), ("answer not a '1'..'5' string", bad_ans),
                      ("level not easy/medium/hard", bad_lvl)]:
        print(f"[check] {name}: {len(bad)}" + (f"  e.g. line {bad[0] + 1}" if bad else "  OK"))
    dist = {}
    for d in lines:
        dist[d["answer"]] = dist.get(d["answer"], 0) + 1
    print(f"[check] answer distribution: {dict(sorted(dist.items()))}")
    dup = len(lines) - len({(d["level"], d["subject"], d["q_no"]) for d in lines})
    print(f"[check] duplicate (level, subject, q_no) keys: {dup}")
    if bad_keys or bad_ans or bad_lvl:
        sys.exit("fix the problems above before submitting")

    if args.score:
        import collections
        have = [(r, p) for r, p in zip(rows, preds) if r["gold"] is not None]
        if not have:
            print("[score] the input has no gold 'answer' field, nothing to score")
        else:
            n = sum(1 for _ in have)
            c = sum(int(p == r["gold"]) for r, p in have)
            print(f"\n[score] accuracy {c / n:.4f}  ({c}/{n})   variants: {variants}")
            for key, label in (("level", "level"), ("subject", "subject")):
                agg = collections.defaultdict(lambda: [0, 0])
                for r, p in have:
                    k = r.get(key) or "?"
                    agg[k][0] += int(p == r["gold"])
                    agg[k][1] += 1
                print(f"  by {label}:")
                for k, (cc, tt) in sorted(agg.items(), key=lambda x: -x[1][1]):
                    if tt >= 20 or label == "level":
                        print(f"    {str(k):<34} {cc / tt:.4f}  ({cc}/{tt})")

    if args.zip:
        with zipfile.ZipFile(args.zip, "w", zipfile.ZIP_DEFLATED) as z:
            z.write(args.out, arcname="predictions.jsonl")   # must sit at the archive root
        with zipfile.ZipFile(args.zip) as z:
            print(f"[zip] {args.zip} contains: {z.namelist()}")
    print("\nFirst two lines:")
    for l in open(args.out, encoding="utf-8").readlines()[:2]:
        print("  " + l.rstrip())


if __name__ == "__main__":
    main()