File size: 15,201 Bytes
c9b07b3
 
af514ea
c9b07b3
 
af514ea
c9b07b3
 
 
 
 
 
 
 
 
 
 
 
 
af514ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9b07b3
 
af514ea
 
 
 
 
 
 
 
 
 
c9b07b3
 
af514ea
 
 
 
 
 
 
 
 
c9b07b3
 
af514ea
 
 
 
c9b07b3
 
af514ea
 
 
 
 
 
 
 
 
c9b07b3
 
 
af514ea
 
 
c9b07b3
 
 
af514ea
 
 
 
 
 
 
 
 
 
 
c9b07b3
af514ea
 
 
c9b07b3
af514ea
 
 
 
c9b07b3
 
af514ea
 
c9b07b3
 
af514ea
 
 
 
c9b07b3
 
af514ea
c9b07b3
af514ea
c9b07b3
af514ea
 
 
 
 
 
c9b07b3
 
 
 
 
af514ea
 
 
c9b07b3
 
 
af514ea
c9b07b3
 
 
af514ea
 
 
 
 
c9b07b3
 
 
af514ea
c9b07b3
 
af514ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9b07b3
 
 
af514ea
c9b07b3
 
 
 
af514ea
c9b07b3
 
 
 
 
 
 
 
 
 
af514ea
c9b07b3
 
 
af514ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9b07b3
 
af514ea
 
c9b07b3
 
 
 
 
 
af514ea
c9b07b3
af514ea
 
c9b07b3
af514ea
c9b07b3
 
af514ea
 
 
 
 
c9b07b3
 
af514ea
c9b07b3
 
 
 
af514ea
 
 
 
c9b07b3
 
 
af514ea
c9b07b3
af514ea
c9b07b3
af514ea
 
 
 
 
 
 
 
 
 
 
 
c9b07b3
 
af514ea
 
 
 
 
 
 
 
 
 
 
 
 
c9b07b3
 
 
af514ea
 
 
 
 
c9b07b3
af514ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9b07b3
 
af514ea
 
 
 
 
c9b07b3
af514ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9b07b3
af514ea
 
 
 
c9b07b3
af514ea
c9b07b3
af514ea
 
c9b07b3
 
af514ea
 
 
 
 
c9b07b3
 
 
 
 
af514ea
c9b07b3
af514ea
 
 
 
 
c9b07b3
af514ea
 
 
 
 
 
 
 
 
 
 
 
 
 
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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
import os
import re
import sys
import json
import time
import logging

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

START_TIME = time.time()
SOFT_LIMIT_SECONDS = 26 * 60
MODEL_ID = "."

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


logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(message)s",
    stream=sys.stdout,
    force=True,
)
log = logging.getLogger("iol-v1")


SYSTEM = (
    "You solve International Linguistics Olympiad problems by reasoning only from "
    "the supplied data. Read the instruction and examples carefully. Preserve every "
    "requested grammatical feature and output form. For translation, return the full "
    "translated form; for fill_blanks, return each missing form; for text_to_num, "
    "return digits; for num_to_text, return the complete written number form. Reason "
    "systematically and verify the inferred rule against the examples. Then write "
    "FINAL ANSWERS: exactly once, followed by one bare answer per line in query order. "
    "Do not number, quote, or explain the final answers. Always answer every item."
)


MATCH_SYSTEM = (
    "Solve this linguistic matching problem as a complete correspondence. Decompose "
    "recurring morphemes and compound words before matching. The numbered puzzle-language "
    "items must be answered in their original order using only option labels. When the "
    "number of labels equals the number of items, this is a one-to-one assignment: use "
    "every label exactly once. Resolve high-confidence pairs first, then use the global "
    "constraint for the remainder. Do not stop early; make a best guess for every item. "
    "Write FINAL ANSWERS: exactly once, followed by one uppercase label per line. Include "
    "no numbering, words, meanings, or commentary in the final section."
)


