Santhoshini commited on
Commit
dcd403d
·
verified ·
1 Parent(s): 122daaa

Update script.py

Browse files
Files changed (1) hide show
  1. script.py +38 -235
script.py CHANGED
@@ -1,29 +1,25 @@
1
- # script.py — CONTROLLED EXPERIMENT: Qwen2.5-14B-Instruct (bnb-4bit, via
2
- # unsloth/Qwen2.5-14B-Instruct-bnb-4bit) + decomposition/verification prompt
3
- # + safe arithmetic for number tasks + guaranteed explanations.
4
  # =============================================================================
5
- # THE ONLY CHANGE vs the 0.0834 baseline is the pip block below. Inference is
6
- # byte-identical. Purpose: the grader's scoring step (metric.py:179 ->
7
- # hf_hub_download of the private solution file) was dying with RemoteDisconnected
8
- # AFTER our script exited cleanly (exit code 0). The leading hypothesis is that
9
- # our `pip install -U transformers ...` dragged a newer huggingface_hub /
10
- # requests / urllib3 into the shared site-packages, and the grader then ran its
11
- # solution-file download on that untested networking stack. This submission
12
- # installs NOTHING that can move those packages, so if it scores, that
13
- # hypothesis is confirmed; if it still fails identically, the class is ruled out
14
- # and the cause is organizer-side infrastructure.
 
 
15
  # =============================================================================
16
  import os
17
  import atexit
18
- # Save pre-script state BEFORE we touch these variables, so we can restore
19
- # it exactly (unset if they were unset, original value if they had one).
20
  _ORIGINAL_HF_HUB_OFFLINE = os.environ.get("HF_HUB_OFFLINE")
21
  _ORIGINAL_TRANSFORMERS_OFFLINE = os.environ.get("TRANSFORMERS_OFFLINE")
22
  def _restore_offline_env_vars():
23
- """Restores these two variables to their exact pre-script state on
24
- exit. Harmless belt-and-suspenders: the scoring traceback reaches the
25
- network, so offline vars are demonstrably NOT the failure cause, but
26
- restoring them cannot hurt and keeps the file otherwise identical."""
27
  for key, original in (("HF_HUB_OFFLINE", _ORIGINAL_HF_HUB_OFFLINE),
28
  ("TRANSFORMERS_OFFLINE", _ORIGINAL_TRANSFORMERS_OFFLINE)):
29
  if original is None:
@@ -35,8 +31,6 @@ os.environ["HF_HUB_OFFLINE"] = "1"
35
  os.environ["TRANSFORMERS_OFFLINE"] = "1"
36
  import subprocess, sys
37
  def emergency_submission_csv(reason, rows_so_far=None):
38
- """Last-resort guarantee: no matter WHERE the script dies, write a valid
39
- submission.csv before the process exits."""
40
  try:
41
  import pandas as pd
42
  if rows_so_far:
@@ -52,17 +46,12 @@ def emergency_submission_csv(reason, rows_so_far=None):
52
  "explanation": f"EMERGENCY FALLBACK: {str(reason)[:150]}"} for i in ids]
53
  pd.DataFrame(rows, columns=["id", "pred", "explanation"]).to_csv("submission.csv", index=False)
54
  except Exception:
55
- # Absolute last resort: a header-only file is still a file.
56
  try:
57
  with open("submission.csv", "w") as f:
58
  f.write("id,pred,explanation\n")
59
  except Exception:
60
  pass
61
  def write_submission_csv(rows_list):
62
- """Stdlib csv, not pandas -- avoids a real pandas/numpy ABI crash inside
63
- pandas' Index construction seen in this exact sandbox. ATOMIC: writes to
64
- a temp file then os.replace()s it into place, so a reader can never
65
- observe a partially-written file mid-save."""
66
  import csv as _csv
67
  tmp_path = "submission.csv.tmp"
68
  with open(tmp_path, "w", newline="", encoding="utf-8") as f:
@@ -72,17 +61,6 @@ def write_submission_csv(rows_list):
72
  w.writerow(row)
73
  os.replace(tmp_path, "submission.csv")
74
  try:
75
- # CONTROLLED EXPERIMENT: no upgrades, no networking-stack churn.
76
- # We install NOTHING that could pull a newer huggingface_hub / requests /
77
- # urllib3 into the shared site-packages, because the grader's scoring step
78
- # (metric.py:179 -> hf_hub_download of the solution file) runs on whatever
79
- # stack we leave behind. bitsandbytes is the only package the 4-bit model
80
- # genuinely needs that may be absent; --no-deps guarantees it cannot drag
81
- # huggingface_hub / requests / urllib3 up with it. torch, pandas,
82
- # transformers, accelerate, tokenizers are used at their PREINSTALLED
83
- # sandbox versions. If a genuine ModuleNotFoundError appears in OUR script
84
- # (non-zero exit, a different failure than the scoring RemoteDisconnected),
85
- # add just that one package back with --no-deps.
86
  subprocess.run([sys.executable, "-m", "pip", "install", "-q",
87
  "--no-deps", "bitsandbytes"], check=True)
