janPaje commited on
Commit
7a5e5b5
Β·
verified Β·
1 Parent(s): 52014b7

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. README.md +36 -0
  2. numeral_solver.py +167 -0
  3. script.py +245 -0
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: transformers
4
+ base_model: google/gemma-4-12B
5
+ pipeline_tag: text-generation
6
+ tags:
7
+ - gemma4
8
+ - iol-ai-2026
9
+ ---
10
+
11
+ # IOL-AI 2026 β€” gemma4:12b hybrid (symbolic numeral solver + budget-managed LLM)
12
+
13
+ Three-pass pipeline:
14
+
15
+ 1. **Symbolic pass** β€” `numeral_solver.py` brute-forces numeral systems
16
+ (base, word values, word-order convention) from the problem's own
17
+ examples and answers `text_to_num` / `num_to_text` exactly when a
18
+ consistent system exists; otherwise falls through to the LLM.
19
+ 2. **Baseline pass** β€” a fast low-token answer for every remaining
20
+ problem, with `submission.csv` atomically checkpointed after every row.
21
+ 3. **Upgrade pass** β€” per-row time-sliced reasoning with per-task-type
22
+ method prompts; a baseline answer is replaced only when the upgrade
23
+ parses to the correct number of answers. Translation reasoning is
24
+ deliberately capped low: on gemma4-12B, truncated reasoning plus a
25
+ forced short answer scored measurably higher than completed reasoning.
26
+
27
+ Model: gemma4-12B (Apache 2.0), weights shipped in this repo, loaded 4-bit
28
+ via bitsandbytes at startup (fp16 12B does not fit the 16 GB T4).
29
+
30
+ ## Upload checklist (before submitting)
31
+
32
+ 1. Put the gemma4-12B weight files (safetensors + config + tokenizer) in
33
+ the repo root so `script.py` loads them from `"."`.
34
+ 2. Repo must be **public** at submission time.
35
+ 3. Submit via the competition Space; the platform mounts the hidden test
36
+ set at `/tmp/data/test.csv` and runs `script.py`.
numeral_solver.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Constraint solver for IOL-style numeral problems.
3
+
4
+ Given example lines "word word = value", searches over (base, word values,
5
+ combination convention) for a system that reproduces every example, then
6
+ answers queries in either direction (words -> digits, digits -> words).
7
+
8
+ Grammar family searched: value words for units (1..base-1) and powers
9
+ (base, base^2, base^3); a numeral is a sequence of groups read left to
10
+ right, each group = [unit multiplier] power, plus an optional trailing
11
+ unit addend. Two conventions tried: multiplier-before-power ("two ten"=20,
12
+ "ten two"=12) and power-before-multiplier (the reverse).
13
+
14
+ Returns None rather than guessing when no consistent system exists, so a
15
+ caller can fall back to an LLM. Standalone test:
16
+ python3 numeral_solver.py # runs against data/dev.csv
17
+ """
18
+ import itertools
19
+ import re
20
+
21
+
22
+ def parse_examples(context):
23
+ """Extract (tokens, value) pairs from lines like 'lo tem = 10'."""
24
+ ex = []
25
+ for line in context.splitlines():
26
+ m = re.match(r"^\s*([^\d=]+?)\s*=\s*(\d+)\s*$", line)
27
+ if m:
28
+ ex.append((tuple(m.group(1).split()), int(m.group(2))))
29
+ return ex
30
+
31
+
32
+ def parse_value(tokens, vals, base, mult_first):
33
+ """Parse a numeral under one convention; None if malformed."""
34
+ powers = {base, base * base, base ** 3}
35
+ total, i = 0, 0
36
+ prev_power = None
37
+ while i < len(tokens):
38
+ v = vals.get(tokens[i])
39
+ if v is None:
40
+ return None
41
+ if v in powers:
42
+ total += v
43
+ prev_power = v
44
+ i += 1
45
+ else: # unit word
46
+ if mult_first and i + 1 < len(tokens) and \
47
+ vals.get(tokens[i + 1]) in powers:
48
+ p = vals[tokens[i + 1]]
49
+ if prev_power is not None and p >= prev_power:
50
+ return None # powers must descend
51
+ total += v * p
52
+ prev_power = p
53
+ i += 2
54
+ elif not mult_first and prev_power is not None and i == len(tokens) - 1:
55
+ total += v
56
+ i += 1
57
+ elif not mult_first and i > 0 and vals.get(tokens[i - 1]) in powers:
58
+ # power-first: unit right after a power multiplies it
59
+ p = vals[tokens[i - 1]]
60
+ total += v * p - p # power already added once
61
+ i += 1
62
+ elif i == len(tokens) - 1:
63
+ total += v # trailing addend
64
+ i += 1
65
+ else:
66
+ return None
67
+ return total
68
+
69
+
70
+ def solve(context):
71
+ """Find (base, vals, mult_first) consistent with every example."""
72
+ examples = parse_examples(context)
73
+ if len(examples) < 3:
74
+ return None
75
+ words = sorted({w for toks, _ in examples for w in toks})
76
+
77
+ # words pinned by single-token examples
78
+ pinned = {toks[0]: v for toks, v in examples if len(toks) == 1}
79
+ free = [w for w in words if w not in pinned]
80
+ max_val = max(v for _, v in examples)
81
+
82
+ for base in range(3, 31):
83
+ if base * base > max_val * base:
84
+ break
85
+ powers = [base, base * base, base ** 3]
86
+ cand = [v for v in list(range(1, base)) + powers if v <= max_val * 2]
87
+ if any(v >= base and v not in powers for v in pinned.values()):
88
+ continue
89
+ if len(free) > 3:
90
+ continue # search would explode
91
+ for combo in itertools.product(cand, repeat=len(free)):
92
+ vals = dict(pinned)
93
+ vals.update(zip(free, combo))
94
+ if len(set(vals.values())) != len(vals):
95
+ continue
96
+ for mult_first in (True, False):
97
+ if all(parse_value(t, vals, base, mult_first) == v
98
+ for t, v in examples):
99
+ return base, vals, mult_first
100
+ return None
101
+
102
+
103
+ def render(value, base, vals, mult_first):
104
+ """Compose the numeral words for a value under a solved system."""
105
+ inv = {v: w for w, v in vals.items()}
106
+ parts = []
107
+ for p in (base ** 3, base * base, base):
108
+ if p in inv:
109
+ mult, value = divmod(value, p)
110
+ if mult == 0:
111
+ continue
112
+ if mult == 1:
113
+ parts.append(inv[p])
114
+ elif mult in inv:
115
+ pair = [inv[mult], inv[p]]
116
+ parts.extend(pair if mult_first else pair[::-1])
117
+ else:
118
+ return None
119
+ if value:
120
+ if value not in inv:
121
+ return None
122
+ parts.append(inv[value])
123
+ return " ".join(parts) if parts else None
124
+
125
+
126
+ def answer(context, query, task_type):
127
+ """Answer all items, or None if the system can't be solved."""
128
+ solved = solve(context)
129
+ if not solved:
130
+ return None
131
+ base, vals, mult_first = solved
132
+ items = re.findall(r"^\s*\d+[.)]\s*(.+)$", query, re.M)
133
+ out = []
134
+ for item in items:
135
+ item = item.strip()
136
+ if task_type == "text_to_num":
137
+ v = parse_value(tuple(item.split()), vals, base, mult_first)
138
+ out.append(str(v) if v is not None else None)
139
+ else:
140
+ m = re.search(r"\d+", item)
141
+ out.append(render(int(m.group()), base, vals, mult_first)
142
+ if m else None)
143
+ return out if all(a is not None for a in out) else None
144
+
145
+
146
+ if __name__ == "__main__":
147
+ import csv
148
+ import json
149
+ gold = json.load(open("data/dev_answers.json", encoding="utf-8"))
150
+ total = solved_ok = items_right = items_total = 0
151
+ for r in csv.DictReader(open("data/dev.csv", newline="", encoding="utf-8")):
152
+ if r["task_type"] not in ("text_to_num", "num_to_text"):
153
+ continue
154
+ total += 1
155
+ ans = answer(r["context"], r["query"], r["task_type"])
156
+ g = gold[r["id"]]
157
+ items_total += len(g)
158
+ if ans is None:
159
+ print(f"{r['id']} {r['task_type']:12} UNSOLVED")
160
+ continue
161
+ right = sum(a == b for a, b in zip(ans, g))
162
+ items_right += right
163
+ solved_ok += 1
164
+ flag = "" if right == len(g) else f" <-- {ans} vs {g}"
165
+ print(f"{r['id']} {r['task_type']:12} solved, {right}/{len(g)} items{flag}")
166
+ print(f"\nsolved {solved_ok}/{total} problems, "
167
+ f"{items_right}/{items_total} items exactly right")
script.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IOL-AI 2026 submission β€” v5 hybrid (solver + budget-managed gemma4:12b).
2
+
3
+ Everything here was validated on a local dev set (see README):
4
+ pass 0 symbolic numeral solver (numeral_solver.py, shipped in this repo);
5
+ answers text_to_num / num_to_text exactly when the system fits the
6
+ searched grammar family, returns None -> LLM fallback otherwise
7
+ pass 1 fast low-token answer for every remaining problem, submission.csv
8
+ atomically checkpointed after every row (a kill never leaves an
9
+ invalid/partial file)
10
+ pass 2 per-row time-sliced re-solve with reasoning; overwrites baseline
11
+ only if the result parses to the right number of answers; translation
12
+ reasoning is deliberately capped LOW (truncated reasoning + a forced
13
+ short answer scored higher than completed reasoning on gemma4:12b)
14
+
15
+ Repo layout expected: this file as script.py, numeral_solver.py beside it,
16
+ gemma4:12b weights in the repo root (load from "."). fp16 12B does not fit a
17
+ 16 GB T4, so weights load 4-bit via bitsandbytes (the organizer-reference
18
+ recipe).
19
+ """
20
+ import os
21
+ import subprocess
22
+ import sys
23
+ import time
24
+
25
+ T0 = time.monotonic()
26
+ TIME_LIMIT = 30 * 60
27
+ DEADLINE = T0 + TIME_LIMIT - 3 * 60 # 3-min reserve for writes/exit
28
+
29
+ # Smoke mode (local CPU shakeout, mirrors the leader's IOL_DUMMY pattern):
30
+ # IOL_SMOKE=1 skip bitsandbytes (no CUDA), load fp32 on CPU
31
+ # IOL_MODEL_ID=... substitute a tiny model for the shipped weights
32
+ # IOL_INPUT=... read a local CSV instead of /tmp/data/test.csv
33
+ SMOKE = os.environ.get("IOL_SMOKE", "0") == "1"
34
+
35
+ deps = ["transformers>=4.51", "accelerate>=0.30", "torch>=2.2", "pandas"]
36
+ if not SMOKE:
37
+ deps.append("bitsandbytes")
38
+ subprocess.run([sys.executable, "-m", "pip", "install", "-q", *deps], check=True)
39
+
40
+ import json
41
+ import re
42
+
43
+ import pandas as pd
44
+ import torch
45
+ from transformers import AutoModelForCausalLM, AutoTokenizer
46
+
47
+ import numeral_solver
48
+
49
+ MODEL_ID = os.environ.get("IOL_MODEL_ID", ".")
50
+ INPUT_CSV = os.environ.get("IOL_INPUT", "/tmp/data/test.csv")
51
+ MARKER = "FINAL ANSWERS"
52
+ TRANSLATION_CAP = 3000 # tokens; see module docstring
53
+ GLOBAL_CAP = 8192
54
+
55
+ BASE_RULES = (
56
+ f"You solve International Linguistics Olympiad problems. Your reasoning budget "
57
+ f"is limited, so be systematic and compact β€” do not second-guess a hypothesis "
58
+ f"that fits all the data. Verify against every given example once, then commit. "
59
+ f"End with the line '{MARKER}:' followed by one answer per line, in item order, "
60
+ f"with no numbering and no extra text."
61
+ )
62
+
63
+ NUMERAL_CORE = (
64
+ "1) For EVERY example, write one arithmetic equation showing exactly how its "
65
+ "words produce its value. 2) Determine the base from the single-word values. "
66
+ "3) CRITICAL: find pairs of examples using the same words in different orders "
67
+ "with different values β€” decide which order means multiplication and which "
68
+ "means addition. 4) Only after your equations reproduce ALL examples, "
69
+ )
70
+
71
+ STRATEGIES = {
72
+ "translation": (
73
+ "Method: 1) Align the given sentence pairs and segment every word into "
74
+ "morphemes by comparing entries that share meaning components. 2) Write a "
75
+ "compact table: each root, prefix, and suffix with its meaning, plus the "
76
+ "morpheme order. 3) Compose each requested item from the table. Mind the "
77
+ "direction of translation asked for."
78
+ ),
79
+ "text_to_num": "Method: " + NUMERAL_CORE + "convert each item to digits.",
80
+ "num_to_text": "Method: " + NUMERAL_CORE + "compose each requested number in "
81
+ "the puzzle language.",
82
+ "fill_blanks": (
83
+ "Method: deduce the paradigm from the completed cells, state the rule for "
84
+ "each row/column, then fill each blank consistently with it."
85
+ ),
86
+ "match_letters": (
87
+ "Method: find anchor items you can pair with certainty first (word length, "
88
+ "repeated letters), then use elimination. Every item gets exactly one match."
89
+ ),
90
+ }
91
+
92
+ SALVAGE = (
93
+ "Time is up. Based on the partial analysis below, give your best answer for "
94
+ "all {n} items RIGHT NOW: one answer per line, in order, no numbering, no "
95
+ "other text, no further reasoning.\n\nThe items to answer:\n{query}\n\n"
96
+ "Partial analysis:\n{tail}"
97
+ )
98
+
99
+
100
+ def remaining():
101
+ return DEADLINE - time.monotonic()
102
+
103
+
104
+ tok = AutoTokenizer.from_pretrained(MODEL_ID, local_files_only=not SMOKE)
105
+ if SMOKE:
106
+ model = AutoModelForCausalLM.from_pretrained(MODEL_ID).eval()
107
+ else:
108
+ from transformers import BitsAndBytesConfig
109
+ model = AutoModelForCausalLM.from_pretrained(
110
+ MODEL_ID,
111
+ quantization_config=BitsAndBytesConfig(
112
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
113
+ bnb_4bit_compute_dtype=torch.float16),
114
+ device_map="auto", local_files_only=True,
115
+ ).eval()
116
+ print(f"[model] loaded at {time.monotonic()-T0:.0f}s", flush=True)
117
+
118
+
119
+ def generate(messages, max_new, max_time=None):
120
+ """Greedy generation. Returns (text, truncated, tokens_per_sec)."""
121
+ enc = tok.apply_chat_template(
122
+ messages, add_generation_prompt=True, return_tensors="pt",
123
+ )
124
+ if hasattr(enc, "keys"): # BatchEncoding on newer transformers
125
+ inputs = {k: v.to(model.device) for k, v in enc.items()}
126
+ else: # bare tensor on older versions
127
+ inputs = {"input_ids": enc.to(model.device)}
128
+ prompt_len = inputs["input_ids"].shape[-1]
129
+ kw = {"max_new_tokens": max_new, "do_sample": False,
130
+ "pad_token_id": tok.eos_token_id}
131
+ if max_time:
132
+ kw["max_time"] = max_time
133
+ t0 = time.monotonic()
134
+ with torch.no_grad():
135
+ out = model.generate(**inputs, **kw)
136
+ n_new = out.shape[-1] - prompt_len
137
+ tps = n_new / max(time.monotonic() - t0, 1e-6)
138
+ text = tok.decode(out[0][prompt_len:], skip_special_tokens=True).strip()
139
+ return text, n_new >= max_new, tps
140
+
141
+
142
+ def item_count(query):
143
+ return len(re.findall(r"^\s*\d+[.)]", query, re.M)) or 1
144
+
145
+
146
+ def extract_answers(text, n):
147
+ m = re.search(rf"{MARKER}\s*:?", text, re.I)
148
+ block = text[m.end():] if m else text
149
+ lines = [ln.strip() for ln in block.splitlines() if ln.strip()]
150
+ if not m:
151
+ lines = lines[-n:]
152
+ lines = [re.sub(r"^\d+[.)]\s*", "", ln) for ln in lines]
153
+ return (lines + [""] * n)[:n]
154
+
155
+
156
+ def valid(answers, n):
157
+ return len(answers) == n and all(answers)
158
+
159
+
160
+ def checkpoint(rows):
161
+ tmp = ".submission.csv.tmp"
162
+ pd.DataFrame(rows, columns=["id", "pred"]).to_csv(tmp, index=False)
163
+ os.replace(tmp, "submission.csv")
164
+
165
+
166
+ def build_messages(r):
167
+ system = BASE_RULES + "\n\n" + STRATEGIES.get(r["task_type"], "")
168
+ return [
169
+ {"role": "system", "content": system},
170
+ {"role": "user", "content": f"{r['context'].strip()}\n\n{r['query'].strip()}"},
171
+ ]
172
+
173
+
174
+ df = pd.read_csv(INPUT_CSV, dtype=str).fillna("")
175
+ problems = [r for _, r in df.iterrows()]
176
+ counts = [item_count(r["query"]) for r in problems]
177
+ rows = [{"id": r["id"], "pred": json.dumps([""] * counts[i])}
178
+ for i, r in enumerate(problems)]
179
+ checkpoint(rows)
180
+
181
+ # ── pass 0: symbolic numeral solver ──────────────────────────────────────────
182
+ solved = [False] * len(problems)
183
+ for i, r in enumerate(problems):
184
+ if r["task_type"] in ("text_to_num", "num_to_text"):
185
+ try:
186
+ ans = numeral_solver.answer(r["context"], r["query"], r["task_type"])
187
+ except Exception as exc:
188
+ print(f"[solver] {r['id']} error: {exc}", flush=True)
189
+ ans = None
190
+ if ans is not None:
191
+ rows[i]["pred"] = json.dumps(ans, ensure_ascii=False)
192
+ solved[i] = True
193
+ print(f"[solver] {r['id']} solved", flush=True)
194
+ checkpoint(rows)
195
+
196
+ # ── pass 1: fast complete baseline ───────────────────────────────────────────
197
+ tps_est = 20.0
198
+ for i, r in enumerate(problems):
199
+ if solved[i] or remaining() < 60:
200
+ continue
201
+ text, _, tps_est = generate(build_messages(r), max_new=48 * counts[i] + 128,
202
+ max_time=min(90.0, remaining() / 4))
203
+ rows[i]["pred"] = json.dumps(extract_answers(text, counts[i]),
204
+ ensure_ascii=False)
205
+ checkpoint(rows)
206
+ print(f"[base {i+1}/{len(problems)}] done at {time.monotonic()-T0:.0f}s",
207
+ flush=True)
208
+
209
+ # ── pass 2: time-sliced reasoning upgrades ───────────────────────────────────
210
+ for i, r in enumerate(problems):
211
+ if solved[i]:
212
+ continue
213
+ n = counts[i]
214
+ if remaining() < 30:
215
+ print("[deadline] stopping upgrades", flush=True)
216
+ break
217
+ todo = sum(1 for j in range(i, len(problems)) if not solved[j])
218
+ slice_s = max(remaining() / max(todo, 1), 10.0)
219
+ max_new = int(min(max(slice_s * 0.8 * tps_est, 512), GLOBAL_CAP))
220
+ if r["task_type"] == "translation":
221
+ max_new = min(max_new, TRANSLATION_CAP)
222
+
223
+ text, truncated, tps_est = generate(build_messages(r), max_new,
224
+ max_time=slice_s * 0.8)
225
+ upgraded = extract_answers(text, n) if text else []
226
+
227
+ if not valid(upgraded, n) and remaining() > 20:
228
+ msgs = [
229
+ {"role": "system", "content": "Output only the answers, one per line."},
230
+ {"role": "user", "content": SALVAGE.format(
231
+ n=n, query=r["query"].strip(), tail=text[-3000:])},
232
+ ]
233
+ text, _, _ = generate(msgs, max_new=48 * n + 64,
234
+ max_time=min(60.0, remaining()))
235
+ upgraded = extract_answers(text, n)
236
+
237
+ if valid(upgraded, n):
238
+ rows[i]["pred"] = json.dumps(upgraded, ensure_ascii=False)
239
+ checkpoint(rows)
240
+ print(f"[think {i+1}/{len(problems)}] upgraded", flush=True)
241
+ else:
242
+ print(f"[think {i+1}/{len(problems)}] kept baseline", flush=True)
243
+
244
+ checkpoint(rows)
245
+ print(f"wrote submission.csv at {time.monotonic()-T0:.0f}s", flush=True)