Santhoshini commited on
Commit
59debaa
·
verified ·
1 Parent(s): f6e7cb6

Update script.py

Browse files
Files changed (1) hide show
  1. script.py +276 -263
script.py CHANGED
@@ -5,6 +5,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent if "__file__" in globals() else Pat
5
  WHEELHOUSE = SCRIPT_DIR / "wheelhouse"
6
  if not WHEELHOUSE.is_dir(): WHEELHOUSE = Path("wheelhouse")
7
  RUNTIME_DIR = Path("/tmp/qwen3deps")
 
8
  def emergency(reason):
9
  try:
10
  import pandas as pd, json as j
@@ -15,6 +16,7 @@ def emergency(reason):
15
  except Exception:
16
  try: open("submission.csv","w").write("id,pred,explanation\n")
17
  except Exception: pass
 
18
  try:
19
  wheels = [str(WHEELHOUSE / w) for w in os.listdir(WHEELHOUSE) if w.endswith(".whl")]
20
  if not wheels: raise FileNotFoundError(f"no wheels in {WHEELHOUSE}")
@@ -26,21 +28,28 @@ try:
26
  except Exception: pass
27
  except Exception as e:
28
  emergency(f"wheel install failed: {e}"); raise
29
- import re, json, time, ast as pyast
 
30
  import pandas as pd, torch
31
- from difflib import SequenceMatcher
32
- from collections import defaultdict
33
  from transformers import AutoTokenizer, AutoModelForCausalLM
34
- MODEL_ID="."; TIME_LIMIT=30*60; start=time.time()
35
- # Extra deterministic passes only run while this much time remains, measured
36
- # FRESHLY at each decision point (never a stale snapshot from earlier in the row).
37
- REPAIR_RESERVE=120
 
 
 
 
 
 
38
  def write_csv(rows):
39
  import csv
40
  with open("submission.csv.tmp","w",newline="",encoding="utf-8") as f:
41
- w=csv.DictWriter(f,fieldnames=["id","pred","explanation"]); w.writeheader()
 
42
  for r in rows: w.writerow(r)
43
  os.replace("submission.csv.tmp","submission.csv")
 
44
  try:
45
  df = pd.read_csv("/tmp/data/test.csv", dtype=str).fillna("")
46
  write_csv([{"id":i,"pred":json.dumps([""]),"explanation":"placeholder"} for i in df["id"]])
@@ -50,274 +59,278 @@ try:
50
  print("loaded, quantized:", getattr(model.config,"quantization_config",None) is not None, flush=True)
51
  except Exception as e:
52
  emergency(f"load failed: {e}"); raise
53
- SYS=("You solve International Linguistics Olympiad problems about a language you have never seen. "
54
- "Everything you need is in the examples. Answer every numbered item, in order. "
55
- "Put each answer on its own line, with no numbering and no extra text.")
 
 
56
  def n_expected(query):
57
- items=re.findall(r"(?m)^\s*(\d+)\s*[.\)]", query)
58
  if items: return len(items)
59
- rng=re.search(r"\(\s*(\d+)\s*[-–—]\s*(\d+)\s*\)", query)
60
  if rng:
61
- lo,hi=int(rng.group(1)),int(rng.group(2))
62
- if 0<hi-lo<100: return hi-lo+1
63
  return None
64
- def norm_generic(s):
65
- s = unicodedata.normalize("NFC", s).strip()
66
- s = re.sub(r"^\s*\(?\d+\)?\s*[.):\-]\s+", "", s)
67
- s = re.sub(r"^\s*[-*•·]\s+", "", s)
68
- s = re.sub(r"(?i)^\s*(answer|translation|output)\s*\d*\s*[:.\-]\s+", "", s)
69
- s = s.strip("* ")
70
- s = re.sub(r"\s{2,}", " ", s)
71
- return s.strip()
72
- def norm_number(s):
73
- g = norm_generic(s)
74
- m = re.search(r"-?\d[\d,\. ]*\d|\d", g)
75
- if not m: return g
76
- digits = re.sub(r"[^\d-]", "", m.group(0))
77
- return digits if digits else g
78
- def normalize(task_type, s):
79
- if task_type == "text_to_num":
80
- return norm_number(s)
81
- return norm_generic(s)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
  # ---------------------------------------------------------------------------
84
- # Generation. gen_msgs() is the general form (needed by the two repair
85
- # passes below); gen() is the exact proven baseline call, unchanged.
86
  # ---------------------------------------------------------------------------