REPAIR_SYSTEM = (
    "Repair the final output of an International Linguistics Olympiad solution. Use the "
    "context, query, attempted solution, required count, and valid labels when supplied. "
    "Return exactly the required number of answers in original item order. For matching, "
    "return uppercase option labels only and use each label once when explicitly told the "
    "task is one-to-one. For text_to_num return digits; for num_to_text return complete "
    "written forms; for fill_blanks include every blank in order. Write FINAL ANSWERS: "
    "exactly once, then one bare answer per line. No reasoning or commentary."
)


MARKER_RE = re.compile(
    r"(?im)^\s*(?:[-*•]\s*)?(?:#{1,6}\s*)?(?:\*\*)?"
    r"final answers?\s*:?(?:\*\*)?\s*$"
)


def clean_answer_line(line):
    line = str(line).strip()
    if re.match(r"^#{1,6}\s+\S", line):
        return ""
    line = re.sub(r"^\s*\d+[.)]\s*", "", line)
    line = re.sub(r"^\s*[-*•]\s+", "", line).strip()
    if line.startswith("**") and line.endswith("**"):
        line = line[2:-2].strip()
    return line


def parse_answers(text):
    """Parse both a normal final block and repeated per-item FINAL ANSWERS blocks."""
    text = str(text)
    markers = list(MARKER_RE.finditer(text))
    if not markers:
        return []

    # Some models write one FINAL ANSWERS marker for every numbered item.
    if len(markers) > 1:
        answers = []
        for index, marker in enumerate(markers):
            end = markers[index + 1].start() if index + 1 < len(markers) else len(text)
            for line in text[marker.end():end].splitlines():
                candidate = clean_answer_line(line)
                if candidate:
                    answers.append(candidate)
                    break
        return answers

    answers = []
    for line in text[markers[0].end():].splitlines():
        if re.match(r"^\s*#{1,6}\s+\S", line):
            break
        candidate = clean_answer_line(line)
        if candidate:
            answers.append(candidate)
    return answers


def numbered_context_items(context):
    return list(dict.fromkeys(re.findall(r"(?m)^\s*(\d+)[.)]\s+\S", str(context))))


def option_labels(context):
    """Extract labels such as A.-R. from a matching context, preserving order."""
    labels = re.findall(r"(?m)^\s*([A-Z])\s*[.)]\s+\S", str(context))
    return list(dict.fromkeys(label.upper() for label in labels))


def count_query_items(query, context="", task_type=""):
    query = str(query).strip()
    task_type = str(task_type).strip()

    if task_type == "match_letters":
        items = numbered_context_items(context)
        if items:
            return len(items)

    ranges = re.findall(r"\((\d+)\s*[-–—]\s*(\d+)\)", query)
    if ranges:
        start, end = map(int, ranges[-1])
        if end >= start:
            return end - start + 1

    numbered = re.findall(r"(?m)^\s*\d+[.)]\s+\S", query)
    if numbered:
        return len(numbered)

    blanks = re.findall(r"\((\d+)\)", query)
    if blanks:
        return len(set(blanks))

    if ":" in query:
        body = query.split(":", 1)[1].strip()
        lines = [line.strip() for line in body.splitlines() if line.strip()]
        if len(lines) > 1:
            return len(lines)
        if len(lines) == 1:
            comma_items = [item.strip() for item in lines[0].split(",") if item.strip()]
            if len(comma_items) > 1:
                return len(comma_items)

    return 1


def normalize_matching_answers(answers):
    normalized = []
    for answer in answers:
        match = re.fullmatch(r"\s*([A-Za-z])(?:[.)])?\s*", str(answer))
        normalized.append(match.group(1).upper() if match else str(answer).strip().upper())
    return normalized


def matching_is_permutation(expected_n, labels):
    return len(labels) == expected_n and len(set(labels)) == expected_n


def valid_matching(answers, expected_n, labels):
    if len(answers) != expected_n:
        return False
    if not labels:
        return all(re.fullmatch(r"[A-Z]", answer or "") for answer in answers)
    if not all(answer in labels for answer in answers):
        return False
    if matching_is_permutation(expected_n, labels):
        return len(set(answers)) == expected_n and set(answers) == set(labels)
    return True