88
  except Exception as e:
@@ -94,13 +72,9 @@ import torch
94
  from transformers import AutoTokenizer, AutoModelForCausalLM
95
  MODEL_ID = "."
96
  TIME_LIMIT_S = 30 * 60
97
- SETUP_BUFFER_S = 420 # larger margin: 14B bnb-4bit checkpoint is ~8-9GB, slower to load than anything tested before
98
  start_time = time.time()
99
  try:
100
- # Read test.csv and checkpoint a placeholder submission FIRST, before
101
- # the slowest and most failure-prone step (model loading, ~7-9 min for
102
- # this checkpoint size) even starts. If loading hangs or gets killed,
103
- # something valid already exists on disk rather than nothing at all.
104
  df = pd.read_csv("/tmp/data/test.csv", dtype=str).fillna("")
105
  placeholder_rows = [{"id": rid, "pred": json.dumps([""]),
106
  "explanation": "Placeholder written before model load."}
@@ -111,10 +85,6 @@ try:
111
  tok = AutoTokenizer.from_pretrained(MODEL_ID)
112
  print("Tokenizer loaded (fast).", flush=True)
113
  except Exception as e:
114
- # Mechanism-level fix: bypasses TokenizerFast.from_file() entirely,
115
- # which is exactly the call that fails on a tokenizer.json saved by a
116
- # newer tokenizers library than the sandbox has. Falls back to the
117
- # pure Python tokenizer built from vocab.json/merges.txt instead.
118
  print(f"Fast tokenizer failed ({e}); falling back to use_fast=False.", flush=True)
119
  tok = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=False)
120
  print("Tokenizer loaded (slow fallback).", flush=True)
@@ -127,18 +97,12 @@ except Exception as e:
127
  emergency_submission_csv(f"tokenizer/model load or test.csv read failed: {e}")
128
  raise
129
  n_rows = len(df)
130
- # Measured, not estimated: if real loading took longer or shorter than the
131
- # SETUP_BUFFER_S guess, every downstream timing decision for the rest of the
132
- # run should work from what actually happened, not a stale assumption.
133
  actual_setup_elapsed = time.time() - start_time
134
  per_row_budget = max(20, (TIME_LIMIT_S - actual_setup_elapsed) / max(n_rows, 1))
135
- print(f"Setup took {actual_setup_elapsed:.0f}s (estimated {SETUP_BUFFER_S}s) | "
136
- f"per_row_budget={per_row_budget:.0f}s for {n_rows} rows", flush=True)
137
- # ---- Query parsing: widened patterns + honest "unknown count" fallback ----
138
  def parse_items(query: str):
139
- """Returns (preamble, items, count_known). count_known=False means no
140
- pattern matched -- we do NOT guess a count, we let the model's own
141
- answer list stand rather than risk truncating real content."""
142
  item_pat = re.compile(r"(?m)^\s*(\d+)\s*[.\)]\s*(.*)$")
143
  matches = list(item_pat.finditer(query))
144
  if matches:
@@ -180,20 +144,9 @@ TASK_GUIDANCE = {
180
  "num_to_text": "give the number written out in words, in the language asked.",
181
  }
182
  DEFAULT_GUIDANCE = "give exactly what the instruction asks, nothing else."
183
- # =============================================================================
184
- # SYMBOLIC PREPROCESSING LAYER -- pure Python standard library only (re,
185
- # difflib, collections), no new dependencies. Deterministic, CPU-only,
186
- # negligible runtime. Augments the raw context; never replaces or rewrites
187
- # any of it.
188
- # =============================================================================
189
  from difflib import SequenceMatcher
190
- from collections import defaultdict, Counter
191
  def extract_forms_from_context(context: str):
192
- """Pulls candidate unknown-language 'forms' out of raw context text, for
193
- reduplication's per-word self-check ONLY. Pipe-delimited lines
194
- contribute ONLY their FIRST field (the conventional unknown-language
195
- side). Plain lines contribute whitespace tokens. Lines with more than 3
196
- pipes are skipped defensively (click-language transcriptions)."""
197
  forms = []
198
  for line in context.splitlines():
199
  line = line.strip()
@@ -216,9 +169,6 @@ def extract_forms_from_context(context: str):
216
  unique_forms.append(f)
217
  return unique_forms
218
  def extract_explicit_pairs(context: str):
219
- """Extracts genuine (input, output) pairs from pipe-delimited rows using
220
- the row's own layout, not language-specific assumptions. Forms from
221
- DIFFERENT rows are never cross-compared."""
222
  pairs = []
223
  for line in context.splitlines():
224
  line = line.strip()
@@ -230,9 +180,6 @@ def extract_explicit_pairs(context: str):
230
  pairs.append((fields[0], fields[1]))
231
  return pairs
232
  def edit_signature(a: str, b: str):
233
- """A clean single-region transformation signature between two strings,
234
- or None if the difference is scattered across multiple regions, OR if
235
- there is no genuine shared stem of at least 2 characters."""
236
  sm = SequenceMatcher(None, a, b, autojunk=False)