87
- def gen_msgs(msgs, max_new_tokens=512):
 
 
88
  try:
89
- enc=tok.apply_chat_template(msgs,add_generation_prompt=True,enable_thinking=False,
90
- return_tensors="pt",return_dict=True).to(model.device)
91
- ilen=enc["input_ids"].shape[-1]
92
- with torch.no_grad(): out=model.generate(**enc,max_new_tokens=max_new_tokens,do_sample=False)
93
  except Exception:
94
- ids=tok.apply_chat_template(msgs,add_generation_prompt=True,enable_thinking=False,
95
- return_tensors="pt").to(model.device)
96
- ilen=ids.shape[-1]
97
- with torch.no_grad(): out=model.generate(ids,max_new_tokens=max_new_tokens,do_sample=False)
98
- return tok.decode(out[0][ilen:],skip_special_tokens=True).strip()
99
- def gen(context, query):
100
- return gen_msgs([{"role":"system","content":SYS},
101
- {"role":"user","content":f"{context.strip()}\n\n{query.strip()}"}])
102
-
103
- # ===========================================================================
104
- # ALGORITHM 1 -- match_letters: bijection conflict detection + ONE targeted
105
- # repair. A matching must be one-to-one, so any reused letter is a
106
- # guaranteed wrong answer for at least one item, independent of any
107
- # language knowledge. Never worse: the repair is accepted only if it
108
- # actually resolves the conflict and has the right shape.
109
- # ===========================================================================
110
- def find_bijection_conflicts(answers):
111
- pos={}
112
- for i,a in enumerate(answers):
113
- if a: pos.setdefault(a,[]).append(i)
114
- return {v:idxs for v,idxs in pos.items() if len(idxs)>1}
115
- def repair_bijection(answers, context, query):
116
- conflicts=find_bijection_conflicts(answers)
117
- if not conflicts: return answers
118
- idxs=sorted({i for v in conflicts.values() for i in v})
119
- used=sorted({answers[i] for i in range(len(answers)) if i not in idxs and answers[i]})
120
- msgs=[{"role":"system","content":"You fix a one-to-one matching. Output only the requested letters, one per line."},
121
- {"role":"user","content":(f"{context.strip()}\n\n{query.strip()}\n\n"
122
- f"Items {', '.join(str(i+1) for i in idxs)} were all given the SAME answer, "
123
- f"which is impossible in a one-to-one matching.\n"
124
- f"Already used elsewhere (do not reuse): {', '.join(used) if used else '(none)'}\n"
125
- f"Give a DIFFERENT answer for each of those {len(idxs)} items, in order, "
126
- f"one per line, nothing else.")}]
127
- try: rep=[norm_generic(l) for l in gen_msgs(msgs,96).splitlines() if l.strip()]
128
- except Exception: return answers
129
- if len(rep)!=len(idxs): return answers
130
- cand=list(answers)
131
- for i,v in zip(idxs,rep): cand[i]=v
132
- return cand if not find_bijection_conflicts(cand) else answers
133
-
134
- # ===========================================================================
135
- # ALGORITHM 2 -- fill_blanks: extract the strongest transformation family
136
- # from the context's OWN given pairs, then apply it MECHANICALLY to each
137
- # query stem. An exact computation beats a free-text guess. Emits nothing
138
- # unless a family is supported by 2+ independent pairs AND shares a real
139
- # 2-char stem -- one occurrence is indistinguishable from coincidence.
140
- # ===========================================================================
141
- def extract_explicit_pairs(context):
142
- pairs=[]
143
- for line in context.splitlines():
144
- line=line.strip()
145
- if not (0<line.count("|")<=3): continue
146
- fields=[re.sub(r"^\s*\d+\s*[.\)]\s*","",f.strip()).strip() for f in line.split("|")]
147
- fields=[f for f in fields if f]
148
- if len(fields)>=2: pairs.append((fields[0],fields[1]))
149
- return pairs
150
- def edit_signature(a,b):
151
- sm=SequenceMatcher(None,a,b,autojunk=False); allops=sm.get_opcodes()
152
- ops=[o for o in allops if o[0]!="equal"]
153
- if not ops or len(ops)>2: return None
154
- if sum((i2-i1) for t,i1,i2,j1,j2 in allops if t=="equal")<2: return None
155
- t,i1,i2,j1,j2=ops[0]
156
- if i1==0: pos="prefix"
157
- elif i2==len(a): pos="suffix"
158
- else: pos="infix"
159
- return (pos,a[i1:i2],b[j1:j2])
160
- def strongest_family(pairs):
161
- groups=defaultdict(list)
162
- for a,b in pairs:
163
- if not a or not b or a==b: continue
164
- sig=edit_signature(a,b)
165
- if sig: groups[sig].append((a,b))
166
- fams=[(len(list(dict.fromkeys(g))),s) for s,g in groups.items()
167
- if len(list(dict.fromkeys(g)))>=2]
168
- if not fams: return None
169
- fams.sort(key=lambda x:-x[0])
170
- return fams[0][1]
171
- def apply_transformation(sig,s):
172
- pos,removed,inserted=sig
173
- if pos=="prefix":
174
- if removed=="": return inserted+s
175
- return inserted+s[len(removed):] if s.startswith(removed) else None
176
- if pos=="suffix":
177
- if removed=="": return s+inserted
178
- return s[:-len(removed)]+inserted if s.endswith(removed) else None
179
- return None # infix: no safe anchor, deliberately never applied
180
- def fill_blanks_override(answers, context, query):
181
- pairs=extract_explicit_pairs(context)
182
- if not pairs: return answers
183
- sig=strongest_family(pairs)
184
- if not sig: return answers
185
- stems=[m.split("|")[0].strip()
186
- for m in re.findall(r"(?m)^\s*\d+\s*[.\)]\s*(.*)$", query)]
187
- if not stems: return answers
188
- out=list(answers)
189
- for i,stem in enumerate(stems):
190
- if i>=len(out) or not stem: continue
191
- mech=apply_transformation(sig,stem)
192
- if mech: out[i]=norm_generic(mech)
193
- return out
194
-
195
- # ===========================================================================
196
- # ALGORITHM 3 -- text_to_num: exact arithmetic verification. When the model
197
- # writes out its own arithmetic (e.g. "6*6+11"), evaluate it EXACTLY via an
198
- # AST walker (no exec/eval, +,-,* and parentheses only) and prefer the
199
- # computed value over the model's stated total. Catches the common case of
200
- # a correct rule with wrong mental arithmetic. Per-line, so index alignment
201
- # is guaranteed. No prompt change -- the winning prompt stays untouched.
202
- # ===========================================================================
203
- _ALLOWED_OPS=(pyast.Add,pyast.Sub,pyast.Mult)
204
- def safe_arithmetic(expr):
205
- try: tree=pyast.parse(expr.strip(),mode="eval")
206
- except Exception: return None
207
- def _ev(n):
208
- if isinstance(n,pyast.Expression): return _ev(n.body)
209
- if isinstance(n,pyast.Constant) and isinstance(n.value,(int,float)): return n.value
210
- if isinstance(n,pyast.BinOp) and isinstance(n.op,_ALLOWED_OPS):
211
- l,r=_ev(n.left),_ev(n.right)
212
- if l is None or r is None: return None
213
- if isinstance(n.op,pyast.Add): return l+r
214
- if isinstance(n.op,pyast.Sub): return l-r
215
- if isinstance(n.op,pyast.Mult): return l*r
216
- if isinstance(n,pyast.UnaryOp) and isinstance(n.op,pyast.USub):
217
- v=_ev(n.operand)
218
- return -v if v is not None else None
219
- return None
220
- return _ev(tree)
221
- def arithmetic_override(line):
222
- for c in sorted(re.findall(r"[0-9]+(?:\s*[\+\-\*]\s*[0-9()]+)+", line), key=len, reverse=True):
223
- v=safe_arithmetic(c)
224
- if v is not None and float(v).is_integer() and v>=0: return str(int(v))
225
- return None
226
 
