#!/usr/bin/env python3
# Historical source snapshot. Comments about accuracy, data origin, or compliance
# are author claims, not verification; see training/README.md and REPRODUCIBILITY.md.
"""
Baseline Inference Runner for Sinhala MMLU Shared Task
Loads all questions from data/ (easy, medium, hard) and outputs predictions.jsonl
Uses Ollama /api/chat with strict structured exam prompting.
Supports:
- Fast direct mode (default), Cross-Lingual CoT mode (--cot), and mode (--think)
- Rapid local validation benchmark (--val_only)
- Automatic checkpointing and resuming
"""
import json
import argparse
import sys
import re
import os
import random
from pathlib import Path
import urllib.request
# Add src to sys.path so prompts can be imported from anywhere
sys.path.insert(0, str(Path(__file__).parent))
from prompts import build_prompt
def extract_answer_digit(text: str) -> str:
"""Extracts the chosen option index (1, 2, 3, 4) from model output text."""
if not text:
return "1"
# If '' is present in content, only parse after ''
if "" in text:
text = text.split("")[-1]
text = text.strip()
# 1. Explicit 'Answer: X' or 'පිළිතුර: X' or 'Option X' (take last match if CoT text precedes it)
explicit_matches = re.findall(
r"(?:පිළිතුර|Answer|Final Answer|Correct Option|නිවැරදි පිළිතුර|තෝරාගැනීම)\s*(?:is|වන්නේ)?\s*[:\s*#(\[]*(?:Option\s*)?([1-4])\b",
text,
re.IGNORECASE,
)
if explicit_matches:
return explicit_matches[-1]
# 2. Match leading digit if response is just a digit
m = re.match(r"^[(\[]?\s*([1-4])\b", text)
if m and len(text) <= 8:
return m.group(1)
# 3. Match the last standalone digit 1-4 in the response
matches = re.findall(r"\b([1-4])\b", text)
if matches:
return matches[-1]
m = re.search(r"[1-4]", text)
if m:
return m.group(0)
# Fallback default
return "1"
def load_all_questions(data_dir: Path):
"""Loads all questions from easy, medium, and hard folders in consistent order."""
questions = []
levels = ["easy", "medium", "hard"]
for lvl in levels:
folder = data_dir / lvl
if not folder.exists():
continue
files = sorted(list(folder.glob("*.json")))
for f in files:
with open(f, "r", encoding="utf-8") as fp:
data = json.load(fp)
for item in data:
item["_level"] = lvl
item["_source_file"] = f.name
questions.append(item)
return questions
def load_val_questions(gold_val_path: Path):
"""Loads questions from the gold validation file."""
questions = []
with open(gold_val_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
d = json.loads(line)
d["_level"] = d["level"]
questions.append(d)
return questions
def make_q_key(record: dict) -> str:
lvl = str(record.get("level", "")).lower().strip()
subj = str(record.get("subject", "")).strip()
q_no = str(record.get("q_no", "")).strip()
return f"{lvl}_{subj}_{q_no}"
def load_existing_checkpoint(output_path: Path):
"""Loads existing predictions from output_file if resuming, indexed by composite key."""
existing = {}
if output_path.exists():
with open(output_path, "r", encoding="utf-8") as fp:
for line in fp:
line = line.strip()
if line:
try:
record = json.loads(line)
key = make_q_key(record)
existing[key] = record
except Exception:
pass
return existing
SYSTEM_EXAM_PROMPT = (
"You are an examination AI for the Sri Lankan school curriculum (Sinhala medium). "
"For every multiple-choice question, analyze the options carefully and answer by replying "
"with ONLY the single digit of the best option (1, 2, 3, or 4). "
"Do NOT provide explanations, do NOT include option text, and do NOT write anything other than the single digit."
)
SYSTEM_COT_PROMPT = (
"You are an expert examiner for the Sri Lankan school curriculum (Sinhala medium). "
"For each multiple-choice question, write a concise 1-2 sentence English translation and fact-check ('Analysis:'), "
"then end on a new line with strictly 'Answer: ' (where is 1, 2, 3, or 4)."
)
SYSTEM_THINK_PROMPT = (
"You are an expert teacher of the Sri Lankan national school curriculum (grades 6-13), "
"fluent in Sinhala. You will receive a multiple-choice question written in Sinhala with four "
"numbered options. Exactly one option is correct. Read the Sinhala carefully, including "
"negations such as 'නොවන' / 'නොවේ' (NOT). You may reason in English. "
"Finish with the final answer in the form 'Answer: '."
)
ANSWER_THINK_RE = re.compile(r"(?:Answer|පිළිතුර)\s*[::]?\s*\**\s*\(?([1-4])\)?", re.IGNORECASE)
def build_think_user_prompt(q: dict) -> str:
"""Builds the Zero-Shot Curriculum Teacher user prompt with Subject & Grade context (72.82% val accuracy)."""
choices = q.get("choices")
if choices is None:
choices = q.get("options", [])
if isinstance(choices, dict):
keys = sorted(choices.keys(), key=lambda x: str(x))
opt_list = [choices[k] for k in keys]
else:
opt_list = list(choices) if choices else []
opts = "\n".join(f"{i}. {c}" for i, c in enumerate(opt_list, 1))
lvl = str(q.get("_level", q.get("level", "medium"))).lower().strip()
grade = "6-9" if lvl == "easy" else ("10-11 (O/L)" if lvl == "medium" else "12-13 (A/L)")
subj = q.get("subject", "General")
qtext = q.get("question", "").strip()
return (
f"විෂය (Subject): {subj} | ශ්රේණිය (Grade): {grade}\n\n"
f"ප්රශ්නය (Question):\n{qtext}\n\n"
f"විකල්ප (Options):\n{opts}\n\n"
"Which option (1, 2, 3 or 4) is correct? End your reply with 'Answer: '."
)
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def query_ollama_chat(
model_name: str,
system_prompt: str,
user_prompt: str,
think: bool = False,
cot: bool = False,
timeout: int = 240
) -> tuple[str, str]:
"""Sends a chat request to local Ollama /api/chat and returns (raw_text, extracted_digit)."""
if think:
num_predict = 2048
elif cot:
num_predict = 260
else:
num_predict = 15
options = {
"temperature": 0.0,
"num_predict": num_predict,
}
if not think:
options["stop"] = ["\nප්රශ්නය:", "\nQuestion:", "<|im_end|>", "<|endoftext|>", "<|im_start|>"]
req_data = json.dumps({
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"stream": False,
"think": think,
"options": options
}).encode("utf-8")
req = urllib.request.Request(
"http://127.0.0.1:11434/api/chat",
data=req_data,
headers={"Content-Type": "application/json"}
)
with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp:
res = json.loads(resp.read().decode("utf-8"))
msg = res.get("message", {})
content = msg.get("content", "")
thinking = msg.get("thinking", "")
if think:
m = ANSWER_THINK_RE.findall(content) or ANSWER_THINK_RE.findall(thinking)
if m:
return content or thinking, m[-1]
combined = (thinking + "\n" + content).strip()
return combined, extract_answer_digit(content if content.strip() else thinking)
return content, extract_answer_digit(content)
def save_merged_full_predictions(
canonical_questions: list,
completed_map: dict,
fallback_map: dict,
merged_path: Path
):
"""Writes a complete 2,238-line prediction file in canonical order using completed_map + fallback_map."""
with open(merged_path, "w", encoding="utf-8") as fp:
for q in canonical_questions:
q_no = q["q_no"]
subj = q.get("subject", "general")
cat = q.get("category", "general")
lvl = q["_level"].lower()
q_key = f"{lvl}_{subj}_{q_no}"
if q_key in completed_map:
rec = completed_map[q_key]
elif q_key in fallback_map:
rec = fallback_map[q_key]
else:
rec = {
"q_no": q_no,
"subject": subj,
"category": cat,
"level": lvl,
"answer": "1"
}
fp.write(json.dumps(rec, ensure_ascii=False) + "\n")
def run_inference(
data_dir: str,
output_file: str,
engine: str,
model_name: str,
mock: bool,
max_questions: int = None,
resume: bool = True,
val_only: bool = False,
gold_val_file: str = "data/gold_val_41.jsonl",
think: bool = False,
cot: bool = False,
priority_disputed: bool = False,
fallback_file: str = "data/predictions_gemma4_cot.jsonl",
compare_file: str = "data/predictions_gemma4_direct.jsonl",
merged_output: str = "data/predictions_gemma4_think_full.jsonl"
):
if val_only:
val_path = Path(gold_val_file)
if not val_path.exists():
print(f"Error: Gold validation file '{gold_val_file}' not found!")
sys.exit(1)
all_questions = load_val_questions(val_path)
print(f"Loaded {len(all_questions)} validation questions from '{gold_val_file}'.")
else:
data_path = Path(data_dir)
if not data_path.exists():
print(f"Error: Data directory '{data_dir}' not found!")
sys.exit(1)
all_questions = load_all_questions(data_path)
print(f"Loaded {len(all_questions)} questions across easy, medium, and hard.")
canonical_questions = list(all_questions)
fallback_map = {}
compare_map = {}
if not val_only and Path(fallback_file).exists():
fallback_map = load_existing_checkpoint(Path(fallback_file))
if not val_only and Path(compare_file).exists():
compare_map = load_existing_checkpoint(Path(compare_file))
if priority_disputed and not val_only and fallback_map and compare_map:
lvl_rank = {"hard": 0, "medium": 1, "easy": 2}
def sort_key(q):
lvl = q["_level"].lower()
subj = q.get("subject", "general")
q_no = q["q_no"]
k = f"{lvl}_{subj}_{q_no}"
a1 = fallback_map.get(k, {}).get("answer", "1")
a2 = compare_map.get(k, {}).get("answer", "1")
is_disputed = 0 if a1 != a2 else 1
return (is_disputed, lvl_rank.get(lvl, 3))
all_questions = sorted(all_questions, key=sort_key)
disputed_cnt = sum(
1 for q in all_questions
if fallback_map.get(f"{q['_level'].lower()}_{q.get('subject','general')}_{q['q_no']}", {}).get("answer")
!= compare_map.get(f"{q['_level'].lower()}_{q.get('subject','general')}_{q['q_no']}", {}).get("answer")
)
print(f"Priority-Disputed Mode ON: Processing {disputed_cnt} disputed questions first (Hard -> Medium -> Easy)!")
if max_questions and max_questions > 0:
all_questions = all_questions[:max_questions]
print(f"Limiting inference to first {len(all_questions)} questions as requested.")
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
merged_path = Path(merged_output)
existing_preds = {}
if resume and output_path.exists():
existing_preds = load_existing_checkpoint(output_path)
print(f"Resuming: Found {len(existing_preds)} existing predictions in '{output_file}'.")
predictions = []
completed_map = dict(existing_preds)
if mock:
print("Running in MOCK mode (generating valid random predictions)...")
for q in all_questions:
q_no = q["q_no"]
subj = q.get("subject", "general")
lvl = q["_level"].lower()
q_key = f"{lvl}_{subj}_{q_no}"
if resume and q_key in existing_preds:
predictions.append(existing_preds[q_key])
continue
pred_record = {
"q_no": q_no,
"subject": subj,
"category": q.get("category", "general"),
"level": lvl,
"answer": str(random.randint(1, 4))
}
predictions.append(pred_record)
completed_map[q_key] = pred_record
elif engine == "ollama":
print(f"Running inference with local Ollama model: '{model_name}' via /api/chat (think={think}, cot={cot})...")
if think:
sys_prompt = SYSTEM_THINK_PROMPT
elif cot:
sys_prompt = SYSTEM_COT_PROMPT
else:
sys_prompt = SYSTEM_EXAM_PROMPT
for i, q in enumerate(all_questions, 1):
q_no = q["q_no"]
subj = q.get("subject", "general")
cat = q.get("category", "general")
lvl = q["_level"].lower()
q_key = f"{lvl}_{subj}_{q_no}"
if resume and q_key in existing_preds:
predictions.append(existing_preds[q_key])
continue
if think:
prompt = build_think_user_prompt(q)
else:
prompt = build_prompt(q)
if cot:
prompt += "\nAnalysis:"
raw = ""
try:
raw, ans_digit = query_ollama_chat(model_name, sys_prompt, prompt, think=think, cot=cot)
except Exception as e:
print(f"[{i}/{len(all_questions)}] Ollama error on Q#{q_no} ({subj}): {e}. Using fallback 1.")
ans_digit = fallback_map.get(q_key, {}).get("answer", "1")
record = {
"q_no": q_no,
"subject": subj,
"category": cat,
"level": lvl,
"answer": str(ans_digit)
}
predictions.append(record)
completed_map[q_key] = record
if i % 10 == 0 or i == len(all_questions):
print(f"Progress: {i}/{len(all_questions)} processed (Total solved={len(completed_map)}). Latest ({lvl}/{subj} Q#{q_no}): => {ans_digit}")
with open(output_path, "w", encoding="utf-8") as fp:
for p in predictions:
fp.write(json.dumps(p, ensure_ascii=False) + "\n")
if not val_only and fallback_map:
save_merged_full_predictions(canonical_questions, completed_map, fallback_map, merged_path)
else:
print(f"Unsupported engine: {engine}")
sys.exit(1)
# Write final predictions
with open(output_path, "w", encoding="utf-8") as fp:
for p in predictions:
fp.write(json.dumps(p, ensure_ascii=False) + "\n")
if not val_only and fallback_map:
save_merged_full_predictions(canonical_questions, completed_map, fallback_map, merged_path)
print(f"Complete 2,238-line merged file updated at: {merged_output}")
print(f"\nDONE! {len(predictions)} predictions written to {output_file}")
if not val_only:
target_sub = merged_output if (priority_disputed and fallback_map) else output_file
print(f"To package and validate for Codabench at any time, run:")
print(f" python3 src/package_submission.py {target_sub} submission_gemma4_think.zip\n")
def main():
parser = argparse.ArgumentParser(description="Sinhala MMLU Baseline Runner")
parser.add_argument("--data_dir", default="data", help="Path to data directory containing easy, medium, hard")
parser.add_argument("--output", default="data/predictions.jsonl", help="Output predictions path")
parser.add_argument("--engine", choices=["ollama"], default="ollama", help="Inference engine")
parser.add_argument("--model", default="qwen3.5:4b", help="Model name in Ollama")
parser.add_argument("--mock", action="store_true", help="Generate fast mock predictions to test pipeline")
parser.add_argument("--max_questions", type=int, default=None, help="Process only first N questions")
parser.add_argument("--no_resume", action="store_true", help="Do not resume from existing checkpoint")
parser.add_argument("--val_only", action="store_true", help="Run only on the 41-question local gold benchmark")
parser.add_argument("--gold_val_file", default="data/gold_val_41.jsonl", help="Gold validation benchmark file")
parser.add_argument("--think", action="store_true", help="Enable native thinking mode (72.82%% val accuracy on gemma4:e4b)")
parser.add_argument("--cot", action="store_true", help="Enable concise Cross-Lingual Translate-and-Reason CoT mode")
parser.add_argument("--priority_disputed", action="store_true", help="Solve disputed questions first and continuously update 2238-line merged output")
parser.add_argument("--merged_output", default="data/predictions_gemma4_think_full.jsonl", help="Path for continuously updated 2238-line full submission file")
args = parser.parse_args()
run_inference(
args.data_dir,
args.output,
args.engine,
args.model,
args.mock,
args.max_questions,
resume=not args.no_resume,
val_only=args.val_only,
gold_val_file=args.gold_val_file,
think=args.think,
cot=args.cot,
priority_disputed=args.priority_disputed,
merged_output=args.merged_output
)
if __name__ == "__main__":
main()