237
  all_ops = sm.get_opcodes()
238
  ops = [op for op in all_ops if op[0] != "equal"]
@@ -251,9 +198,6 @@ def edit_signature(a: str, b: str):
251
  pos = "infix"
252
  return (pos, removed, inserted)
253
  def find_transformation_families(pairs):
254
- """Clusters GENUINELY PAIRED forms (same row only) sharing an identical
255
- clean edit signature. Emits a family only if 2+ separate given pairs
256
- share it."""
257
  groups = defaultdict(list)
258
  for a, b in pairs:
259
  if not a or not b or a == b:
@@ -271,11 +215,9 @@ def find_transformation_families(pairs):
271
  examples = "; ".join(f"{a}->{b}" for a, b in unique_pairs[:4])
272
  families.append((len(unique_pairs),
273
  f"{pos} change: '{removed_disp}' -> '{inserted_disp}' (seen in: {examples})"))
274
- families.sort(key=lambda x: -x[0]) # strongest support first
275
  return [f for _, f in families]
276
  def detect_reduplication(forms):
277
- """Flags a word only if it contains an exact adjacent doubled substring
278
- (length >= 2). Emits nothing if absent."""
279
  findings = []
280
  for w in forms:
281
  n = len(w)
@@ -292,8 +234,6 @@ def detect_reduplication(forms):
292
  break
293
  return findings
294
  def build_symbolic_evidence(context: str) -> str:
295
- """The full symbolic layer. Returns "" if no supported transformation
296
- family and no reduplication is found. Never replaces context."""
297
  forms = extract_forms_from_context(context)
298
  pairs = extract_explicit_pairs(context)
299
  families = find_transformation_families(pairs) if pairs else []
@@ -313,11 +253,9 @@ def build_symbolic_evidence(context: str) -> str:
313
  "may be incomplete -- verify against the examples, do not trust blindly):\n"
314
  + "\n".join(lines))
315
  def build_messages(context, query, task_type):
316
- """The frozen single-call decomposition scaffold behind the accepted
317
- 0.083/0.0296/0.2323 baseline."""
318
  preamble, items, count_known = parse_items(query)
319
  guidance = TASK_GUIDANCE.get(task_type, DEFAULT_GUIDANCE)
320
- symbolic_evidence = build_symbolic_evidence(context) # "" if nothing found
321
  system = (
322
  "You solve puzzles about a language you have never seen. Everything you "
323
  "need is in the examples below. Use only the examples, not outside "
@@ -386,7 +324,6 @@ def build_repair_messages(query, n_items, bad_text):
386
  f"FINAL ANSWERS: answer1 | answer2"
387
  )
388
  return [{"role": "system", "content": system}, {"role": "user", "content": user}]
389
- # ---- Safe arithmetic: no exec(), no eval() of arbitrary code ----
390
  _ALLOWED_BINOPS = (pyast.Add, pyast.Sub, pyast.Mult)
391
  def safe_arithmetic(expr: str):
392
  try:
@@ -411,16 +348,16 @@ def safe_arithmetic(expr: str):
411
  return None
412
  return _eval(tree)
413
  def clean_answer(a: str) -> str:
414
- # Strips "Answer N:", "is:", "the answer is:", "final answer:".
 
 
 
 
415
  a = re.sub(r"(?i)^\s*(the\s+)?(final\s+)?answer\s*\d*\s*(is)?\s*:\s*", "", a).strip()
416
  a = re.sub(r"(?i)^\s*is\s*:\s*", "", a).strip()
417
  a = a.strip("* ")
418
  return a.strip(" .\"'“”‘’")
419
  def extract(text):
420
- """Fixed against three real bugs found on real Linguini output:
421
- (1) markdown-bold marker with content on the NEXT line, not same line;
422
- (2) a following COMPUTE: line bleeding into the answer list;
423
- (3) NO marker found + all answers dumped on one pipe-separated line."""
424
  m = list(re.finditer(r"final answers?\s*:?\s*\**", text, flags=re.IGNORECASE))
425
  if m:
426
  tail = text[m[-1].end():]
@@ -432,7 +369,6 @@ def extract(text):
432
  parts = [clean_answer(p) for p in candidate.split("|") if p.strip()]
433
  if parts:
434
  return parts, m[-1].start()
435
- # Fallback (no marker found): split each line further by "|" if present.
436
  lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
437
  fallback = []
438
  for ln in lines:
@@ -453,15 +389,7 @@ def extract_compute_overrides(text, n_answers):
453
  if val is not None:
454
  overrides[i] = str(int(val)) if float(val).is_integer() else str(val)
455
  return overrides
456
- # ---- Generation: defensive against BOTH chat-template return shapes. ----
457
- def generate(messages, max_new_tokens, constraint_fn=None, sample=False, seed=None):
458
- """constraint_fn: optional prefix_allowed_tokens_fn, default None means
459
- byte-identical behavior to every existing call site. If a constrained
460
- attempt fails for ANY reason, falls back to a fully UNCONSTRAINED
461
- generation. sample=True switches to Qwen's recommended stochastic
462
- settings (temp 0.7 / top_p 0.8 / top_k 20) for MBR candidate generation;
463
- the greedy default path is untouched, so every existing call is
464
- byte-identical to the 0.104 baseline."""
465
  def _try_generate(gen_kwargs):