227
- # ===========================================================================
228
- # ALGORITHM 4 -- translation INTO the unseen language: character-inventory
229
- # hallucination filter. A character that never appears anywhere in the
230
- # context cannot be part of a correct answer. Detection alone can't raise
231
- # EM, so a flagged row gets ONE targeted repair naming the constraint
232
- # (greedy decoding is deterministic -- an identical re-ask would be a
233
- # guaranteed no-op), and we keep whichever answer set has fewer
234
- # out-of-inventory characters. Strictly never worse.
235
- # Translating INTO English is deliberately excluded: English letters
236
- # legitimately won't appear in an unseen-language context, so the signal
237
- # would be meaningless there.
238
- # ===========================================================================
239
- _NEUTRAL=set(" \t.,;:!?'\"()[]-–—/0123456789\u2018\u2019\u201c\u201d")
240
- def out_of_inventory(answers, charset):
241
- return sum(1 for a in answers for ch in unicodedata.normalize("NFC",a)
242
- if ch not in _NEUTRAL and ch not in charset)
243
- def into_task_language(query, task_lang):
244
- q=query.lower()
245
- # Hard exclusion FIRST. Without this, a task_lang of "English" (or an
246
- # into-English query) satisfies the generic check below and the filter
247
- # would fire on exactly the case it must never touch -- flagging every
248
- # legitimate English answer as hallucinated and burning a wasted repair
249
- # call on each. Strictly worse than baseline; caught by an isolation test.
250
- if re.search(r"into\s+english", q): return False
251
- tl=(task_lang or "").strip().lower()
252
- if tl and tl != "english" and tl in q and "into" in q: return True
253
- return bool(re.search(r"translate .{0,40}into (?!english)", q))
254
- def inventory_repair(answers, context, query, n):
255
- charset=set(unicodedata.normalize("NFC",context))
256
- bad=out_of_inventory(answers,charset)
257
- if bad==0: return answers
258
- msgs=[{"role":"system","content":SYS},
259
- {"role":"user","content":(f"{context.strip()}\n\n{query.strip()}\n\n"
260
- f"Important: your answer must use ONLY characters that actually appear in the "
261
- f"examples above. Do not invent letters or symbols that are not shown there. "
262
- f"Answer again, one answer per line, nothing else.")}]
263
- try: rep=[norm_generic(l) for l in gen_msgs(msgs,512).splitlines() if l.strip()]
264
- except Exception: return answers
265
- if not rep: return answers
266
- if n:
267
- if len(rep)<n: rep=rep+[rep[-1]]*(n-len(rep))
268
- elif len(rep)>n: rep=rep[:n]
269
- return rep if out_of_inventory(rep,charset)<bad else answers
270
-
271
- rows=[]; done=set()
272
  try:
