arvindcr4 commited on
Commit
73ebad8
·
verified ·
1 Parent(s): f1897d1

script.py: aligned answer blocks, self-consistency voting, task-type format hints

Browse files
Files changed (1) hide show
  1. script.py +630 -0
script.py ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """IOL-AI 2026 submission -- International Linguistics Olympiad solver.
3
+
4
+ Design notes (the eval sandbox is unforgiving, so these matter):
5
+
6
+ * HARD 30-MINUTE LIMIT. A killed process means no score at all, so the script
7
+ is structured as a monotonically-improving pipeline: it writes a complete,
8
+ correctly-shaped submission.csv *before* the model is even loaded, then
9
+ overwrites it after every improvement. Any crash or timeout leaves the best
10
+ result reached so far on disk.
11
+ * ALIGNMENT IS EVERYTHING. Each row is a problem block with N numbered items
12
+ and `pred` must be a JSON list of exactly N answers, in order. One missing
13
+ line shifts every later answer and zeroes the whole block on both metrics.
14
+ So N is detected from the query and the model output is force-fitted to it.
15
+ * NEVER EMIT AN EMPTY STRING. The final score is a geometric mean of exact
16
+ match and chrF, so an empty answer scores zero on both. A wrong guess is
17
+ strictly better than a blank.
18
+ * Environment is transformers 4.44.1 / torch 2.4.0 / autoawq on a 16GB T4
19
+ (fp16 only, no bf16, no flash-attn), with no internet.
20
+ """
21
+ import os
22
+ import re
23
+ import json
24
+ import time
25
+ import unicodedata
26
+ from collections import Counter, defaultdict
27
+
28
+ T0 = time.time()
29
+
30
+ # The platform allows 30 minutes. Reserve a margin for model load overhead we
31
+ # can't predict and for the final write; being 60s early costs a little
32
+ # accuracy, being 1s late costs the entire submission.
33
+ TIME_LIMIT = float(os.environ.get("IOL_TIME_LIMIT", "1800"))
34
+ SAFETY = float(os.environ.get("IOL_SAFETY", "150"))
35
+ DEADLINE = T0 + TIME_LIMIT - SAFETY
36
+
37
+ TEST_CSV = os.environ.get("IOL_TEST_CSV", "/tmp/data/test.csv")
38
+ OUT_CSV = os.environ.get("IOL_OUT_CSV", "submission.csv")
39
+ MODEL_ID = os.environ.get("IOL_MODEL", ".")
40
+ WANT_EXPLANATION = os.environ.get("IOL_EXPLAIN", "1") == "1"
41
+ MAX_NEW = int(os.environ.get("IOL_MAXNEW", "900")) # reasoning budget/item
42
+ MAX_SAMPLES = int(os.environ.get("IOL_MAXSAMPLES", "8")) # self-consistency cap
43
+
44
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
45
+ os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
46
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
47
+ # Reduce allocator fragmentation: at batch 4 the T4 has only ~2GB spare.
48
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
49
+
50
+
51
+ def log(msg):
52
+ print(f"[{time.time() - T0:7.1f}s] {msg}", flush=True)
53
+
54
+
55
+ def left():
56
+ return DEADLINE - time.time()
57
+
58
+
59
+ # ===========================================================================
60
+ # Item-count detection (validated: 98.4% of Linguini items land in
61
+ # correctly-sized blocks)
62
+ # ===========================================================================
63
+
64
+ _LINE_NUM = re.compile(r"^[ \t]*(\d{1,3})[.)\]]", re.M)
65
+ _PAREN_NUM = re.compile(r"\((\d{1,3})\)")
66
+ _RANGE = re.compile(r"\(?(\d{1,3})\s*(?:[-–—]|to)\s*(\d{1,3})\)?")
67
+ _LINE_LETTER = re.compile(r"^[ \t]*([A-Z])[.)\]]\s", re.M)
68
+ _PAREN_LETTER = re.compile(r"\(([A-Z])\)")
69
+
70
+
71
+ def detect_n_items(query, task_type="", context=""):
72
+ """How many numbered sub-items this problem asks for. Never < 1."""
73
+ q = query or ""
74
+ line_nums = [int(m) for m in _LINE_NUM.findall(q)]
75
+ paren_nums = [int(m) for m in _PAREN_NUM.findall(q)]
76
+
77
+ range_n = 0
78
+ for a, b in _RANGE.findall(q):
79
+ a, b = int(a), int(b)
80
+ if 0 < b - a < 60:
81
+ range_n = max(range_n, b - a + 1)
82
+
83
+ cand = max(len(set(line_nums)), len(set(paren_nums)))
84
+ if range_n and cand and range_n != cand:
85
+ # A stated range ("items 1-4") can disagree with the markers actually
86
+ # present; the markers are what we have to answer, so they win.
87
+ return cand
88
+ cand = max(cand,
89
+ len(set(_LINE_LETTER.findall(q))),
90
+ len(set(_PAREN_LETTER.findall(q))))
91
+
92
+ n = max(range_n, cand)
93
+ if n > 1:
94
+ return n
95
+
96
+ # Unnumbered "Translate into X:" followed by one item per line.
97
+ lines = [l.strip() for l in q.splitlines() if l.strip()]
98
+ if len(lines) > 1:
99
+ head = lines[0]
100
+ body = lines[1:] if head.endswith((":", ".")) else lines
101
+ if body:
102
+ return len(body)
103
+
104
+ # Bare instruction ("Determine the correct correspondences."): items are in
105
+ # the shared context (this is the match_letters shape).
106
+ if context:
107
+ c_nums = len(set(int(m) for m in _LINE_NUM.findall(context)))
108
+ if c_nums > 1:
109
+ return c_nums
110
+ c_lets = len(set(_LINE_LETTER.findall(context)))
111
+ if c_lets > 1:
112
+ return c_lets
113
+
114
+ return max(n, 1)
115
+
116
+
117
+ # ===========================================================================
118
+ # Output parsing / repair
119
+ # ===========================================================================
120
+
121
+ _STRIP_PREFIX = re.compile(r"^\s*(?:\(?\d{1,3}\)?[.):\]]\s*|[-*•]\s+)")
122
+ _FENCE = re.compile(r"^```[a-zA-Z]*\s*$")
123
+ _CHATTY = re.compile(
124
+ r"^\s*(?:here (?:are|is)\b|answers?\s*:?\s*$|explanation\b|note\b|okay\b|"
125
+ r"solution\b|reasoning\b|analysis\b|translations?\s*:?\s*$|the answers?\b|"
126
+ r"let me\b|first,|so,|therefore\b|thus\b)",
127
+ re.I,
128
+ )
129
+
130
+
131
+ def clean_line(s):
132
+ s = s.strip()
133
+ s = _STRIP_PREFIX.sub("", s)
134
+ s = s.strip().strip("`").strip()
135
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'“”":
136
+ s = s[1:-1].strip()
137
+ # "word | gloss" answer lines: keep the side being asked for is ambiguous,
138
+ # so keep the whole line -- chrF still gives partial credit.
139
+ return s.strip()
140
+
141
+
142
+ def extract_item_sources(query, n):
143
+ """The source text of each numbered item, used as a last-resort fallback.
144
+
145
+ A blank scores zero on both metrics; echoing the item's own source string is
146
+ strictly better, and on transcription / fill-the-blank tasks the source and
147
+ the target share a lot of characters, so it collects real chrF credit.
148
+ """
149
+ q = query or ""
150
+ out = []
151
+ for ln in q.splitlines():
152
+ s = ln.strip()
153
+ if not s:
154
+ continue
155
+ m = re.match(r"^\(?(\d{1,3})\)?[.):\]]\s*(.+)$", s)
156
+ if m:
157
+ out.append(m.group(2).strip())
158
+ if not out:
159
+ lines = [l.strip() for l in q.splitlines() if l.strip()]
160
+ if len(lines) > 1 and lines[0].endswith((":", ".")):
161
+ out = lines[1:]
162
+ # "form | gloss" items: the left side is the thing being asked about.
163
+ out = [o.split("|")[0].strip() if "|" in o else o for o in out]
164
+ out = [o for o in out if o]
165
+ while len(out) < n:
166
+ out.append(out[-1] if out else "?")
167
+ return out[:n]
168
+
169
+
170
+ def parse_answers(text, n, fallback=None):
171
+ """Raw model output -> exactly n non-empty answers."""
172
+ if not text:
173
+ return list(fallback[:n]) if fallback else ["?"] * n
174
+
175
+ # Prefer the explicit final block the prompt asks for.
176
+ m = None
177
+ for m2 in re.finditer(r"(?:^|\n)\s*(?:final\s+)?answers?\s*:\s*\n?", text, re.I):
178
+ m = m2
179
+ body = text[m.end():] if m else text
180
+
181
+ numbered, raw = [], []
182
+ for ln in body.splitlines():
183
+ if _FENCE.match(ln):
184
+ continue
185
+ mm = re.match(r"^\s*\(?(\d{1,3})\)?[.):\]]\s*(.+)$", ln.strip())
186
+ if mm:
187
+ val = clean_line(mm.group(2))
188
+ if val and not _CHATTY.match(val):
189
+ numbered.append((int(mm.group(1)), val))
190
+ c = clean_line(ln)
191
+ if c and not _CHATTY.match(c):
192
+ raw.append(c)
193
+
194
+ # If the model numbered its answers, trust those labels for placement.
195
+ if len(numbered) >= n:
196
+ by_label = {}
197
+ for lab, val in numbered:
198
+ by_label[lab] = val # last write wins (models restate)
199
+ labs = sorted(by_label)
200
+ if len(labs) >= n:
201
+ return [by_label[l] for l in labs[:n]]
202
+
203
+ return fit_to_n(raw, n, fallback)
204
+
205
+
206
+ def fit_to_n(items, n, fallback=None):
207
+ items = [i for i in items if i and i.strip()]
208
+ if len(items) > n:
209
+ # Take the LAST n. The prompt asks for reasoning first and the answers
210
+ # last, so when there is no ANSWERS: marker to slice on, the tail is the
211
+ # answer block and the head is reasoning prose.
212
+ items = items[-n:]
213
+ while len(items) < n:
214
+ if fallback and len(items) < len(fallback):
215
+ items.append(fallback[len(items)])
216
+ else:
217
+ items.append(items[-1] if items else "?")
218
+ return items[:n]
219
+
220
+
221
+ def norm(s):
222
+ s = unicodedata.normalize("NFC", (s or "").strip().lower())
223
+ s = re.sub(r"\s+", " ", s)
224
+ return s.strip(" .!?;:,")
225
+
226
+
227
+ # ===========================================================================
228
+ # chrF (inline, dependency-free) -- used only to pick the most "central"
229
+ # candidate when self-consistency voting has no majority. sacrebleu is not
230
+ # guaranteed to be importable inside the sandbox.
231
+ # ===========================================================================
232
+
233
+ def _ngrams(s, k):
234
+ s = re.sub(r"\s+", "", s)
235
+ return Counter(s[i:i + k] for i in range(len(s) - k + 1)) if len(s) >= k else Counter()
236
+
237
+
238
+ def chrf_sim(hyp, ref, order=6, beta=2.0):
239
+ if not hyp or not ref:
240
+ return 0.0
241
+ ps, rs = [], []
242
+ for k in range(1, order + 1):
243
+ h, r = _ngrams(hyp, k), _ngrams(ref, k)
244
+ if not h or not r:
245
+ continue
246
+ overlap = sum((h & r).values())
247
+ ps.append(overlap / max(1, sum(h.values())))
248
+ rs.append(overlap / max(1, sum(r.values())))
249
+ if not ps:
250
+ return 0.0
251
+ p, r = sum(ps) / len(ps), sum(rs) / len(rs)
252
+ if p + r == 0:
253
+ return 0.0
254
+ b2 = beta * beta
255
+ return (1 + b2) * p * r / (b2 * p + r)
256
+
257
+
258
+ def vote(cands):
259
+ """Pick one answer from several samples of the same item.
260
+
261
+ Majority on a normalised form maximises exact match; when there is no
262
+ majority, the medoid by chrF maximises expected partial credit.
263
+ """
264
+ cands = [c for c in cands if c and c.strip()]
265
+ if not cands:
266
+ return "?"
267
+ if len(cands) == 1:
268
+ return cands[0]
269
+
270
+ groups = defaultdict(list)
271
+ for c in cands:
272
+ groups[norm(c)].append(c)
273
+ best_key, best = None, -1
274
+ for k, v in groups.items():
275
+ if len(v) > best:
276
+ best_key, best = k, len(v)
277
+ if best > len(cands) / 2.0: # strict majority
278
+ return Counter(groups[best_key]).most_common(1)[0][0]
279
+
280
+ scored = []
281
+ for c in cands:
282
+ s = sum(chrf_sim(c, o) for o in cands if o is not c)
283
+ scored.append((s + 0.5 * len(groups[norm(c)]), c))
284
+ scored.sort(key=lambda t: (-t[0], len(t[1])))
285
+ return scored[0][1]
286
+
287
+
288
+ def repair_bijection(answers):
289
+ """match_letters answers are usually a permutation of the option letters.
290
+
291
+ When every answer is a single letter and there are as many items as
292
+ distinct letters available, duplicates are certainly wrong. Reassign the
293
+ duplicated slots to the unused letters. Strictly guarded so it is a no-op
294
+ on anything that isn't this shape.
295
+ """
296
+ if len(answers) < 3:
297
+ return answers
298
+ if not all(re.fullmatch(r"[A-Z]", a or "") for a in answers):
299
+ return answers
300
+ n = len(answers)
301
+ universe = [chr(ord("A") + i) for i in range(n)]
302
+ if len(set(answers)) == n:
303
+ return answers
304
+ unused = [l for l in universe if l not in set(answers)]
305
+ if not unused:
306
+ return answers
307
+ seen, out = set(), []
308
+ for a in answers:
309
+ if a in seen and unused:
310
+ out.append(unused.pop(0))
311
+ else:
312
+ seen.add(a)
313
+ out.append(a)
314
+ return out
315
+
316
+
317
+ # ===========================================================================
318
+ # Prompting
319
+ # ===========================================================================
320
+
321
+ SYSTEM = (
322
+ "You are a gold medallist at the International Linguistics Olympiad.\n"
323
+ "Each problem gives data from a language you have never seen. Everything "
324
+ "you need is in the problem itself; no outside knowledge is required or "
325
+ "allowed.\n"
326
+ "Method: line up the given examples, segment the words, identify the "
327
+ "recurring morphemes and the rules that order them, check your rules "
328
+ "against EVERY example, then apply them to the items asked for.\n"
329
+ "Be concise while reasoning. Then output a final block that begins with a "
330
+ "line containing exactly ANSWERS: followed by one answer per line, in the "
331
+ "order asked, with no numbering, no commentary and no blank lines.\n"
332
+ "Give your best guess for every item. Never leave one blank."
333
+ )
334
+
335
+
336
+ # Exact match is half the score, so the answer's *form* matters as much as its
337
+ # content. test.csv states the task type, so say precisely what a well-formed
338
+ # answer looks like. Unknown/absent types simply get no hint.
339
+ TASK_HINTS = {
340
+ "translation": "Each answer is the translation alone -- no source text, no "
341
+ "gloss, no notes, no quotation marks.",
342
+ "match_letters": "Each answer is a single capital letter identifying the "
343
+ "match for that numbered item. Every letter is used "
344
+ "exactly once, so no letter may repeat.",
345
+ "fill_blanks": "Each answer is only the missing form that belongs in that "
346
+ "blank -- not the whole line, not the gloss.",
347
+ "text_to_num": "Each answer is written in digits only (e.g. 111).",
348
+ "num_to_text": "Each answer is the number written out in the problem "
349
+ "language, words only.",
350
+ }
351
+
352
+
353
+ def build_prompt(row, n):
354
+ hint = TASK_HINTS.get((row.get("task_type") or "").strip().lower(), "")
355
+ return (
356
+ f"{row['context'].strip()}\n\n{row['query'].strip()}\n\n"
357
+ f"There are exactly {n} item{'s' if n != 1 else ''} to answer."
358
+ + (f" {hint}" if hint else "") +
359
+ f"\nAfter your reasoning, write ANSWERS: on its own line and then exactly "
360
+ f"{n} line{'s' if n != 1 else ''}, one answer per item, in order."
361
+ )
362
+
363
+
364
+ EXPLAIN_SYSTEM = (
365
+ "You explain International Linguistics Olympiad solutions to a human judge. "
366
+ "Given a problem and the answers produced, state the key rules of the "
367
+ "language that justify them: the relevant morphemes, word order and any "
368
+ "sound changes. Be specific and concise (2-4 sentences or a few short "
369
+ "bullets). Do not restate the reasoning as a stream of thought."
370
+ )
371
+
372
+
373
+ def build_explain_prompt(row, answers):
374
+ return (
375
+ f"{row['context'].strip()}\n\n{row['query'].strip()}\n\n"
376
+ f"Answers given:\n" + "\n".join(f"- {a}" for a in answers) +
377
+ "\n\nBriefly explain the linguistic rules behind these answers."
378
+ )
379
+
380
+
381
+ # ===========================================================================
382
+ # Main
383
+ # ===========================================================================
384
+
385
+ def dev_score(preds):
386
+ """Offline diagnostic: score against a gold file when IOL_GOLD is set.
387
+
388
+ Never runs on the platform (the answers are hidden, so the variable is
389
+ unset there); it exists so one benchmark run reveals the whole learning
390
+ curve -- greedy, then after each self-consistency pass -- instead of a
391
+ single final number.
392
+ """
393
+ gold_path = os.environ.get("IOL_GOLD")
394
+ if not gold_path or not os.path.exists(gold_path):
395
+ return
396
+ try:
397
+ import ast
398
+
399
+ import pandas as pd
400
+ g = pd.read_csv(gold_path, dtype=str)
401
+ ems, cfs = [], []
402
+ for _, r in g.iterrows():
403
+ gold = ast.literal_eval(r["answer"])
404
+ p = preds.get(str(r["id"]), [])
405
+ p = list(p)[:len(gold)] + [""] * max(0, len(gold) - len(p))
406
+ for gi, pi in zip(gold, p):
407
+ alts = gi if isinstance(gi, (list, tuple)) else [gi]
408
+ alts = [str(a) for a in alts]
409
+ ems.append(1.0 if any(pi.strip() == a.strip() for a in alts) else 0.0)
410
+ cfs.append(max(chrf_sim(pi, a) for a in alts))
411
+ em = sum(ems) / max(1, len(ems))
412
+ cf = sum(cfs) / max(1, len(cfs))
413
+ log(f" [dev] EM={em:.4f} chrF~={cf:.4f} score~={(em * cf) ** 0.5:.4f} "
414
+ f"over {len(ems)} items")
415
+ except Exception as e:
416
+ log(f" [dev] scoring failed: {type(e).__name__}: {e}")
417
+
418
+
419
+ def write_submission(path, ids, preds, explanations=None):
420
+ import pandas as pd
421
+ rows = []
422
+ for i in ids:
423
+ rec = {"id": i, "pred": json.dumps(preds[i], ensure_ascii=False)}
424
+ if explanations is not None:
425
+ rec["explanation"] = explanations.get(i, "")
426
+ rows.append(rec)
427
+ pd.DataFrame(rows).to_csv(path, index=False)
428
+
429
+
430
+ def main():
431
+ import pandas as pd
432
+
433
+ df = pd.read_csv(TEST_CSV, dtype=str).fillna("")
434
+ ids = [str(x) for x in df["id"].tolist()]
435
+ ns = [detect_n_items(r.get("query", ""), r.get("task_type", ""), r.get("context", ""))
436
+ for _, r in df.iterrows()]
437
+ total_items = sum(ns)
438
+ log(f"loaded {len(df)} problems, {total_items} items "
439
+ f"(min={min(ns)} max={max(ns)} mean={total_items / len(ns):.1f})")
440
+
441
+ srcs = {i: extract_item_sources(r.get("query", ""), n)
442
+ for i, (_, r), n in zip(ids, df.iterrows(), ns)}
443
+
444
+ # --- 1. Baseline submission on disk before anything can go wrong --------
445
+ preds = {i: list(srcs[i]) for i in ids}
446
+ explanations = {i: "" for i in ids} if WANT_EXPLANATION else None
447
+ write_submission(OUT_CSV, ids, preds, explanations)
448
+ log(f"wrote placeholder {OUT_CSV} ({len(ids)} rows)")
449
+
450
+ # --- 2. Load model -----------------------------------------------------
451
+ import torch
452
+ from transformers import (AutoTokenizer, AutoModelForCausalLM,
453
+ StoppingCriteria, StoppingCriteriaList)
454
+
455
+ class Deadline(StoppingCriteria):
456
+ """Abort generation on wall-clock, checked every token.
457
+
458
+ Without this the budget is only checked between batches, so a batch
459
+ started near the limit runs past it and the platform kills the process.
460
+ """
461
+
462
+ def __init__(self, stop_at):
463
+ self.stop_at = stop_at
464
+
465
+ def __call__(self, input_ids, scores, **kw):
466
+ return time.time() > self.stop_at
467
+
468
+ log("loading tokenizer/model ...")
469
+ tok = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
470
+ if tok.pad_token is None:
471
+ tok.pad_token = tok.eos_token
472
+ tok.padding_side = "left"
473
+
474
+ # Pin every layer to the GPU. device_map="auto" is free to spill layers to
475
+ # CPU when it thinks VRAM is tight, and a couple of offloaded layers make
476
+ # generation ~100x slower without any error -- the worst kind of failure
477
+ # here. Falling back to "auto" only if the explicit placement fails.
478
+ def _load(dev_map):
479
+ # transformers 4.44 (the sandbox) wants torch_dtype=; 5.x renamed it to
480
+ # dtype=. Accept either so the same file runs in both.
481
+ try:
482
+ return AutoModelForCausalLM.from_pretrained(
483
+ MODEL_ID, torch_dtype=torch.float16, device_map=dev_map,
484
+ trust_remote_code=True).eval()
485
+ except TypeError:
486
+ return AutoModelForCausalLM.from_pretrained(
487
+ MODEL_ID, dtype=torch.float16, device_map=dev_map,
488
+ trust_remote_code=True).eval()
489
+
490
+ try:
491
+ model = _load({"": 0} if torch.cuda.is_available() else "auto")
492
+ except Exception as e:
493
+ log(f"pinned load failed ({type(e).__name__}: {e}); falling back to auto")
494
+ model = _load("auto")
495
+
496
+ devs = set(str(p.device) for p in model.parameters())
497
+ log(f"model ready on {sorted(devs)} ({left():.0f}s of budget left)")
498
+ if any(d.startswith("cpu") or d == "meta" for d in devs):
499
+ log("WARNING: part of the model is off-GPU; generation will be very slow")
500
+ if torch.cuda.is_available():
501
+ log(f" VRAM allocated {torch.cuda.memory_allocated()/1e9:.2f} GB / "
502
+ f"{torch.cuda.get_device_properties(0).total_memory/1e9:.1f} GB")
503
+
504
+ prompts = []
505
+ for (_, r), n in zip(df.iterrows(), ns):
506
+ msgs = [{"role": "system", "content": SYSTEM},
507
+ {"role": "user", "content": build_prompt(r, n)}]
508
+ prompts.append(tok.apply_chat_template(msgs, tokenize=False,
509
+ add_generation_prompt=True))
510
+
511
+ batch_size = int(os.environ.get("IOL_BATCH", "4"))
512
+
513
+ def generate(texts, max_new, sample, temp=0.7):
514
+ """Batched generation with OOM backoff. Returns list of strings."""
515
+ nonlocal batch_size
516
+ out = [""] * len(texts)
517
+ order = sorted(range(len(texts)), key=lambda i: len(texts[i]))
518
+ i = 0
519
+ while i < len(order):
520
+ if left() < 25:
521
+ log(" out of time inside generate(); returning partial")
522
+ break
523
+ idx = order[i:i + batch_size]
524
+ chunk = [texts[j] for j in idx]
525
+ try:
526
+ enc = tok(chunk, return_tensors="pt", padding=True,
527
+ truncation=True, max_length=6144).to(model.device)
528
+ kw = dict(max_new_tokens=max_new, pad_token_id=tok.pad_token_id,
529
+ stopping_criteria=StoppingCriteriaList(
530
+ [Deadline(DEADLINE - 10)]))
531
+ if sample:
532
+ kw.update(do_sample=True, temperature=temp, top_p=0.95)
533
+ else:
534
+ kw.update(do_sample=False)
535
+ with torch.no_grad():
536
+ o = model.generate(**enc, **kw)
537
+ for k, j in enumerate(idx):
538
+ out[j] = tok.decode(o[k][enc["input_ids"].shape[1]:],
539
+ skip_special_tokens=True)
540
+ i += batch_size
541
+ except torch.cuda.OutOfMemoryError:
542
+ torch.cuda.empty_cache()
543
+ if batch_size == 1:
544
+ log(" OOM at batch=1; skipping this item")
545
+ i += 1
546
+ else:
547
+ batch_size = max(1, batch_size // 2)
548
+ log(f" OOM -> batch_size={batch_size}")
549
+ except Exception as e: # never die mid-run
550
+ log(f" generate error: {type(e).__name__}: {e}")
551
+ i += batch_size
552
+ return out
553
+
554
+ # --- 3. Pass 1: greedy, guarantees a full answer set --------------------
555
+ # Size the reasoning budget to the actual problem count. Measured on the
556
+ # eval hardware (T4, 14B AWQ, batch 4) throughput is ~32 tok/s, so the whole
557
+ # 30 minutes buys only ~50k generated tokens. With ~16 problem blocks that
558
+ # affords full-length reasoning; if the platform instead ships one row per
559
+ # sub-question (~90 rows) a fixed 900-token budget would not even finish a
560
+ # single pass. Spend at most ~40% of what's left on pass 1.
561
+ TOK_PER_S = float(os.environ.get("IOL_TOKS", "30"))
562
+ adaptive = int(0.40 * max(1.0, left()) * TOK_PER_S / max(1, len(df)))
563
+ max_new = max(192, min(MAX_NEW, adaptive))
564
+ log(f"reasoning budget: {max_new} new tokens/problem "
565
+ f"(adaptive={adaptive}, cap={MAX_NEW}, {len(df)} problems)")
566
+
567
+ t = time.time()
568
+ texts = generate(prompts, max_new=max_new, sample=False)
569
+ pass1_cost = time.time() - t
570
+ samples = {i: [] for i in ids}
571
+ for i, n, txt in zip(ids, ns, texts):
572
+ a = repair_bijection(parse_answers(txt, n, srcs[i]))
573
+ preds[i] = a
574
+ samples[i].append(a)
575
+ write_submission(OUT_CSV, ids, preds, explanations)
576
+ log(f"pass 1 (greedy) done in {pass1_cost:.0f}s -> submission written")
577
+ dev_score(preds)
578
+
579
+ # --- 4. Self-consistency passes while budget allows ---------------------
580
+ reserve = 0.0
581
+ if WANT_EXPLANATION:
582
+ reserve = min(300.0, 0.25 * pass1_cost + 60) # explanations are short
583
+ n_extra = 0
584
+ while left() - reserve > pass1_cost * 1.25 and n_extra < MAX_SAMPLES:
585
+ n_extra += 1
586
+ log(f"self-consistency pass {n_extra} ({left():.0f}s left)")
587
+ texts = generate(prompts, max_new=max_new, sample=True, temp=0.7)
588
+ for i, n, txt in zip(ids, ns, texts):
589
+ if txt:
590
+ samples[i].append(repair_bijection(parse_answers(txt, n, srcs[i])))
591
+ for i, n in zip(ids, ns):
592
+ if len(samples[i]) > 1:
593
+ preds[i] = repair_bijection(
594
+ [vote([s[k] for s in samples[i]]) for k in range(n)])
595
+ write_submission(OUT_CSV, ids, preds, explanations)
596
+ log(f" voted over {n_extra + 1} samples -> submission written")
597
+ dev_score(preds)
598
+
599
+ # --- 5. Explanations for the jury track ---------------------------------
600
+ if WANT_EXPLANATION and left() > 60:
601
+ log(f"generating explanations ({left():.0f}s left)")
602
+ ex_prompts = []
603
+ for (_, r), i in zip(df.iterrows(), ids):
604
+ msgs = [{"role": "system", "content": EXPLAIN_SYSTEM},
605
+ {"role": "user", "content": build_explain_prompt(r, preds[i])}]
606
+ ex_prompts.append(tok.apply_chat_template(
607
+ msgs, tokenize=False, add_generation_prompt=True))
608
+ ex = generate(ex_prompts, max_new=200, sample=False)
609
+ for i, e in zip(ids, ex):
610
+ e = re.sub(r"\s+", " ", (e or "").strip())
611
+ if e:
612
+ explanations[i] = e[:1200]
613
+ write_submission(OUT_CSV, ids, preds, explanations)
614
+ log("explanations written")
615
+
616
+ # --- 6. Final integrity check ------------------------------------------
617
+ bad = [i for i, n in zip(ids, ns) if len(preds[i]) != n or any(
618
+ not str(x).strip() for x in preds[i])]
619
+ if bad:
620
+ log(f"repairing {len(bad)} malformed rows")
621
+ for i, n in zip(ids, ns):
622
+ preds[i] = fit_to_n([x for x in preds[i] if str(x).strip()], n, srcs[i])
623
+ write_submission(OUT_CSV, ids, preds, explanations)
624
+
625
+ log(f"DONE. {len(ids)} rows, {sum(len(v) for v in preds.values())} answers, "
626
+ f"{time.time() - T0:.0f}s elapsed")
627
+
628
+
629
+ if __name__ == "__main__":
630
+ main()