466
  try:
467
  enc = tok.apply_chat_template(
@@ -478,15 +406,7 @@ def generate(messages, max_new_tokens, constraint_fn=None, sample=False, seed=No
478
  with torch.no_grad():
479
  out = model.generate(ids, **gen_kwargs)
480
  return out, input_len
481
- if sample:
482
- if seed is not None:
483
- torch.manual_seed(seed)
484
- if torch.cuda.is_available():
485
- torch.cuda.manual_seed_all(seed)
486
- base_kwargs = {"max_new_tokens": max_new_tokens, "do_sample": True,
487
- "temperature": 0.7, "top_p": 0.8, "top_k": 20}
488
- else:
489
- base_kwargs = {"max_new_tokens": max_new_tokens, "do_sample": False}
490
  if constraint_fn is not None:
491
  try:
492
  out, input_len = _try_generate({**base_kwargs, "prefix_allowed_tokens_fn": constraint_fn})
@@ -501,36 +421,7 @@ EXPLANATION_SYSTEM = (
501
  "Be concise and structured -- do not repeat the full reasoning."
502
  )
503
  EXPLANATION_FALLBACK = "Answer derived from patterns found in the examples above."
504
- _LETTER_CONSTRAINT_CACHE = {}
505
- def build_letter_constraint_fn(tok, valid_letters):
506
- cache_key = (id(tok), tuple(sorted(valid_letters)))
507
- if cache_key in _LETTER_CONSTRAINT_CACHE:
508
- return _LETTER_CONSTRAINT_CACHE[cache_key]
509
- try:
510
- allowed_chars = set(valid_letters) | set(" |\n\t\r")
511
- eos = tok.eos_token_id
512
- pieces = []
513
- for token_id in range(len(tok)):
514
- if token_id == eos:
515
- continue
516
- piece = tok.decode([token_id], skip_special_tokens=False)
517
- if piece and all(c in allowed_chars for c in piece):
518
- pieces.append(token_id)
519
- allowed_ids = ([eos] if eos is not None else []) + pieces
520
- def allowed(_batch_id, _input_ids):
521
- return allowed_ids if allowed_ids else list(range(len(tok)))
522
- _LETTER_CONSTRAINT_CACHE[cache_key] = allowed
523
- return allowed
524
- except Exception:
525
- return None
526
- # =============================================================================
527
- # closed-answer-space pre-constraint, match_letters only. Deterministic,
528
- # read-only. Extracts the closed set of option letters genuinely present in
529
- # the context and states that exact set as a hard constraint. FAIL-OPEN.
530
- # =============================================================================
531
  def extract_match_letter_options(context: str):
532
- """Returns a sorted list of option letters if extraction is CLEAN and
533
- UNAMBIGUOUS, else None. Deliberately strict: this must never guess."""
534
  found = set()
535
  for line in context.splitlines():
536
  for m in re.finditer(r"(?:^|\s)([A-Z])[.\)]\s+\S", line):
@@ -545,67 +436,11 @@ def extract_match_letter_options(context: str):
545
  return None
546
  return letters
547
  # =============================================================================
548
- # TRANSLATION: chrF-MBR (Minimum Bayes Risk) item-level selection.
549
- # We are scored on chrF; MBR with a chrF utility provably optimizes chrF
550
- # (Freitag et al. TACL 2022). Instead of voting on the exact string (which
551
- # almost never agrees on free-form translations), we pick, PER ITEM, the
552
- # candidate whose mean chrF against the other candidates is highest -- the
553
- # "chrF-central" answer. That central answer tends to be both the highest-chrF
554
- # AND the likeliest exact match, so it lifts both scored components at once.
555
- # Pure stdlib, no dependencies. The greedy candidate is always in the pool, so
556
- # MBR can only match or beat greedy, never regress it.
557
- # =============================================================================
558
- def _char_ngrams(s, n):
559
- return Counter(s[i:i + n] for i in range(len(s) - n + 1)) if len(s) >= n else Counter()
560
- def chrf_score(hyp, ref, max_n=6, beta=2.0):
561
- """Character n-gram F-beta (chrF), the same family the grader uses. Light
562
- normalization (lowercase, collapse whitespace) so it rewards content, not
563
- incidental spacing/case. Returns 1.0 on exact post-normalization match."""
564
- hyp = re.sub(r"\s+", " ", (hyp or "").strip().lower())
565
- ref = re.sub(r"\s+", " ", (ref or "").strip().lower())
566
- if hyp == ref:
567
- return 1.0
568
- if not hyp or not ref:
569
- return 0.0
570
- b2 = beta * beta
571
- total, counted = 0.0, 0
572
- for n in range(1, max_n + 1):
573
- h, r = _char_ngrams(hyp, n), _char_ngrams(ref, n)
574
- if not h or not r:
575
- continue
576
- overlap = sum((h & r).values())
577
- p = overlap / max(sum(h.values()), 1)
578
- rec = overlap / max(sum(r.values()), 1)
579
- f = 0.0 if (p + rec) == 0 else (1 + b2) * p * rec / (b2 * p + rec)
580
- total += f
581
- counted += 1
582
- return total / counted if counted else 0.0
583
- def mbr_select_item(cands):
584
- """Pick the chrF-central candidate for one item. Ties keep the first
585
- (which is the greedy answer, since it is inserted first)."""
586
- cands = [c for c in cands if c is not None]
587
- if not cands:
588
- return ""
589
- if len(set(cands)) == 1:
590
- return cands[0]
591
- best, best_score = cands[0], -1.0
592
- for i, c in enumerate(cands):
593
- others = [cands[j] for j in range(len(cands)) if j != i]
594
- score = sum(chrf_score(c, o) for o in others) / max(len(others), 1)
595
- if score > best_score:
596
- best, best_score = c, score
597
- return best
598
- # =============================================================================
599
- # MATCH_LETTERS: reason first, then a bijection-CONSTRAINED answer pass.
600
- # The main greedy generation is left to reason freely and produce FINAL
601
- # ANSWERS. Then, ONLY for match_letters, we run a short second pass whose
602
- # decoding grammar (a) allows only valid option letters + separators + EOS,
603
- # and (b) when #letters == #items (a clean bijection), forbids any letter
604
- # already emitted -- so the output physically cannot repeat or skip a letter.
605
- # This is the structural half of how humans solve matching puzzles
606
- # (anchor + elimination), enforced at the decoder. FAIL-OPEN everywhere:
607
- # any failure or a non-bijection problem falls straight back to the greedy
608
- # answers, so this can only help.
609
  # =============================================================================
610
  _LETTER_TOKEN_CACHE = {}
611
  def _letter_token_map(tok, labels):
@@ -631,14 +466,12 @@ def build_answer_only_messages(query, reasoning_text, labels, n_items, bijection
631
  "No numbering, no words, no punctuation.")
632
  user = (
633
  f"Task:\n{query.strip()}\n\n"
634
- f"Your reasoning:\n{reasoning_text[:1200]}\n\n"
635
  f"Valid letters: {', '.join(labels)}. Give exactly {n_items} letters, one per "
636
  f"line, in item order. Each item takes exactly one letter{uniq}."
637
  )
638
  return [{"role": "system", "content": system}, {"role": "user", "content": user}]
639
  def generate_constrained_letters(messages, labels, n_items, bijection, max_new_tokens=96):
640
- """Own encode/generate so the stateful constraint knows where the prompt
641
- ends. Returns a list of letters or None on any failure (fail-open)."""
642
  labels = set(labels)
643
  try:
644
  try:
@@ -704,13 +537,7 @@ try:
704
  if idx < len(answers):
705
  answers[idx] = val
706
  if (marker_pos is None or not answers) and remaining > SETUP_BUFFER_S:
707
- repair_constraint = None
708
- if task_type == "match_letters":
709
- repair_options = extract_match_letter_options(r["context"])
710
- if repair_options:
711
- repair_constraint = build_letter_constraint_fn(tok, repair_options)
712
- repair_text = generate(build_repair_messages(r["query"], n_items, text), 128,
713
- constraint_fn=repair_constraint)
714
  rep, rep_pos = extract(repair_text)
715
  if rep:
716
  answers, marker_pos = rep, rep_pos
@@ -719,14 +546,12 @@ try:
719
  answers = answers + [answers[-1] if answers else ""] * (n_items - len(answers))
720
  elif len(answers) > n_items and marker_pos is None:
721
  answers = answers[:n_items]
722
- # else: marker found, more answers than our guess -> KEEP THEM ALL
723
  if not answers:
724
  answers = [""]
725
- # ---- TASK-SPECIFIC REFINEMENT (translation MBR / match_letters bijection) ----
726
- # match_letters: reason (already done above) -> bijection-constrained answer pass.
727
  if task_type == "match_letters" and n_items:
728
  labels = extract_match_letter_options(r["context"])
729
- if labels and len(labels) >= 2:
730
  bijection = (len(labels) == n_items)
731
  refined = generate_constrained_letters(
732
  build_answer_only_messages(r["query"], text, labels, n_items, bijection),
@@ -734,25 +559,6 @@ try:
734
  )
735
  if refined and len(refined) == n_items:
736
  answers = refined
737
- # translation: chrF-MBR, but only when we can afford ~2 extra full
738
- # generations without eating another row's budget. Time-gated so it
739
- # never causes a timeout; greedy answer is always candidate #1.
740
- elif task_type == "translation" and n_items and n_items > 0:
741
- remaining_now = TIME_LIMIT_S - (time.time() - start_time)
742
- rows_left = max(n_rows - len(rows), 1)
743
- afford_mbr = remaining_now > rows_left * per_row_budget * 2.2
744
- if afford_mbr and n_items <= 20:
745
- cand_lists = [answers]
746
- for s in range(2):
747
- stext = generate(messages, tokens_cap, sample=True, seed=1234 + s)
748
- sa, _ = extract(stext)
749
- if len(sa) >= n_items:
750
- cand_lists.append(sa[:n_items])
751
- elif sa:
752
- cand_lists.append(sa + [sa[-1]] * (n_items - len(sa)))
753
- if len(cand_lists) >= 2:
754
- answers = [mbr_select_item([cl[i] for cl in cand_lists if i < len(cl)])
755
- for i in range(n_items)]
756
  remaining_after = TIME_LIMIT_S - (time.time() - start_time)
757
  budget_left_after = max(n_rows - len(rows) - 1, 0)
758
  comfortable = remaining_after > (budget_left_after + 1) * per_row_budget * 1.3
@@ -786,7 +592,6 @@ try:
786
  if time.time() - start_time > TIME_LIMIT_S - 60:
787
  print("Time budget nearly exhausted, stopping early.", flush=True)
788
  break
789
- # Guarantee one row per test.csv id, even under a timeout.
790
  for _, r in df.iterrows():
791
  if r["id"] in processed_ids:
792
  continue
@@ -800,7 +605,5 @@ try:
800
  write_submission_csv(rows)
801
  print("DONE.", flush=True)
802
  except Exception as e:
803
- # Final safety net: even if something escapes every inner try/except
804
- # above, whatever rows were collected so far still get written.
805
  emergency_submission_csv(f"main loop failed: {e}", rows_so_far=rows if rows else None)
806
  print(f"FATAL, but submission.csv was written with {len(rows)} rows. Error: {e}", flush=True)
 
1
+ # script.py — PROVEN bnb Qwen2.5-14B (0.104) + two SAFE, deterministic
2
+ # improvements only. No sampling, no MBR (that was the -0.07 regression).
 
3
  # =============================================================================
4
+ # Base = the exact 0.104 pipeline (Qwen2.5-14B-Instruct-bnb-4bit, install only
5
+ # bitsandbytes --no-deps so the networking stack is untouched).
6
+ # ADDED (both deterministic, both fail-open, neither can tank exact_match):
7
+ # 1. match_letters BIJECTION-CONSTRAINED answer pass: after the model
8
+ # reasons, a short second pass regenerates just the letters under a
9
+ # decoder grammar that allows only valid option letters and — when
10
+ # #letters == #items — forbids any letter already used, so it cannot
11
+ # repeat or skip. Non-bijection / any failure -> fall back to greedy.
12
+ # 2. NFC unicode normalization in clean_answer: composes look-identical
13
+ # code points to their canonical form so a correct answer isn't lost to
14
+ # an invisible encoding difference. Purely canonical (does NOT swap
15
+ # characters), so it cannot corrupt puzzle-orthography answers.
16
  # =============================================================================
17
  import os
18
  import atexit
19
+ import unicodedata
 
20
  _ORIGINAL_HF_HUB_OFFLINE = os.environ.get("HF_HUB_OFFLINE")
21
  _ORIGINAL_TRANSFORMERS_OFFLINE = os.environ.get("TRANSFORMERS_OFFLINE")
22
  def _restore_offline_env_vars():
 
 
 
 
23
  for key, original in (("HF_HUB_OFFLINE", _ORIGINAL_HF_HUB_OFFLINE),
24
  ("TRANSFORMERS_OFFLINE", _ORIGINAL_TRANSFORMERS_OFFLINE)):
25
  if original is None:
 
31
  os.environ["TRANSFORMERS_OFFLINE"] = "1"
32
  import subprocess, sys
33
  def emergency_submission_csv(reason, rows_so_far=None):
 
 
34
  try:
35
  import pandas as pd
36
  if rows_so_far:
 
46
  "explanation": f"EMERGENCY FALLBACK: {str(reason)[:150]}"} for i in ids]