273
- for _,r in df.iterrows():
274
  try:
275
- task_type=r.get("task_type",""); task_lang=r.get("task_lang","")
276
- text=gen(r["context"],r["query"])
277
- raw=[ln for ln in text.splitlines() if ln.strip()]
278
- ans=[normalize(task_type,ln) for ln in raw]
279
-
280
- # ALGO 3 -- exact arithmetic, per line so indices stay aligned.
281
- if task_type=="text_to_num":
282
- for i,ln in enumerate(raw):
283
- if i>=len(ans): break
284
- v=arithmetic_override(ln)
285
- if v is not None: ans[i]=v
286
-
287
- n=n_expected(r["query"])
288
- if n:
289
- if len(ans)<n: ans=ans+[ans[-1] if ans else ""]*(n-len(ans))
290
- elif len(ans)>n: ans=ans[:n]
291
- if not ans: ans=[""]
292
-
293
- # ALGO 2 -- mechanical transformation override (no extra call).
294
- if task_type=="fill_blanks":
295
- ans=fill_blanks_override(ans,r["context"],r["query"])
296
-
297
- # ALGO 1 / ALGO 4 -- one targeted repair each, only if a real
298
- # defect was detected AND real remaining time (freshly measured)
299
- # allows it. Both are never-worse by construction.
300
- if time.time()-start < TIME_LIMIT-REPAIR_RESERVE:
301
- if task_type=="match_letters":
302
- ans=repair_bijection(ans,r["context"],r["query"])
303
- elif task_type=="translation" and into_task_language(r["query"],task_lang):
304
- ans=inventory_repair(ans,r["context"],r["query"],n)
305
-
306
- if not ans: ans=[""]
307
- expl=re.sub(r"\s+"," ",text[:300]).strip() or "derived from the examples"
308
- rows.append({"id":r["id"],"pred":json.dumps(ans,ensure_ascii=False),"explanation":expl})
309
  except Exception as e:
310
- n=n_expected(r["query"]) or 1
311
- rows.append({"id":r["id"],"pred":json.dumps([""]*n,ensure_ascii=False),"explanation":"fallback"})
312
- print("row error",r["id"],e,flush=True)
 
 
313
  done.add(r["id"]); write_csv(rows)
314
- print(f"{len(rows)}/{len(df)} t={time.time()-start:.0f}s",flush=True)
315
- if time.time()-start>TIME_LIMIT-60:
316
- print("time up, stopping",flush=True); break
317
- for _,r in df.iterrows():
 
 
318
  if r["id"] in done: continue
