File size: 10,095 Bytes
ef8d39f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c02ca54
ef8d39f
 
 
 
 
 
 
 
 
 
 
 
117e057
530ac51
 
 
 
1336c78
117e057
1336c78
117e057
 
 
 
 
 
 
ef8d39f
117e057
 
 
 
 
 
 
 
ef8d39f
 
 
 
 
 
 
3e6db66
 
 
6245d15
ef8d39f
 
 
 
 
 
 
17613f9
 
 
ef8d39f
 
465f972
 
 
ef8d39f
 
 
 
 
 
80cecee
117e057
 
ef8d39f
 
117e057
ef8d39f
 
 
 
 
 
 
 
 
 
 
 
465f972
 
 
 
 
 
 
 
 
 
 
ef8d39f
1964d3a
9b580c1
ef8d39f
 
 
 
 
 
 
47da312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef8d39f
 
 
279a593
ef8d39f
 
 
 
 
 
117e057
ef8d39f
117e057
ef8d39f
279a593
cb33f4a
be726fd
279a593
ef8d39f
 
 
279a593
 
 
ef8d39f
279a593
117e057
 
279a593
117e057
279a593
ef8d39f
 
 
 
 
 
 
 
 
47da312
17613f9
279a593
ef8d39f
 
 
 
 
465f972
ef8d39f
 
279a593
ef8d39f
 
 
465f972
ef8d39f
465f972
 
 
 
 
ef8d39f
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
"""
IOL-AI 2026 submission script. Ships inside the model repo alongside the Qwen2.5-14B-Instruct-AWQ
weights. Reads /tmp/data/test.csv, writes submission.csv (id, pred, explanation) to the working
directory. No internet at runtime -- everything must load from local files (".").
"""
import os

os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"

import json
import re
import time

START_TIME = time.time()
TIME_LIMIT_SECONDS = 30 * 60
SAFETY_MARGIN_SECONDS = 90              # reserve for CSV write + any per-row overrun
DEADLINE = START_TIME + TIME_LIMIT_SECONDS - SAFETY_MARGIN_SECONDS

import pandas as pd
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

SYSTEM = (
    "You solve International Linguistics Olympiad problems. You will be given data "
    "from a language you have never seen before, plus hints, and asked to answer "
    "numbered items about it. The ONLY source of truth is the data given to you in "
    "this problem -- do not rely on anything you think you know about the language "
    "if it conflicts with the examples given here.\n\n"
    "Work method:\n"
    "1. Go through every single example given, in order. For each word in the "
    "unfamiliar language, first split it into its likely component morphemes "
    "(stem plus any prefixes/suffixes) even if you are not fully sure of the "
    "boundaries -- treat it as a sequence of parts, not one opaque unit. Then, "
    "for each one, write down "
    "every distinct morpheme, word, particle, or structural pattern (word order, "
    "marking, alternation) it contains and what it appears to mean or mark. Do not "
    "skip any example, and do not stop early once you have a plausible-looking "
    "pattern -- an alternation that looks like it marks one thing (e.g. tense) may "
    "actually mark something else (e.g. person, number, or agreement with a "
    "different argument), and only the examples you skipped may reveal which.\n"
    "2. Write a section titled RULE TABLE: that lists, as a table or bullet list, "
    "every distinct piece you identified and its meaning/function -- this must "
    "cover every example from step 1, not just the ones similar to the query.\n"
    "3. Using ONLY entries from your RULE TABLE, work out the answer to each query "
    "item. If the table has no entry for something the query needs, say so and give "
    "your best-supported guess rather than leaving it blank.\n"
    "4. Before writing your final answers, re-read your RULE TABLE and re-derive "
    "each answer from it one more time, checking: did you actually apply every "
    "rule you stated (e.g. a plural marker, a tense marker) to every relevant "
    "answer, not just some of them? If two answers could plausibly be swapped "
    "(e.g. two options assigned to the wrong item), re-check the evidence that "
    "distinguishes them specifically.\n\n"
    "Output format by task type -- give exactly this, nothing more:\n"
    "- translation: the translated form only, in the language the task asks for.\n"
    "- fill_blanks: only the missing form for each blank.\n"
    "- match_letters: only the option letter (e.g. A, B, C).\n"
    "- text_to_num: the number in digits.\n"
    "- num_to_text: the number written out in words, in the language asked.\n"
    "- any other task type: give exactly what the instruction asks for, nothing else.\n\n"
    "Each final answer must be the bare form only -- no surrounding quotes, no "
    "trailing period or punctuation that isn't part of the answer itself, no "
    "parenthetical notes, no alternate options separated by '/' or 'or'. Pick "
    "one single best answer per item.\n\n"
    "Follow the work method above, showing your RULE TABLE. Then write a line that "
    "says exactly FINAL ANSWERS: and, below it, one answer per line in the order "
    "the items are asked -- the bare answer only, no numbering, no quotes, no "
    "extra commentary."
)


def count_expected_items(query):
    """Count numbered items (e.g. '17.', '18)') in the query -- the target answer count."""
    return len(re.findall(r"(?m)^\s*\d+[.)]", query))


MAX_EXPLANATION_CHARS = 2000