47
  pd.DataFrame(rows, columns=["id", "pred", "explanation"]).to_csv("submission.csv", index=False)
48
  except Exception:
 
49
  try:
50
  with open("submission.csv", "w") as f:
51
  f.write("id,pred,explanation\n")
52
  except Exception:
53
  pass
54
  def write_submission_csv(rows_list):
 
 
 
 
55
  import csv as _csv
56
  tmp_path = "submission.csv.tmp"
57
  with open(tmp_path, "w", newline="", encoding="utf-8") as f:
 
61
  w.writerow(row)
62
  os.replace(tmp_path, "submission.csv")
63
  try:
 
 
 
 
 
 
 
 
 
 
 
64
  subprocess.run([sys.executable, "-m", "pip", "install", "-q",
65
  "--no-deps", "bitsandbytes"], check=True)
66
  except Exception as e:
 
72
  from transformers import AutoTokenizer, AutoModelForCausalLM
73
  MODEL_ID = "."
74
  TIME_LIMIT_S = 30 * 60
75
+ SETUP_BUFFER_S = 420
76
  start_time = time.time()
77
  try:
 
 
 
 
78
  df = pd.read_csv("/tmp/data/test.csv", dtype=str).fillna("")
79
  placeholder_rows = [{"id": rid, "pred": json.dumps([""]),
80
  "explanation": "Placeholder written before model load."}
 
85
  tok = AutoTokenizer.from_pretrained(MODEL_ID)
86
  print("Tokenizer loaded (fast).", flush=True)
87
  except Exception as e:
 
 
 
 
88
  print(f"Fast tokenizer failed ({e}); falling back to use_fast=False.", flush=True)
89
  tok = AutoTokenizer.from_pretrained(MODEL_ID, use_fast=False)
90
  print("Tokenizer loaded (slow fallback).", flush=True)
 
97
  emergency_submission_csv(f"tokenizer/model load or test.csv read failed: {e}")
98
  raise
99
  n_rows = len(df)
 
 
 
100
  actual_setup_elapsed = time.time() - start_time
101
  per_row_budget = max(20, (TIME_LIMIT_S - actual_setup_elapsed) / max(n_rows, 1))
102
+ print(f"Setup took {actual_setup_elapsed:.0f}s | per_row_budget={per_row_budget:.0f}s "
103
+ f"for {n_rows} rows", flush=True)
104
+ # ---- Query parsing ----
105
  def parse_items(query: str):
 
 
 
106
  item_pat = re.compile(r"(?m)^\s*(\d+)\s*[.\)]\s*(.*)$")
107
  matches = list(item_pat.finditer(query))
108
  if matches:
 
144
  "num_to_text": "give the number written out in words, in the language asked.",
145
  }