319
- n=n_expected(r["query"]) or 1
320
- rows.append({"id":r["id"],"pred":json.dumps([""]*n,ensure_ascii=False),"explanation":"fallback"})
321
- write_csv(rows); print("DONE",flush=True)
 
322
  except Exception as e:
323
- emergency(f"main loop: {e}"); print("FATAL",e,flush=True)
 
5
  WHEELHOUSE = SCRIPT_DIR / "wheelhouse"
6
  if not WHEELHOUSE.is_dir(): WHEELHOUSE = Path("wheelhouse")
7
  RUNTIME_DIR = Path("/tmp/qwen3deps")
8
+
9
  def emergency(reason):
10
  try:
11
  import pandas as pd, json as j
 
16
  except Exception:
17
  try: open("submission.csv","w").write("id,pred,explanation\n")
18
  except Exception: pass
19
+
20
  try:
21
  wheels = [str(WHEELHOUSE / w) for w in os.listdir(WHEELHOUSE) if w.endswith(".whl")]
22
  if not wheels: raise FileNotFoundError(f"no wheels in {WHEELHOUSE}")
 
28
  except Exception: pass
29
  except Exception as e:
30
  emergency(f"wheel install failed: {e}"); raise
31
+
32
+ import re, json, time
33
  import pandas as pd, torch
 
 
34
  from transformers import AutoTokenizer, AutoModelForCausalLM
35
+
36
+ MODEL_ID = "."
37
+ TIME_LIMIT = 30 * 60
38
+ SAFETY_S = 90
39
+ start = time.time()
40
+
41
+ LONG_THINK_TASKS = {"translation", "match_letters", "fill_blanks"}
42
+ LONG_TOTAL_TOKENS = 1536
43
+ THINK_CLOSE_TOKEN_ID = 151668 # </think> in Qwen3 tokenizer (documented)
44
+
45
  def write_csv(rows):
46
  import csv
47
  with open("submission.csv.tmp","w",newline="",encoding="utf-8") as f:
48
+ w = csv.DictWriter(f, fieldnames=["id","pred","explanation"])
49
+ w.writeheader()
50
  for r in rows: w.writerow(r)
51
  os.replace("submission.csv.tmp","submission.csv")
52
+
53
  try:
54
  df = pd.read_csv("/tmp/data/test.csv", dtype=str).fillna("")
55
  write_csv([{"id":i,"pred":json.dumps([""]),"explanation":"placeholder"} for i in df["id"]])
 
59
  print("loaded, quantized:", getattr(model.config,"quantization_config",None) is not None, flush=True)
60
  except Exception as e:
61
  emergency(f"load failed: {e}"); raise
62
+
63
+ SYS = ("You solve International Linguistics Olympiad problems about a language you have never seen. "
64
+ "Everything you need is in the examples. Answer every numbered item, in order. "
65
+ "Put each answer on its own line, with no numbering and no extra text.")
66
+
67
  def n_expected(query):
68
+ items = re.findall(r"(?m)^\s*(\d+)\s*[.\)]", query)
69
  if items: return len(items)
70
+ rng = re.search(r"\(\s*(\d+)\s*[-–—]\s*(\d+)\s*\)", query)
71
  if rng:
72
+ lo, hi = int(rng.group(1)), int(rng.group(2))
73
+ if 0 < hi - lo < 100: return hi - lo + 1
74
  return None