def complete_matching_permutation(answers, expected_n, labels):
    """Keep valid first occurrences and fill duplicates/missing slots with unused labels."""
    if not matching_is_permutation(expected_n, labels):
        return answers
    completed = []
    used = set()
    holes = []
    for answer in answers[:expected_n]:
        answer = str(answer).strip().upper()
        if answer in labels and answer not in used:
            completed.append(answer)
            used.add(answer)
        else:
            holes.append(len(completed))
            completed.append(None)
    while len(completed) < expected_n:
        holes.append(len(completed))
        completed.append(None)
    unused = [label for label in labels if label not in used]
    for position, label in zip(holes, unused):
        completed[position] = label
    return completed


def write_submission(rows):
    pd.DataFrame(rows, columns=["id", "pred"]).to_csv("submission.csv", index=False)


def generate_text(messages, max_new_tokens):
    input_ids = tokenizer.apply_chat_template(
        messages, add_generation_prompt=True, return_tensors="pt"
    ).to(model.device)
    with torch.no_grad():
        output = model.generate(
            input_ids,
            max_new_tokens=max_new_tokens,
            do_sample=False,
            use_cache=True,
            pad_token_id=tokenizer.eos_token_id,
        )
    return tokenizer.decode(
        output[0][input_ids.shape[-1]:], skip_special_tokens=True
    ).strip()


def validate_submission(rows, source_df):
    errors = []
    if len(rows) != len(source_df):
        errors.append(f"row count {len(rows)} != {len(source_df)}")
    ids = [str(row["id"]) for row in rows]
    expected_ids = source_df["id"].astype(str).tolist()
    if ids != expected_ids:
        errors.append("IDs or row order differ from test.csv")
    if len(ids) != len(set(ids)):
        errors.append("duplicate IDs")
    for position, row in enumerate(rows):
        try:
            predictions = json.loads(row["pred"])
        except Exception as error:
            errors.append(f"row {position}: invalid JSON ({error})")
            continue
        source = source_df.iloc[position]
        expected_n = count_query_items(
            source["query"], source["context"], source.get("task_type", "")
        )
        if not isinstance(predictions, list):
            errors.append(f"row {position}: pred is not a list")
        elif len(predictions) != expected_n:
            errors.append(f"row {position}: {len(predictions)} != {expected_n} answers")
        elif not all(isinstance(answer, str) and answer != "" for answer in predictions):
            errors.append(f"row {position}: empty or non-string answer")
    return errors


log.info("Loading local model")
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, local_files_only=True)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    torch_dtype=torch.float16,
    device_map="auto",
    local_files_only=True,
).eval()
log.info("Model loaded in %.1fs", time.time() - START_TIME)

df = pd.read_csv("/tmp/data/test.csv", dtype=str).fillna("")
log.info("Loaded %d test rows", len(df))

# Always keep a structurally complete CSV on disk.
submission_rows = []
for _, row in df.iterrows():
    expected_n = count_query_items(
        row["query"], row["context"], row.get("task_type", "")
    )
    labels = option_labels(row["context"]) if row.get("task_type", "") == "match_letters" else []
    fallback = labels[:expected_n] if len(labels) >= expected_n else ["?"] * expected_n
    submission_rows.append({
        "id": row["id"],
        "pred": json.dumps(fallback, ensure_ascii=False),
    })
write_submission(submission_rows)