146
  DEFAULT_GUIDANCE = "give exactly what the instruction asks, nothing else."
 
 
 
 
 
 
147
  from difflib import SequenceMatcher
148
+ from collections import defaultdict
149
  def extract_forms_from_context(context: str):
 
 
 
 
 
150
  forms = []
151
  for line in context.splitlines():
152
  line = line.strip()
 
169
  unique_forms.append(f)
170
  return unique_forms
171
  def extract_explicit_pairs(context: str):
 
 
 
172
  pairs = []
173
  for line in context.splitlines():
174
  line = line.strip()
 
180
  pairs.append((fields[0], fields[1]))
181
  return pairs
182
  def edit_signature(a: str, b: str):
 
 
 
183
  sm = SequenceMatcher(None, a, b, autojunk=False)
184
  all_ops = sm.get_opcodes()
185
  ops = [op for op in all_ops if op[0] != "equal"]
 
198
  pos = "infix"
199
  return (pos, removed, inserted)
200
  def find_transformation_families(pairs):
 
 
 
201
  groups = defaultdict(list)
202
  for a, b in pairs:
203
  if not a or not b or a == b:
 
215
  examples = "; ".join(f"{a}->{b}" for a, b in unique_pairs[:4])
216
  families.append((len(unique_pairs),
217
  f"{pos} change: '{removed_disp}' -> '{inserted_disp}' (seen in: {examples})"))