75
+
76
+ # =============================================================================
77
+ # PARSER -- designed as a compiler, not a regex patchwork.
78
+ # Replaces the prior line-based parser. Every rule justified by a specific
79
+ # failure mode derived from adversarial analysis of Qwen3 outputs.
80
+ #
81
+ # Failure modes handled that the old parser did NOT handle:
82
+ #
83
+ # 1. THINK BLOCK LEAKAGE (F1, F2): <think>...</think> and orphan </think>
84
+ # appear as text when thinking mode is active. Old parser: included the
85
+ # reasoning text as answers. New parser: strips all think content first.
86
+ #
87
+ # 2. PIPE-SEPARATED ANSWERS (A3): model emits "cat | dog | mouse" on one
88
+ # line (common because IOL contexts use | as field delimiter). Old parser:
89
+ # treats the whole line as one answer. New parser: tries pipe-split and
90
+ # selects it when count matches n.
91
+ #
92
+ # 3. COMMA-SEPARATED ANSWERS (A4): model emits "cat, dog, mouse". Old parser:
93
+ # one answer. New parser: tries comma-split for single-line short outputs.
94
+ #
95
+ # 4. SPACE-SEPARATED LETTERS FOR MATCHING (A6): model emits "A B C D" on one
96
+ # line. Old parser: one answer. New parser: splits when all tokens are
97
+ # single capital letters and task is match_letters.
98
+ #
99
+ # 5. PREAMBLE LINES (B5): "Here are the answers:\ncat\ndog". Old parser:
100
+ # "Here are the answers:" becomes the first answer. New parser: detects
101
+ # and discards preamble lines before applying count enforcement.
102
+ #
103
+ # 6. EXPLANATION LINES (B3): "cat\ndog\nThis is because...". Old parser with
104
+ # n=2: takes first 2 including explanation if it appears early. New parser:
105
+ # detects explanation lines and discards them.
106
+ #
107
+ # 7. TABLE FORMAT FOR MATCHING (B8): "| A | water |\n| B | child |". Old
108
+ # parser: pipe split disabled, each row becomes one answer. New parser:
109
+ # extracts the single-letter option from each table row.
110
+ #
111
+ # 8. ZERO-WIDTH SPACES AND NON-BREAKING SPACES (D2, D4): invisible Unicode
112
+ # characters that cause silent EM failure. Old parser: only NFC
113
+ # normalization, does not remove Cf category characters. New parser:
114
+ # explicitly strips zero-width space, ZWJ, ZWNJ, soft hyphen, BOM,
115
+ # and replaces non-breaking space with regular space.
116
+ #
117
+ # 9. ITEM N: PREFIX NOT STRIPPED (B10): "item 1: cat". Old parser: strips
118
+ # only answer/translation/output prefixes. New parser: also strips
119
+ # "item N:" prefix pattern.
120
+ #
121
+ # Failures NOT changed (old behavior preserved for these):
122
+ # - Numbered list "1. cat" → strips correctly (unchanged)
123
+ # - Bullet "- cat" → strips correctly (unchanged)
124
+ # - Answer: prefix → strips correctly (unchanged)
125
+ # - NFC normalization → preserved
126
+ # - text_to_num digit extraction → preserved
127
+ # =============================================================================
128
+
129
+ def _clean_unicode(s: str) -> str:
130
+ s = s.replace('\u00a0', ' ') # non-breaking space → space
131
+ s = s.replace('\u200b', '') # zero-width space
132
+ s = s.replace('\u200c', '') # zero-width non-joiner
133
+ s = s.replace('\u200d', '') # zero-width joiner
134
+ s = s.replace('\ufeff', '') # BOM
135
+ s = s.replace('\u00ad', '') # soft hyphen
136
+ return unicodedata.normalize("NFC", s)
137
+
138
+ def _strip_think(s: str) -> str:
139
+ s = re.sub(r'<think>.*?</think>', '', s, flags=re.DOTALL | re.IGNORECASE)
140
+ s = re.sub(r'</think>', '', s, flags=re.IGNORECASE)
141
+ s = re.sub(r'<think>', '', s, flags=re.IGNORECASE)
142
+ return s
143
+
144
+ _PREAMBLE = re.compile(
145
+ r"""(?ix)^(?:here\s+(?:are|is)\b|the\s+following\b|these\s+(?:are|follow)\b|
146
+ (?:answer|result|translation|output)s?\s*:|note\s*:|explanation\s*:)""")
147
+ _EXPLANATION = re.compile(
148
+ r"""(?ix)^(?:(?:this|it)\s+is\s+because\b|(?:this|the)\s+(?:pattern|rule|form)\b|
149
+ (?:note|explanation|reason|because)\s*:|therefore\b|
150
+ the\s+answer\s+(?:is|follows)\b|(?:we\s+can\s+see|observe\s+that)\b|
151
+ (?:in\s+this\s+language)\b|(?:looking\s+at)\b|(?:from\s+the\s+examples)\b)""")
152
+
153
+ def _is_preamble(line: str) -> bool:
154
+ s = line.strip()
155
+ return bool(s) and (s.endswith(':') and len(s) > 3 or bool(_PREAMBLE.match(s)))
156
+
157
+ def _is_explanation(line: str) -> bool:
158
+ return bool(_EXPLANATION.match(line.strip()))
159
+
160
+ def _norm_one(s: str, task_type: str) -> str:
161
+ s = _clean_unicode(s)
162
+ s = re.sub(r'^\s*\(?\d+\)?\s*[.):\-]\s+', '', s) # "1. " / "1) " / "(1) "
163
+ s = re.sub(r'^\s*[-*•·]\s+', '', s) # bullets
164
+ s = re.sub(r'(?i)^\s*(?:the\s+)?(?:final\s+)?'
165
+ r'(?:answer|translation|output|item)\s*\d*\s*[:.\-]\s*', '', s)
166
+ s = re.sub(r'(?i)^\s*is\s*:\s*', '', s)
167
+ s = s.strip('* ')
168
+ s = re.sub(r'[ \t]+', ' ', s)
169
+ s = s.strip()
170
+ s = s.strip('"\'"\u201c\u201d\u2018\u2019')
171
+ if task_type == 'text_to_num':
172
+ m = re.search(r'-?\d[\d,\. ]*\d|\d', s)
173
+ if m:
174
+ d = re.sub(r'[^\d-]', '', m.group(0))
175
+ if d: s = d
176
+ return s
177
+
178
+ def _table_match_answers(text: str, task_type: str) -> list | None:
179
+ if '|' not in text: return None
180
+ rows = [ln.strip() for ln in text.splitlines() if ln.strip() and '|' in ln]
181
+ if not rows: return None
182
+ result = []
183
+ for row in rows:
184
+ cells = [c.strip() for c in row.split('|') if c.strip()]
185
+ for cell in cells:
186
+ if re.match(r'^[A-Z]$', cell):
187
+ result.append(cell)
188
+ break
189
+ else:
190
+ if cells:
191
+ c = _norm_one(cells[0], task_type)
192
+ if c: result.append(c)
193
+ return result or None
194
+
195
+ def _segmentations(text: str, task_type: str) -> list:
196
+ cands = []
197
+ lines = [_norm_one(ln, task_type) for ln in text.splitlines()
198
+ if ln.strip() and _norm_one(ln.strip(), task_type)]
199
+ if lines: cands.append(lines)
200
+
201
+ if '|' in text:
202
+ parts = [_norm_one(p, task_type) for p in re.split(r'\s*\|\s*', text.strip()) if p.strip()]
203
+ parts = [p for p in parts if p]
204
+ if parts and parts != lines: cands.append(parts)
205
+
206
+ if ',' in text and '\n' not in text.strip():
207
+ parts = [_norm_one(p, task_type) for p in text.split(',') if p.strip()]
208
+ parts = [p for p in parts if p and len(p) <= 60]
209
+ if parts and parts != lines: cands.append(parts)
210
+
211
+ if task_type == 'match_letters':
212
+ tokens = text.strip().split()
213
+ if tokens and all(re.match(r'^[A-Z]$', t) for t in tokens) and tokens != lines:
214
+ cands.append(tokens)
215
+
216
+ return cands
217
+
218
+ def parse_answers(raw: str, task_type: str, n: int | None) -> list:
219
+ if not raw or not raw.strip():
220
+ return [""] * (n or 1)
221
+
222
+ text = _clean_unicode(raw)
223
+ text = _strip_think(text).strip()
224
+ if not text:
225
+ return [""] * (n or 1)
226
+
227
+ # Table extraction for match_letters
228
+ if task_type == 'match_letters' and '|' in text:
229
+ ta = _table_match_answers(text, task_type)
230
+ if ta and (n is None or len(ta) == n):
231
+ return ta
232
+
233
+ segs = _segmentations(text, task_type)
234
+ if not segs:
235
+ return [""] * (n or 1)
236
+
237
+ if n is not None:
238
+ # Pick first segmentation with exact count match
239
+ for seg in segs:
240
+ if len(seg) == n:
241
+ return seg
242
+ # Try filtering preamble/explanation lines
243
+ raw_lines = [ln for ln in text.splitlines() if ln.strip()]
244
+ filtered = [ln for ln in raw_lines if not _is_preamble(ln) and not _is_explanation(ln)]
245
+ filtered_norm = [_norm_one(ln, task_type) for ln in filtered if _norm_one(ln, task_type)]
246
+ if len(filtered_norm) == n:
247
+ return filtered_norm
248
+ # Apply count enforcement on best candidate
249
+ best = filtered_norm if filtered_norm else segs[0]
250
+ if len(best) > n: return best[:n]
251
+ if len(best) < n: return best + [best[-1] if best else ""] * (n - len(best))
252
+ return best
253
+
254
+ # n unknown: filter preamble/explanation, return remainder
255
+ raw_lines = [ln for ln in text.splitlines() if ln.strip()]
256
+ filtered = [ln for ln in raw_lines if not _is_preamble(ln) and not _is_explanation(ln)]
257
+ filtered_norm = [_norm_one(ln, task_type) for ln in filtered if _norm_one(ln, task_type)]
258
+ return filtered_norm if filtered_norm else (segs[0] if segs else [""])
259
+
260
 