for position, (_, row) in enumerate(df.iterrows()):
    task_type = row.get("task_type", "")
    expected_n = count_query_items(row["query"], row["context"], task_type)
    labels = option_labels(row["context"]) if task_type == "match_letters" else []
    one_to_one = task_type == "match_letters" and matching_is_permutation(expected_n, labels)
    elapsed = time.time() - START_TIME
    remaining = SOFT_LIMIT_SECONDS - elapsed
    if remaining > 10 * 60:
        main_budget = 1536
    elif remaining > 5 * 60:
        main_budget = 768
    else:
        main_budget = 384

    log.info(
        "row=%d/%d type=%s expected=%d labels=%d bijection=%s elapsed=%.1fs budget=%d",
        position + 1,
        len(df),
        task_type,
        expected_n,
        len(labels),
        one_to_one,
        elapsed,
        main_budget,
    )

    if task_type == "match_letters":
        user_content = (
            f"CONTEXT:\n{row['context'].strip()}\n\nQUERY:\n{row['query'].strip()}\n\n"
            f"Required answer count: {expected_n}.\n"
            f"Valid labels: {', '.join(labels) if labels else 'infer from context'}."
        )
        system_prompt = MATCH_SYSTEM
    else:
        user_content = (
            f"TASK TYPE: {task_type}\n\nCONTEXT:\n{row['context'].strip()}\n\n"
            f"QUERY:\n{row['query'].strip()}\n\nRequired answer count: {expected_n}."
        )
        system_prompt = SYSTEM

    try:
        raw_text = generate_text(
            [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_content},
            ],
            max_new_tokens=main_budget,
        )
        log.info("row=%d primary_raw=%r", position + 1, raw_text)
        answers = parse_answers(raw_text)
        original_count = len(answers)
        if task_type == "match_letters":
            answers = normalize_matching_answers(answers)
            valid = valid_matching(answers, expected_n, labels)
        else:
            valid = len(answers) == expected_n and all(str(answer).strip() for answer in answers)

        log.info("row=%d primary parsed=%d valid=%s", position + 1, original_count, valid)

        if not valid and remaining > 3 * 60:
            repair_content = (
                f"TASK TYPE: {task_type}\nREQUIRED ANSWER COUNT: {expected_n}\n"
                f"ONE-TO-ONE MATCHING: {one_to_one}\n"
                f"VALID LABELS: {', '.join(labels)}\n\n"
                f"CONTEXT:\n{row['context'].strip()}\n\nQUERY:\n{row['query'].strip()}\n\n"
                f"ATTEMPTED SOLUTION:\n{raw_text}"
            )
            repair_text = generate_text(
                [
                    {"role": "system", "content": REPAIR_SYSTEM},
                    {"role": "user", "content": repair_content},
                ],
                max_new_tokens=768 if remaining > 8 * 60 else 384,
            )
            log.info("row=%d repair_raw=%r", position + 1, repair_text)
            repaired = parse_answers(repair_text)
            if task_type == "match_letters":
                repaired = normalize_matching_answers(repaired)
                repaired_valid = valid_matching(repaired, expected_n, labels)
            else:
                repaired_valid = len(repaired) == expected_n and all(
                    str(answer).strip() for answer in repaired
                )
            log.info(
                "row=%d repair parsed=%d valid=%s",
                position + 1,
                len(repaired),
                repaired_valid,
            )
            if repaired_valid or abs(len(repaired) - expected_n) < abs(len(answers) - expected_n):
                answers = repaired

        if task_type == "match_letters":
            answers = normalize_matching_answers(answers)
            if one_to_one and not valid_matching(answers, expected_n, labels):
                answers = complete_matching_permutation(answers, expected_n, labels)

        answers = [str(answer).strip() for answer in answers[:expected_n]]
        if len(answers) < expected_n:
            answers.extend(["?"] * (expected_n - len(answers)))
        answers = [answer if answer else "?" for answer in answers]

    except Exception as error:
        log.exception("row=%d failed: %s", position + 1, error)
        if task_type == "match_letters" and len(labels) >= expected_n:
            answers = labels[:expected_n]
        else:
            answers = ["?"] * expected_n
        if torch.cuda.is_available():
            torch.cuda.empty_cache()

    submission_rows[position] = {
        "id": row["id"],
        "pred": json.dumps(answers, ensure_ascii=False),
    }
    log.info(
        "row=%d id=%s final_answers=%s",
        position + 1,
        row["id"],
        json.dumps(answers, ensure_ascii=False),
    )
    write_submission(submission_rows)
    log.info("row=%d completed answers=%d/%d", position + 1, len(answers), expected_n)

errors = validate_submission(submission_rows, df)
if errors:
    for error in errors:
        log.error("validation: %s", error)
    raise RuntimeError("submission.csv validation failed")

log.info(
    "submission.csv valid rows=%d total_runtime=%.1fs",
    len(submission_rows),
    time.time() - START_TIME,
)