218
+ families.sort(key=lambda x: -x[0])
219
  return [f for _, f in families]
220
  def detect_reduplication(forms):
 
 
221
  findings = []
222
  for w in forms:
223
  n = len(w)
 
234
  break
235
  return findings
236
  def build_symbolic_evidence(context: str) -> str:
 
 
237
  forms = extract_forms_from_context(context)
238
  pairs = extract_explicit_pairs(context)
239
  families = find_transformation_families(pairs) if pairs else []
 
253
  "may be incomplete -- verify against the examples, do not trust blindly):\n"
254
  + "\n".join(lines))
255
  def build_messages(context, query, task_type):
 
 
256
  preamble, items, count_known = parse_items(query)
257
  guidance = TASK_GUIDANCE.get(task_type, DEFAULT_GUIDANCE)
258
+ symbolic_evidence = build_symbolic_evidence(context)
259
  system = (
260
  "You solve puzzles about a language you have never seen. Everything you "
261
  "need is in the examples below. Use only the examples, not outside "
 
324
  f"FINAL ANSWERS: answer1 | answer2"
325
  )
326
  return [{"role": "system", "content": system}, {"role": "user", "content": user}]
 
327
  _ALLOWED_BINOPS = (pyast.Add, pyast.Sub, pyast.Mult)
328
  def safe_arithmetic(expr: str):
329
  try:
 
348
  return None
349
  return _eval(tree)
350
  def clean_answer(a: str) -> str:
351
+ # NFC normalization first: composes visually-identical code points to a
352
+ # canonical form so a correct answer isn't lost to an invisible encoding
353
+ # difference. Canonical composition only -- never swaps a character for a
354
+ # different one, so puzzle-orthography answers stay intact.
355
+ a = unicodedata.normalize("NFC", a)
356
  a = re.sub(r"(?i)^\s*(the\s+)?(final\s+)?answer\s*\d*\s*(is)?\s*:\s*", "", a).strip()
357
  a = re.sub(r"(?i)^\s*is\s*:\s*", "", a).strip()