261
  # ---------------------------------------------------------------------------
262
+ # Generation -- documented single-call Qwen3 pattern.
 
263
  # ---------------------------------------------------------------------------
264
+ def gen_baseline(context, query):
265
+ msgs = [{"role":"system","content":SYS},
266
+ {"role":"user","content":f"{context.strip()}\n\n{query.strip()}"}]
267
  try:
268
+ enc = tok.apply_chat_template(msgs, add_generation_prompt=True, enable_thinking=False,
269
+ return_tensors="pt", return_dict=True).to(model.device)
270
+ ilen = enc["input_ids"].shape[-1]
271
+ with torch.no_grad(): out = model.generate(**enc, max_new_tokens=512, do_sample=False)
272
  except Exception:
273
+ ids = tok.apply_chat_template(msgs, add_generation_prompt=True, enable_thinking=False,
274
+ return_tensors="pt").to(model.device)
275
+ ilen = ids.shape[-1]
276
+ with torch.no_grad(): out = model.generate(ids, max_new_tokens=512, do_sample=False)
277
+ return tok.decode(out[0][ilen:], skip_special_tokens=True).strip()
278
+
279
+ def gen_long_think(context, query):
280
+ msgs = [{"role":"system","content":SYS},
281
+ {"role":"user","content":f"{context.strip()}\n\n{query.strip()}"}]
282
+ try:
283
+ enc = tok.apply_chat_template(msgs, add_generation_prompt=True, enable_thinking=True,
284
+ return_tensors="pt", return_dict=True).to(model.device)
285
+ input_length = enc["input_ids"].shape[-1]
286
+ except Exception:
287
+ return gen_baseline(context, query)
288
+ try:
289
+ with torch.no_grad():
290
+ out = model.generate(**enc, max_new_tokens=LONG_TOTAL_TOKENS,
291
+ do_sample=True, temperature=0.6, top_p=0.95, top_k=20)
292
+ except Exception:
293
+ return gen_baseline(context, query)
294
+ output_ids = out[0][input_length:].tolist()
295
+ try:
296
+ idx = len(output_ids) - output_ids[::-1].index(THINK_CLOSE_TOKEN_ID)
297
+ except ValueError:
298
+ idx = 0
299
+ return tok.decode(output_ids[idx:], skip_special_tokens=True).strip("\n").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
 