def parse_answers(text, expected_count=None):
    """Keep only the lines after the last 'FINAL ANSWERS:' marker, one answer per line.

    If expected_count is given, pad with "" or truncate so the row never silently
    drops points from a length mismatch against the scorer's positional alignment.
    """
    marker = list(re.finditer(r"(?im)^[#*\s]*final answers?[:#*\s]*", text))
    if marker:
        text = text[marker[-1].end():]
    answers = []
    for line in text.splitlines():
        line = re.sub(r"^\s*\d+[.)]\s*", "", line).strip()
        line = line.strip("\"'")
        if line:
            answers.append(line)

    if expected_count:
        if len(answers) < expected_count:
            answers = answers + [""] * (expected_count - len(answers))
        elif len(answers) > expected_count:
            answers = answers[:expected_count]
    return answers


def extract_explanation(text):
    """The reasoning/RULE TABLE portion before the final-answers marker, for the
    Human Evaluation track -- truncated so one long row can't bloat the CSV."""
    marker = re.search(r"(?im)^[#*\s]*final answers?[:#*\s]*$", text)
    explanation = text[:marker.start()] if marker else text
    explanation = explanation.strip()
    if len(explanation) > MAX_EXPLANATION_CHARS:
        explanation = explanation[:MAX_EXPLANATION_CHARS].rsplit(" ", 1)[0] + " ..."
    return explanation


MODEL_ID = "."
MAX_NEW_TOKENS_FULL = 2048   # normal pass -- room to show a full RULE TABLE
MAX_NEW_TOKENS_FAST = 768    # fallback pass when time is short -- less room to reason

print(f"[{time.time() - START_TIME:.0f}s] loading model...", flush=True)
tok = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID, torch_dtype=torch.float16, device_map="auto",
).eval()
PAD_TOKEN_ID = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id

MAX_INPUT_TOKENS = 6000   # conservative guard against the model's context window -- leaves
                          # headroom for the system prompt, chat template overhead, and
                          # MAX_NEW_TOKENS_FULL generation on unusually long real IOL problems


def truncate_context(context, query):
    """Guard against exceeding the model's context window -- truncates `context` (never
    `query`, which holds the actual questions) from the end, keeping as much of the given
    data as fits within MAX_INPUT_TOKENS."""
    query_len = len(tok(query, add_special_tokens=False)["input_ids"])
    budget = MAX_INPUT_TOKENS - query_len
    if budget <= 0:
        return context
    context_ids = tok(context, add_special_tokens=False)["input_ids"]
    if len(context_ids) <= budget:
        return context
    return tok.decode(context_ids[:budget], skip_special_tokens=True)
print(f"[{time.time() - START_TIME:.0f}s] model loaded", flush=True)


def generate(system, user, max_new_tokens):
    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": user},
    ]
    # tokenize=False + separate tok(...) call, rather than apply_chat_template(..., return_dict=True),
    # for compatibility with the eval sandbox's pinned transformers==4.44.1 (return_dict support on
    # apply_chat_template was added later).
    prompt = tok.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
    enc = tok(prompt, return_tensors="pt").to(model.device)
    with torch.no_grad():
        out = model.generate(
            **enc, max_new_tokens=max_new_tokens, do_sample=True, temperature=0.5, top_p=0.95,
            pad_token_id=PAD_TOKEN_ID, repetition_penalty=1.15,
        )
    return tok.decode(out[0][enc["input_ids"].shape[-1]:], skip_special_tokens=True).strip()


def solve_row(problem, expected, avg_row_time):
    """One greedy pass, with a shorter token budget if the time budget is running low."""
    full_pass_estimate = avg_row_time if avg_row_time else 45.0   # seconds -- rough guess for row 1
    remaining = DEADLINE - time.time()
    if remaining > full_pass_estimate:
        text = generate(SYSTEM, problem, MAX_NEW_TOKENS_FULL)
    else:
        # Running low on time: fewer tokens, same prompt.
        text = generate(SYSTEM, problem, MAX_NEW_TOKENS_FAST)
    return parse_answers(text, expected_count=expected), extract_explanation(text)


df = pd.read_csv("/tmp/data/test.csv", dtype=str).fillna("")
print(f"[{time.time() - START_TIME:.0f}s] {len(df)} rows to solve", flush=True)

rows_out = []
row_times = []
for i, r in df.iterrows():
    row_start = time.time()
    problem = f"{truncate_context(r['context'].strip(), r['query'].strip())}\n\n{r['query'].strip()}"
    expected = count_expected_items(r["query"])
    avg_row_time = sum(row_times) / len(row_times) if row_times else None

    if time.time() > DEADLINE:
        # Out of time: best-effort placeholder rather than risking the whole process
        # getting killed by the 30-minute hard limit with no submission.csv at all.
        answers = [""] * max(expected, 1)
        explanation = ""
    else:
        try:
            answers, explanation = solve_row(problem, expected, avg_row_time)
        except Exception as e:
            print(f"[{time.time() - START_TIME:.0f}s] row {r['id']} failed: {e}", flush=True)
            answers = [""] * max(expected, 1)
            explanation = ""

    rows_out.append({
        "id": r["id"],
        "pred": json.dumps(answers, ensure_ascii=False),
        "explanation": explanation,
    })
    row_times.append(time.time() - row_start)
    print(
        f"[{time.time() - START_TIME:.0f}s] row {i + 1}/{len(df)} done in {row_times[-1]:.0f}s, "
        f"{len(answers)} answers, {DEADLINE - time.time():.0f}s budget left",
        flush=True,
    )

pd.DataFrame(rows_out).to_csv("submission.csv", index=False)
print(f"[{time.time() - START_TIME:.0f}s] wrote submission.csv ({len(rows_out)} rows)", flush=True)