358
  a = a.strip("* ")
359
  return a.strip(" .\"'“”‘’")
360
  def extract(text):
 
 
 
 
361
  m = list(re.finditer(r"final answers?\s*:?\s*\**", text, flags=re.IGNORECASE))
362
  if m:
363
  tail = text[m[-1].end():]
 
369
  parts = [clean_answer(p) for p in candidate.split("|") if p.strip()]
370
  if parts:
371
  return parts, m[-1].start()
 
372
  lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
373
  fallback = []
374
  for ln in lines:
 
389
  if val is not None:
390
  overrides[i] = str(int(val)) if float(val).is_integer() else str(val)
391
  return overrides
392
+ def generate(messages, max_new_tokens, constraint_fn=None):
 
 
 
 
 
 
 
 
393
  def _try_generate(gen_kwargs):
394
  try:
395
  enc = tok.apply_chat_template(
 
406
  with torch.no_grad():
407
  out = model.generate(ids, **gen_kwargs)
408
  return out, input_len
409
+ base_kwargs = {"max_new_tokens": max_new_tokens, "do_sample": False}
 
 
 
 
 
 
 
 
410
  if constraint_fn is not None:
411
  try:
412
  out, input_len = _try_generate({**base_kwargs, "prefix_allowed_tokens_fn": constraint_fn})
 
421
  "Be concise and structured -- do not repeat the full reasoning."
422
  )
423
  EXPLANATION_FALLBACK = "Answer derived from patterns found in the examples above."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  def extract_match_letter_options(context: str):
 
 
425
  found = set()
426
  for line in context.splitlines():
427
  for m in re.finditer(r"(?:^|\s)([A-Z])[.\)]\s+\S", line):
 
436
  return None
437
  return letters
438
  # =============================================================================
439
+ # MATCH_LETTERS: reason first (main pass), then a bijection-CONSTRAINED answer
440
+ # pass. Grammar allows only valid option letters + separators + EOS, and when
441
+ # #letters == #items forbids any letter already emitted, so the output cannot
442
+ # repeat or skip a letter. FAIL-OPEN: any failure or non-bijection problem
443
+ # falls straight back to the greedy answers, so this can only help.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  # =============================================================================
445
  _LETTER_TOKEN_CACHE = {}
446
  def _letter_token_map(tok, labels):
 
466
  "No numbering, no words, no punctuation.")
467
  user = (
468
  f"Task:\n{query.strip()}\n\n"
469
+ f"Your reasoning:\n{reasoning_text[:1400]}\n\n"
470
  f"Valid letters: {', '.join(labels)}. Give exactly {n_items} letters, one per "
471
  f"line, in item order. Each item takes exactly one letter{uniq}."
472
  )
473
  return [{"role": "system", "content": system}, {"role": "user", "content": user}]
474
  def generate_constrained_letters(messages, labels, n_items, bijection, max_new_tokens=96):
 
 
475
  labels = set(labels)
476
  try:
477
  try:
 
537
  if idx < len(answers):
538
  answers[idx] = val
539
  if (marker_pos is None or not answers) and remaining > SETUP_BUFFER_S:
540
+ repair_text = generate(build_repair_messages(r["query"], n_items, text), 128)
 
 
 
 
 
 
541
  rep, rep_pos = extract(repair_text)
542
  if rep:
543
  answers, marker_pos = rep, rep_pos
 
546
  answers = answers + [answers[-1] if answers else ""] * (n_items - len(answers))
547
  elif len(answers) > n_items and marker_pos is None:
548
  answers = answers[:n_items]
 
549
  if not answers:
550
  answers = [""]
551
+ # ---- SAFE REFINEMENT: match_letters bijection-constrained pass ----
 
552
  if task_type == "match_letters" and n_items:
553
  labels = extract_match_letter_options(r["context"])
554
+ if labels and len(labels) >= 2 and remaining > SETUP_BUFFER_S:
555
  bijection = (len(labels) == n_items)
556
  refined = generate_constrained_letters(
557
  build_answer_only_messages(r["query"], text, labels, n_items, bijection),
 
559
  )
560
  if refined and len(refined) == n_items:
561
  answers = refined
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
  remaining_after = TIME_LIMIT_S - (time.time() - start_time)
563
  budget_left_after = max(n_rows - len(rows) - 1, 0)
564
  comfortable = remaining_after > (budget_left_after + 1) * per_row_budget * 1.3
 
592
  if time.time() - start_time > TIME_LIMIT_S - 60:
593
  print("Time budget nearly exhausted, stopping early.", flush=True)
594
  break
 
595
  for _, r in df.iterrows():
596
  if r["id"] in processed_ids:
597
  continue
 
605
  write_submission_csv(rows)
606
  print("DONE.", flush=True)
607
  except Exception as e:
 
 
608
  emergency_submission_csv(f"main loop failed: {e}", rows_so_far=rows if rows else None)
609
  print(f"FATAL, but submission.csv was written with {len(rows)} rows. Error: {e}", flush=True)