301
+
302
+ rows = []; done = set()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  try:
304
+ for _, r in df.iterrows():
305
  try:
306
+ task_type = r.get("task_type", "")
307
+ text = gen_long_think(r["context"], r["query"]) if task_type in LONG_THINK_TASKS \
308
+ else gen_baseline(r["context"], r["query"])
309
+
310
+ n = n_expected(r["query"])
311
+ ans = parse_answers(text, task_type, n)
312
+ if not ans: ans = [""]
313
+
314
+ expl = re.sub(r"\s+", " ", text[:300]).strip() or "derived from the examples"
315
+ rows.append({"id":r["id"], "pred":json.dumps(ans, ensure_ascii=False),
316
+ "explanation":expl})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  except Exception as e:
318
+ n = n_expected(r["query"]) or 1
319
+ rows.append({"id":r["id"], "pred":json.dumps([""]*n, ensure_ascii=False),
320
+ "explanation":"fallback"})
321
+ print("row error", r["id"], e, flush=True)
322
+
323
  done.add(r["id"]); write_csv(rows)
324
+ elapsed = time.time() - start
325
+ print(f"{len(rows)}/{len(df)} t={elapsed:.0f}s task={task_type}", flush=True)
326
+ if elapsed > TIME_LIMIT - SAFETY_S:
327
+ print("time up, stopping", flush=True); break
328
+
329
+ for _, r in df.iterrows():
330
  if r["id"] in done: continue
331
+ n = n_expected(r["query"]) or 1
332
+ rows.append({"id":r["id"], "pred":json.dumps([""]*n, ensure_ascii=False),
333
+ "explanation":"fallback"})
334
+ write_csv(rows); print("DONE", flush=True)
335
  except Exception as e:
336
+ emergency(f"main loop: {e}"); print("FATAL", e, flush=True)