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, )