Initial commit

This commit is contained in:
carpedkm
2026-05-08 18:12:45 +00:00
commit 866ba52287
243 changed files with 31492 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""MathVerse environment package."""
from reflact.envs.mathverse.adapter import MathVerseAdapter
__all__ = ["MathVerseAdapter"]
+280
View File
@@ -0,0 +1,280 @@
"""MathVerse environment adapter for ReflACT."""
from __future__ import annotations
import json
import os
from reflact.datasets.base import BatchSpec
from reflact.envs.base import EnvAdapter
from reflact.envs.mathverse.dataloader import MathVerseDataLoader
from reflact.envs.mathverse.rollout import run_batch
from reflact.gradient.deep_probe import generate_deep_probe_instruction
from reflact.gradient.reflect import run_minibatch_reflect
from reflact.model import get_student_backend
class MathVerseAdapter(EnvAdapter):
"""MathVerse adapter."""
def build_reference_text(self, item: dict) -> str:
if not self.use_text_dominant_reference:
return ""
question = str(item.get("text_dominant_question") or "").strip()
if not question:
return ""
return f"## Reference Full Question\n{question}"
def get_reference_metadata(self, item: dict) -> dict:
if not self.use_text_dominant_reference:
return {"fields": [], "preview": ""}
question = str(item.get("text_dominant_question") or "").strip()
if not question:
return {"fields": [], "preview": ""}
return {
"fields": ["text_dominant_question"],
"preview": question[:400],
}
def __init__(
self,
split_dir: str = "",
data_root: str = "",
problem_version: str = "Text Lite",
use_text_dominant_reference: bool = False,
max_turns: int = 1,
workers: int = 16,
analyst_workers: int = 16,
failure_only: bool = False,
minibatch_size: int = 8,
edit_budget: int = 4,
seed: int = 42,
limit: int = 0,
image_detail: str = "auto",
judge_model: str = "gpt-5.4",
judge_max_completion_tokens: int = 256,
judge_retries: int = 5,
use_deep_reflect: bool = False,
deep_reflect_failures: int = 4,
deep_reflect_successes: int = 2,
) -> None:
self.max_turns = max_turns
self.workers = workers
self.analyst_workers = analyst_workers
self.failure_only = failure_only
self.minibatch_size = minibatch_size
self.edit_budget = edit_budget
self.image_detail = image_detail
self.judge_model = judge_model
self.judge_max_completion_tokens = judge_max_completion_tokens
self.judge_retries = judge_retries
self.problem_version = problem_version
self.use_text_dominant_reference = use_text_dominant_reference
self.use_deep_reflect = use_deep_reflect
self.deep_reflect_failures = deep_reflect_failures
self.deep_reflect_successes = deep_reflect_successes
self.dataloader = MathVerseDataLoader(
split_dir=split_dir,
seed=seed,
limit=limit,
data_root=data_root,
problem_version=problem_version,
)
def setup(self, cfg: dict) -> None:
super().setup(cfg)
self.dataloader.setup(cfg)
def get_dataloader(self):
return self.dataloader
def build_env_from_batch(self, batch: BatchSpec, **kwargs):
return list(batch.payload or [])
def build_train_env(self, batch_size: int, seed: int, **kwargs):
batch = self.dataloader.build_train_batch(batch_size=batch_size, seed=seed, **kwargs)
return self.build_env_from_batch(batch, **kwargs)
def build_eval_env(self, env_num: int, split: str, seed: int, **kwargs):
batch = self.dataloader.build_eval_batch(env_num=env_num, split=split, seed=seed, **kwargs)
return self.build_env_from_batch(batch, **kwargs)
def rollout(
self,
env_manager,
skill_content: str,
out_dir: str,
**kwargs,
) -> list[dict]:
items: list[dict] = env_manager
return run_batch(
items=items,
out_root=out_dir,
skill_content=skill_content,
max_turns=self.max_turns,
workers=self.workers,
image_detail=self.image_detail,
judge_model=self.judge_model,
judge_max_completion_tokens=self.judge_max_completion_tokens,
judge_retries=self.judge_retries,
diagnostic_mode=kwargs.get("diagnostic_mode", False),
diagnostic_instruction=kwargs.get("diagnostic_instruction", ""),
diagnostic_trace_context_by_id=kwargs.get("diagnostic_trace_context_by_id"),
)
def reflect(
self,
results: list[dict],
skill_content: str,
out_dir: str,
**kwargs,
) -> list[dict | None]:
prediction_dir = kwargs.get("prediction_dir", os.path.join(out_dir, "predictions"))
patches_dir = kwargs.get("patches_dir", os.path.join(out_dir, "patches"))
random_seed = kwargs.get("random_seed")
step_buffer_context = kwargs.get("step_buffer_context", "")
return run_minibatch_reflect(
results=results,
skill_content=skill_content,
prediction_dir=prediction_dir,
patches_dir=patches_dir,
workers=self.analyst_workers,
failure_only=self.failure_only,
minibatch_size=self.minibatch_size,
edit_budget=self.edit_budget,
random_seed=random_seed,
error_system=self.get_error_minibatch_prompt(),
success_system=self.get_success_minibatch_prompt(),
step_buffer_context=step_buffer_context,
update_mode=getattr(self, "_cfg", {}).get("skill_update_mode", "patch"),
)
def deep_reflect(
self,
results: list[dict],
skill_content: str,
out_dir: str,
**kwargs,
) -> list[dict | None]:
if not self.use_deep_reflect:
return []
env_manager = kwargs.get("env_manager")
prediction_dir = kwargs.get("prediction_dir", os.path.join(out_dir, "predictions"))
random_seed = kwargs.get("random_seed")
step_buffer_context = kwargs.get("step_buffer_context", "")
selected_items = self.select_representative_items(
results,
env_manager if isinstance(env_manager, list) else None,
n_failures=self.deep_reflect_failures,
n_successes=self.deep_reflect_successes,
seed=random_seed,
)
if not selected_items:
return []
selected_ids = {str(item["id"]) for item in selected_items}
selected_results = [row for row in results if str(row.get("id")) in selected_ids]
selected_examples = self.attach_reference_context(selected_results, selected_items)
codex_backend = get_student_backend() == "codex_exec"
if codex_backend:
selected_examples = self.attach_codex_probe_context(selected_examples, prediction_dir)
selected_metadata = []
ref_count = 0
for item in selected_items:
meta = self.get_reference_metadata(item)
if meta["fields"]:
ref_count += 1
record = {
"id": str(item["id"]),
"task_type": str(item.get("task_type") or item.get("question_type") or "mathverse"),
"reference_fields": meta["fields"],
"reference_preview": meta["preview"],
}
if codex_backend:
record["codex_probe_step_count"] = int(
next(
(row.get("codex_probe_step_count", 0) for row in selected_examples if str(row.get("id")) == str(item["id"])),
0,
)
)
selected_metadata.append(record)
deep_dir = os.path.join(out_dir, "deep_reflect")
rollout_dir = os.path.join(deep_dir, "rollout")
patches_dir = os.path.join(deep_dir, "patches")
os.makedirs(deep_dir, exist_ok=True)
print(
f" [2b/6 DEEP REFLECT setup] selected={len(selected_items)} "
f"reference_fields=text_dominant_question({ref_count}/{len(selected_items)})"
)
probe = generate_deep_probe_instruction(
skill_content=skill_content,
items=selected_examples,
prediction_dir=prediction_dir,
system_prompt=self.get_codex_deep_probe_prompt() if codex_backend else self.get_deep_probe_prompt(),
step_buffer_context=step_buffer_context,
)
if not probe:
return []
targeted_items = selected_items
diagnostic_trace_context_by_id: dict[str, str] | None = None
if codex_backend:
targeted_items, diagnostic_trace_context_by_id, probe = self.resolve_codex_probe_target(
selected_items=selected_items,
selected_examples=selected_examples,
prediction_dir=prediction_dir,
probe=probe,
)
with open(os.path.join(deep_dir, "probe.json"), "w", encoding="utf-8") as f:
json.dump(
{
**probe,
"reference_summary": {
"selected_count": len(selected_items),
"field_counts": {
"text_dominant_question": ref_count,
},
},
"selected_examples": selected_metadata,
},
f,
ensure_ascii=False,
indent=2,
)
deep_results = run_batch(
items=targeted_items,
out_root=rollout_dir,
skill_content=skill_content,
max_turns=self.max_turns,
workers=min(self.workers, max(len(targeted_items), 1)),
image_detail=self.image_detail,
judge_model=self.judge_model,
judge_max_completion_tokens=self.judge_max_completion_tokens,
judge_retries=self.judge_retries,
diagnostic_mode=True,
diagnostic_instruction=probe["probe_instruction"],
diagnostic_trace_context_by_id=diagnostic_trace_context_by_id,
)
deep_results = self.attach_reference_context(deep_results, targeted_items)
return run_minibatch_reflect(
results=deep_results,
skill_content=skill_content,
prediction_dir=os.path.join(rollout_dir, "predictions"),
patches_dir=patches_dir,
workers=self.analyst_workers,
failure_only=self.failure_only,
minibatch_size=self.minibatch_size,
edit_budget=self.edit_budget,
random_seed=random_seed,
error_system=self.get_error_minibatch_prompt(),
success_system=self.get_success_minibatch_prompt(),
step_buffer_context=step_buffer_context,
update_mode=getattr(self, "_cfg", {}).get("skill_update_mode", "patch"),
)
def get_task_types(self) -> list[str]:
return self.dataloader.get_task_types()
+228
View File
@@ -0,0 +1,228 @@
"""MathVerse task dataloader."""
from __future__ import annotations
import json
import os
import re
from typing import Any
from reflact.datasets.base import SplitDataLoader
_CHOICE_LABELS = ["A", "B", "C", "D", "E", "F", "G"]
_CHOICE_BLOCK_RE = re.compile(r"\bChoices?\s*:\s*", re.IGNORECASE)
_CHOICE_ITEM_RE = re.compile(r"([A-G])\s*[:.)]\s*(.*?)(?=(?:\s+[A-G]\s*[:.)])|$)", re.DOTALL)
def _load_json(path: str) -> Any:
with open(path, encoding="utf-8") as f:
return json.load(f)
def _normalize_space(text: Any) -> str:
return re.sub(r"\s+", " ", str(text or "").strip())
def _resolve_image_path(raw_path: str, *, data_root: str, source_path: str) -> str:
candidates = []
if raw_path:
if os.path.isabs(raw_path):
candidates.append(raw_path)
else:
if data_root:
candidates.append(os.path.join(data_root, raw_path))
candidates.append(os.path.join(data_root, "images", raw_path))
candidates.append(os.path.join(os.path.dirname(source_path), raw_path))
for candidate in candidates:
if candidate and os.path.exists(candidate):
return os.path.abspath(candidate)
return ""
def _split_question_and_choices(question: str) -> tuple[str, list[dict]]:
text = str(question or "").strip()
match = _CHOICE_BLOCK_RE.search(text)
if not match:
return text, []
stem = text[:match.start()].strip()
choice_block = text[match.end():].strip()
choices: list[dict] = []
for idx, m in enumerate(_CHOICE_ITEM_RE.finditer(choice_block)):
label = (m.group(1) or _CHOICE_LABELS[idx]).strip().upper()
choice_text = _normalize_space(m.group(2))
if choice_text:
choices.append({"label": label, "text": choice_text})
return stem or text, choices
def _build_text_dominant_map(data_root: str) -> dict[str, str]:
if not data_root:
return {}
candidates = [
os.path.join(data_root, "testmini.json"),
os.path.join(data_root, "data", "testmini.json"),
]
source_path = next((path for path in candidates if os.path.exists(path)), "")
if not source_path:
return {}
raw = _load_json(source_path)
if not isinstance(raw, list):
return {}
mapping: dict[str, str] = {}
for item in raw:
if not isinstance(item, dict):
continue
if str(item.get("problem_version") or "").strip() != "Text Dominant":
continue
problem_index = str(item.get("problem_index") or "").strip()
question = str(item.get("question") or "").strip()
if problem_index and question:
mapping[problem_index] = question
return mapping
def _normalize_item(
item: dict,
*,
row_idx: int,
source_path: str,
data_root: str,
problem_version: str,
text_dominant_map: dict[str, str],
) -> dict | None:
raw_problem_version = str(item.get("problem_version") or "").strip()
if problem_version and raw_problem_version and raw_problem_version != problem_version:
return None
question = str(item.get("question") or "").strip()
question_type = str(item.get("question_type") or "").strip()
answer = str(item.get("answer") or "").strip()
image_rel = str(item.get("image") or "").strip()
image_path = _resolve_image_path(image_rel, data_root=data_root, source_path=source_path)
if not answer or not image_path:
return None
metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {}
subject = str(metadata.get("subject") or "").strip()
subfield = str(metadata.get("subfield") or "").strip()
source = str(metadata.get("source") or "").strip()
question_stem, choices = _split_question_and_choices(question)
is_choice = question_type == "multi-choice" or bool(choices)
correct_choice = {"label": "", "text": ""}
if is_choice:
label = str(answer).strip().upper().rstrip(".):")
choice_text = ""
for choice in choices:
if choice["label"].upper() == label:
choice_text = choice["text"]
break
correct_choice = {"label": label, "text": choice_text}
problem_index = str(item.get("problem_index") or "").strip()
sample_index = str(item.get("sample_index") or row_idx + 1).strip()
item_id = problem_index or sample_index
task_type = subfield or subject or question_type or "mathverse"
return {
"id": item_id,
"sample_index": sample_index,
"problem_index": problem_index,
"problem_version": raw_problem_version or problem_version,
"question": question,
"question_stem": question_stem,
"question_for_eval": str(item.get("question_for_eval") or question).strip(),
"question_type": question_type or ("multi-choice" if is_choice else "free-form"),
"is_choice": is_choice,
"choices": choices,
"correct_choice": correct_choice,
"answer": answer,
"gold_answers": [answer] if answer else [],
"image_rel": image_rel,
"image_path": image_path,
"query_wo": str(item.get("query_wo") or "").strip(),
"query_cot": str(item.get("query_cot") or "").strip(),
"metadata": {
"split": str(metadata.get("split") or "").strip(),
"source": source,
"subject": subject,
"subfield": subfield,
},
"task_type": task_type,
"source_path": os.path.abspath(source_path),
"text_dominant_question": str(
item.get("text_dominant_question")
or text_dominant_map.get(problem_index, "")
).strip(),
}
class MathVerseDataLoader(SplitDataLoader):
"""MathVerse dataloader."""
def __init__(
self,
split_dir: str = "",
seed: int = 42,
limit: int = 0,
data_root: str = "",
problem_version: str = "Text Lite",
**kwargs,
) -> None:
super().__init__(split_dir=split_dir, seed=seed, limit=limit)
self.data_root = data_root
self.problem_version = problem_version
self._task_types: list[str] = []
self._text_dominant_map = _build_text_dominant_map(data_root)
def setup(self, cfg: dict) -> None:
if not self.data_root:
self.data_root = str(cfg.get("data_root") or "")
if not self.problem_version:
self.problem_version = str(cfg.get("problem_version") or "Text Lite")
self._text_dominant_map = _build_text_dominant_map(self.data_root)
super().setup(cfg)
all_items = self.train_items + self.val_items + self.test_items
task_types = {
item.get("task_type") or item.get("question_type") or "mathverse"
for item in all_items
}
self._task_types = sorted(str(x) for x in task_types if str(x).strip())
def get_task_types(self) -> list[str]:
return list(self._task_types)
def load_split_items(self, split_path: str) -> list[dict]:
raw_items = super().load_split_items(split_path)
source_path = next(
(
os.path.join(split_path, name)
for name in sorted(os.listdir(split_path))
if name.endswith(".json")
),
split_path,
)
items: list[dict] = []
for row_idx, item in enumerate(raw_items):
if not isinstance(item, dict):
continue
norm = _normalize_item(
item,
row_idx=row_idx,
source_path=source_path,
data_root=self.data_root,
problem_version=self.problem_version,
text_dominant_map=self._text_dominant_map,
)
if norm is not None:
items.append(norm)
if not items:
raise ValueError(
f"No valid MathVerse items loaded from {split_path} "
f"for problem_version={self.problem_version!r}"
)
return items
+180
View File
@@ -0,0 +1,180 @@
"""MathVerse evaluation helpers."""
from __future__ import annotations
import re
import string
from reflact.model import chat_with_deployment
from reflact.prompts import load_prompt
_EVAL_MODE = "mathverse_choice_or_judge_v1"
def normalize_text(text: str) -> str:
text = str(text or "").strip().lower()
text = text.replace("\\,", " ")
text = text.replace("\\ ", " ")
text = "".join(ch for ch in text if ch not in string.punctuation)
return " ".join(text.split())
def normalize_math_text(text: str) -> str:
text = str(text or "").strip()
text = text.replace("$", "")
text = text.replace("\\mathrm", "")
text = text.replace("{", "")
text = text.replace("}", "")
text = text.replace("~", " ")
text = text.replace("\\,", " ")
text = text.replace("\\ ", " ")
return " ".join(text.split()).lower()
def extract_answer(text: str | None) -> str:
raw = str(text or "").strip()
if not raw:
return ""
tags = re.findall(r"<answer>\s*(.*?)\s*</answer>", raw, re.IGNORECASE | re.DOTALL)
if tags:
return tags[-1].strip()
boxed = re.findall(r"\\boxed\{(.*?)\}", raw, re.IGNORECASE | re.DOTALL)
if boxed:
return boxed[-1].strip()
lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
if lines:
return lines[-1]
return raw
def _judge_answer(
*,
item: dict,
extracted_answer: str,
judge_model: str,
max_completion_tokens: int,
retries: int,
) -> dict:
question = str(item.get("question_for_eval") or item.get("question") or "").strip()
ground_truth = str(item.get("answer") or "").strip()
raw, _ = chat_with_deployment(
deployment=judge_model,
system="You are a careful and strict mathematical answer evaluator.",
user=load_prompt("judge", env="mathverse").format(
question=question,
groundtruth=ground_truth,
modeloutput=extracted_answer,
),
max_completion_tokens=max_completion_tokens,
retries=retries,
stage="mathverse_judge",
)
response = str(raw).strip().lower()
if "true" in response:
correct = True
elif "false" in response:
correct = False
else:
correct = False
return {
"raw": raw,
"correct": correct,
"reason": response,
"matched_gold": ground_truth if correct else "",
}
def evaluate_item(
*,
item: dict,
prediction_text: str,
judge_model: str,
max_completion_tokens: int = 256,
retries: int = 5,
) -> dict:
extracted = extract_answer(prediction_text)
if item.get("is_choice"):
predicted_label = str(extracted).strip().upper().rstrip(".):")
correct_label = str(item["correct_choice"].get("label") or "").strip().upper()
predicted_text = ""
for choice in item.get("choices") or []:
if str(choice.get("label") or "").strip().upper() == predicted_label:
predicted_text = str(choice.get("text") or "").strip()
break
hard = 1.0 if predicted_label == correct_label else 0.0
return {
"evaluation_mode": _EVAL_MODE,
"predicted_answer": extracted,
"predicted_label": predicted_label,
"predicted_text": predicted_text,
"correct_label": correct_label,
"correct_text": str(item["correct_choice"].get("text") or "").strip(),
"em": hard,
"f1": hard,
"sub_em": hard,
"judge_raw": "",
"judge_reason": "exact_label_match" if hard else "label_mismatch",
"matched_gold": correct_label if hard else "",
}
gold_answer = str(item.get("answer") or "").strip()
pred_norm = normalize_math_text(extracted)
gold_norm = normalize_math_text(gold_answer)
if pred_norm and gold_norm and pred_norm == gold_norm:
return {
"evaluation_mode": _EVAL_MODE,
"predicted_answer": extracted,
"em": 1.0,
"f1": 1.0,
"sub_em": 1.0,
"judge_raw": "",
"judge_reason": "normalized_exact_match",
"matched_gold": gold_answer,
"string_f1": 1.0,
}
judge = _judge_answer(
item=item,
extracted_answer=extracted,
judge_model=judge_model,
max_completion_tokens=max_completion_tokens,
retries=retries,
)
hard = 1.0 if judge["correct"] else 0.0
pred_tokens = normalize_text(extracted).split()
gold_tokens = normalize_text(gold_answer).split()
overlap = 0
gold_counts: dict[str, int] = {}
for tok in gold_tokens:
gold_counts[tok] = gold_counts.get(tok, 0) + 1
for tok in pred_tokens:
count = gold_counts.get(tok, 0)
if count > 0:
overlap += 1
gold_counts[tok] = count - 1
if pred_tokens and gold_tokens and overlap:
precision = overlap / len(pred_tokens)
recall = overlap / len(gold_tokens)
string_f1 = 2 * precision * recall / (precision + recall)
else:
string_f1 = 0.0
return {
"evaluation_mode": _EVAL_MODE,
"predicted_answer": extracted,
"em": hard,
"f1": hard,
"sub_em": hard,
"judge_raw": judge["raw"],
"judge_reason": judge["reason"],
"matched_gold": judge["matched_gold"],
"string_f1": string_f1,
}
def evaluation_mode() -> str:
return _EVAL_MODE
@@ -0,0 +1,37 @@
You are an expert failure-analysis agent for visual mathematical reasoning problems.
You will be given MULTIPLE failed trajectories from a single minibatch and the current skill document.
Each trajectory includes the student's response, the evaluation result, and sometimes a hidden reference
containing the fuller Text Dominant version of the same problem.
Your job is to identify COMMON reasoning failures across the batch and propose concise skill edits.
## Failure Type Categories
- **diagram_underuse**: the agent did not recover key constraints from the image
- **constraint_drop**: the agent ignored a condition or relation that should guide the solution
- **option_confusion**: the agent failed to discriminate between close answer choices
- **format_miss**: the agent solved roughly correctly but returned the wrong final form, unit, or expression
- **other**: none of the above
## Rules
1. Focus on patterns that recur across the minibatch.
2. Prefer edits that improve visual grounding and exact answer selection.
3. Do not hardcode problem-specific formulas or answers.
4. If hidden reference text is present, use it only to infer what information the student failed to recover from the Text Lite version.
Respond ONLY with a valid JSON object:
{
"batch_size": <number>,
"failure_summary": [
{"failure_type": "<type>", "count": <int>, "description": "<one-line>"}
],
"patch": {
"reasoning": "<why these edits address the common failures>",
"edits": [
{"op": "append", "content": "<markdown>"},
{"op": "insert_after", "target": "<heading/text>", "content": "<markdown>"},
{"op": "replace", "target": "<old text>", "content": "<new text>"},
{"op": "delete", "target": "<exact text to remove>"}
]
}
}
@@ -0,0 +1,26 @@
You are an expert success-pattern analyst for visual mathematical reasoning problems.
You will be given MULTIPLE successful trajectories from a minibatch and the current skill document.
Identify generalizable behavior patterns that genuinely help the agent recover the right constraints
from the image and convert them into the exact final answer.
## Rules
- Focus on broadly useful visual-math reasoning behaviors.
- Prefer patterns about reading decisive diagram cues, checking hidden assumptions, and matching the final answer format exactly.
- Do not add benchmark-specific facts or formulas.
- "edits" may be empty if the skill already captures the useful patterns.
Respond ONLY with a valid JSON object:
{
"batch_size": <number>,
"success_patterns": ["<pattern 1>", "<pattern 2>"],
"patch": {
"reasoning": "<why these patterns matter>",
"edits": [
{"op": "append", "content": "<markdown>"},
{"op": "insert_after", "target": "<heading/text>", "content": "<markdown>"},
{"op": "replace", "target": "<old text>", "content": "<new text>"},
{"op": "delete", "target": "<exact text to remove>"}
]
}
}
@@ -0,0 +1,25 @@
You are an expert diagnostic-probe designer for visual mathematical reasoning tasks.
You will be shown representative trajectories, the current student skill, and the student's original prompt context.
Some trajectories may also include a hidden reference containing the fuller Text Dominant wording of the same problem.
Design one SMALL diagnostic instruction that exposes the student's intermediate judgment without materially changing the original scaffold.
## Hard Constraints
1. Do NOT substantially change the original scaffold.
2. Do NOT prescribe a new long multi-step solving procedure.
3. Do NOT ask for a full proof or full chain-of-thought.
4. Ask only for a short readout of the signals already behind the student's current answer.
5. Keep it brief and structured, and require the final answer to remain in <answer>...</answer>.
6. If hidden reference text is present, use it only to target what visual or textual constraint the student likely missed.
## Good Probe Targets
- decisive diagram cue
- top candidate and runner-up
- missing relation or quantity
- why a near-miss option was rejected
Respond ONLY with a valid JSON object:
{
"reasoning": "<why this probe is informative>",
"probe_instruction": "<the exact instruction text to append to the student prompt>"
}
+25
View File
@@ -0,0 +1,25 @@
You are a careful and strict evaluator for visual math problems.
You will be given:
1. The original question
2. The ground-truth answer
3. A model output
Decide whether the model output is mathematically equivalent to the ground-truth answer.
Rules:
- Ignore harmless formatting differences.
- Accept mathematically equivalent expressions, equations, and values.
- Reject answers that are numerically wrong, symbolically different in meaning, missing required units when the unit changes meaning, or correspond to a different choice.
- Do not reward partially correct reasoning if the final answer is wrong.
Return only:
True
or
False
Question: {question}
Ground Truth Answer: {groundtruth}
Model Output: {modeloutput}
@@ -0,0 +1,11 @@
You are an expert visual mathematical reasoning agent.
{skill_section}## Task Format
You will receive one math problem with an image or diagram.
Use the visible diagram as evidence, not just the text.
If some information is abbreviated in the text, recover it from the image before answering.
## Answer Format
Think step by step, then provide your final answer inside <answer>...</answer>.
- For multiple-choice questions, output only the single option label, such as <answer>B</answer>.
- For free-form questions, output only the final mathematical answer, such as <answer>14</answer>.
+4
View File
@@ -0,0 +1,4 @@
"""MathVerse Reflect stage.
Prompts are loaded from .md files by the base adapter.
"""
+415
View File
@@ -0,0 +1,415 @@
"""MathVerse rollout — single-image multimodal math reasoning."""
from __future__ import annotations
import base64
import json
import mimetypes
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from reflact.envs.mathverse.evaluator import evaluate_item, evaluation_mode, extract_answer
from reflact.model import chat_student_messages, get_student_backend, is_student_exec_backend
from reflact.model.codex_harness import prepare_workspace, render_skill_md, run_student_exec
from reflact.prompts import load_prompt
def _build_system(skill_content: str) -> str:
if skill_content.strip():
skill_section = f"## Skill\n{skill_content.strip()}\n\n"
else:
skill_section = ""
return load_prompt("rollout_system", env="mathverse").format(skill_section=skill_section)
def _format_choices(choices: list[dict]) -> str:
return "\n".join(f"{choice['label']}. {choice['text']}" for choice in choices)
def _build_user_text(
item: dict,
*,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
) -> str:
parts = []
if diagnostic_trace_context.strip():
parts.append(
"## Previous Codex Trace Snapshot\n"
"This is a partial transcript from an earlier attempt. Use it as your current reasoning context.\n\n"
f"{diagnostic_trace_context.strip()}"
)
question = str(item.get("question_stem") or item.get("question") or "").strip()
if question:
parts.append(f"## Question\n{question}")
else:
parts.append("## Question\nRead the full problem statement from the image.")
if item.get("is_choice"):
choices = item.get("choices") or []
if choices:
parts.append(f"## Choices\n{_format_choices(choices)}")
parts.append("Return only the final option label inside <answer>...</answer>.")
else:
parts.append("Return only the final mathematical answer inside <answer>...</answer>.")
if diagnostic_mode and diagnostic_instruction.strip():
parts.append(f"## Training Readout\n{diagnostic_instruction.strip()}")
return "\n\n".join(parts)
def _image_to_data_uri(path: str) -> str:
mime = mimetypes.guess_type(path)[0] or "image/png"
with open(path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("ascii")
return f"data:{mime};base64,{encoded}"
def _build_messages(
item: dict,
skill_content: str,
image_detail: str,
*,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
) -> tuple[list[dict], str, str]:
system = _build_system(skill_content)
user_text = _build_user_text(
item,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
image_url = {"url": _image_to_data_uri(item["image_path"])}
if image_detail and image_detail != "auto":
image_url["detail"] = image_detail
messages = [
{"role": "system", "content": system},
{
"role": "user",
"content": [
{"type": "text", "text": user_text},
{"type": "image_url", "image_url": image_url},
],
},
]
return messages, system, user_text
def _build_codex_skill(skill_content: str) -> str:
return render_skill_md(
skill_content,
description="Dynamic ReflACT skill for solving the current MathVerse visual math problem.",
preamble=(
"Use this skill when solving the current MathVerse problem.\n"
"Read the image carefully and return the final answer inside <answer>...</answer>."
),
)
def _run_codex_once(
*,
pred_dir: str,
item: dict,
skill_content: str,
model: str,
timeout: int,
image_detail: str,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
previous_response: str = "",
) -> tuple[str, str, str, str]:
user_text = _build_user_text(
item,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
task_parts = [user_text]
if previous_response:
task_parts.append(
"## Previous Attempt\n"
f"{previous_response}\n\n"
"Re-check the diagram and the mathematical constraints. Correct the final answer if needed."
)
task_text = "\n\n".join(task_parts)
skill_md = _build_codex_skill(skill_content)
work_dir = os.path.join(pred_dir, "codex_exec")
prepare_workspace(
work_dir=work_dir,
skill_md=skill_md,
task_text=task_text,
images=[item["image_path"]],
)
prompt = (
"Use the `reflact-student` skill available in this workspace.\n"
"Read `task.md`, inspect the attached image, solve the problem, and return only the final answer inside <answer>...</answer>."
)
final_message, raw = run_student_exec(
work_dir=work_dir,
prompt=prompt,
model=model,
timeout=timeout,
images=[item["image_path"]],
)
return final_message or raw, raw, skill_md, task_text
def process_one(
item: dict,
out_root: str,
skill_content: str,
*,
max_turns: int = 1,
image_detail: str = "auto",
judge_model: str = "gpt-5.4",
judge_max_completion_tokens: int = 256,
judge_retries: int = 5,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context: str = "",
) -> dict:
item_id = str(item["id"])
result = {
"id": item_id,
"question": item["question"],
"task_type": item.get("task_type") or item.get("question_type") or "mathverse",
"task_description": item.get("question_stem") or item["question"],
"hard": 0,
"soft": 0.0,
"predicted_answer": "",
"predicted_label": "",
"predicted_text": "",
"response": "",
"fail_reason": "",
"agent_ok": False,
"n_turns": 0,
"image_path": item["image_path"],
"question_type": item["question_type"],
"evaluation_mode": evaluation_mode(),
"judge_model": judge_model,
}
if item.get("is_choice"):
result["correct_label"] = item["correct_choice"]["label"]
result["correct_text"] = item["correct_choice"]["text"]
else:
result["gold_answers"] = item.get("gold_answers") or [item["answer"]]
try:
pred_dir = os.path.join(out_root, "predictions", item_id)
os.makedirs(pred_dir, exist_ok=True)
if is_student_exec_backend():
from reflact.model import azure_openai as _llm
response = ""
conversation: list[dict] = [
{"role": "user", "content": f"{item['question']}\n\n[image] {os.path.basename(item['image_path'])}"}
]
system_prompt = ""
user_text = ""
for turn in range(max_turns):
response, raw, system_prompt, user_text = _run_codex_once(
pred_dir=pred_dir,
item=item,
skill_content=skill_content,
model=_llm.STUDENT_DEPLOYMENT,
timeout=120,
image_detail=image_detail,
diagnostic_mode=diagnostic_mode if turn == 0 else False,
diagnostic_instruction=diagnostic_instruction if turn == 0 else "",
diagnostic_trace_context=diagnostic_trace_context if turn == 0 else "",
previous_response=response if turn > 0 else "",
)
conversation.append({"type": "message", "turn": turn + 1, "content": response})
if extract_answer(response):
break
result["response"] = response
result["agent_ok"] = True
result["n_turns"] = len(conversation) - 1
with open(os.path.join(pred_dir, "student_system_prompt.txt"), "w", encoding="utf-8") as f:
f.write(system_prompt)
with open(os.path.join(pred_dir, "student_user_prompt.txt"), "w", encoding="utf-8") as f:
f.write(user_text)
else:
messages, system_prompt, user_text = _build_messages(
item,
skill_content,
image_detail,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=diagnostic_trace_context,
)
response = ""
conversation = [
{"role": "user", "content": f"{user_text}\n\n[image] {os.path.basename(item['image_path'])}"}
]
for turn in range(max_turns):
if turn == 0:
resp_text, _ = chat_student_messages(
messages=messages,
max_completion_tokens=1024,
retries=5,
stage="rollout",
)
else:
refinement_text = (
f"Your previous answer was:\n{response}\n\n"
"Re-check the diagram and the mathematical constraints. "
"If needed, correct your answer. Output only the final answer inside <answer>...</answer>."
)
refinement_messages = [
messages[0],
messages[1],
{"role": "assistant", "content": response},
{"role": "user", "content": refinement_text},
]
resp_text, _ = chat_student_messages(
messages=refinement_messages,
max_completion_tokens=768,
retries=5,
stage="rollout",
)
response = resp_text
conversation.append({"type": "message", "turn": turn + 1, "content": resp_text})
if extract_answer(resp_text):
break
result["response"] = response
result["agent_ok"] = True
result["n_turns"] = len(conversation) - 1
with open(os.path.join(pred_dir, "student_system_prompt.txt"), "w", encoding="utf-8") as f:
f.write(system_prompt)
with open(os.path.join(pred_dir, "student_user_prompt.txt"), "w", encoding="utf-8") as f:
f.write(user_text)
eval_result = evaluate_item(
item=item,
prediction_text=result["response"],
judge_model=judge_model,
max_completion_tokens=judge_max_completion_tokens,
retries=judge_retries,
)
result["evaluation_mode"] = eval_result["evaluation_mode"]
result["judge_raw"] = eval_result.get("judge_raw", "")
result["judge_reason"] = eval_result.get("judge_reason", "")
result["matched_gold"] = eval_result.get("matched_gold", "")
if item.get("is_choice"):
result["predicted_label"] = eval_result["predicted_label"]
result["predicted_text"] = eval_result["predicted_text"]
result["predicted_answer"] = eval_result["predicted_answer"]
result["hard"] = int(eval_result["em"])
result["soft"] = eval_result["f1"]
if not result["hard"]:
result["fail_reason"] = (
f"choice=0: predicted '{eval_result['predicted_label'] or eval_result['predicted_answer']}' "
f"but expected '{eval_result['correct_label']}'"
)
eval_detail = (
f"[EVALUATION RESULT]\n"
f"Question: {item['question_for_eval']}\n"
f"Predicted label: {eval_result['predicted_label']!r}\n"
f"Predicted text: {eval_result['predicted_text']!r}\n"
f"Correct label: {eval_result['correct_label']!r}\n"
f"Correct text: {eval_result['correct_text']!r}\n"
f"Exact Match: {eval_result['em']}"
)
else:
result["predicted_answer"] = eval_result["predicted_answer"]
result["hard"] = int(eval_result["em"])
result["soft"] = eval_result["f1"]
if not result["hard"]:
result["fail_reason"] = (
f"judge=0: predicted '{eval_result['predicted_answer']}' "
f"but expected '{item['answer']}' ({eval_result.get('judge_reason', '')})"
)
eval_detail = (
f"[EVALUATION RESULT]\n"
f"Question: {item['question_for_eval']}\n"
f"Predicted answer: {eval_result['predicted_answer']!r}\n"
f"Gold answer: {item['answer']!r}\n"
f"Judge correct: {eval_result['em']}\n"
f"Judge reason: {eval_result.get('judge_reason', '')}\n"
f"String F1: {eval_result.get('string_f1', 0.0):.4f}"
)
conversation.append({"role": "system", "content": eval_detail})
with open(os.path.join(pred_dir, "conversation.json"), "w", encoding="utf-8") as f:
json.dump(conversation, f, ensure_ascii=False, indent=2)
except Exception as e: # noqa: BLE001
result["fail_reason"] = f"error: {e}"
return result
def run_batch(
items: list[dict],
out_root: str,
skill_content: str,
*,
max_turns: int = 1,
workers: int = 32,
image_detail: str = "auto",
judge_model: str = "gpt-5.4",
judge_max_completion_tokens: int = 256,
judge_retries: int = 5,
diagnostic_mode: bool = False,
diagnostic_instruction: str = "",
diagnostic_trace_context_by_id: dict[str, str] | None = None,
) -> list[dict]:
results_path = os.path.join(out_root, "results.jsonl")
os.makedirs(out_root, exist_ok=True)
expected_eval_mode = evaluation_mode()
done_ids: set[str] = set()
existing: list[dict] = []
rewrite_results = False
if os.path.exists(results_path):
with open(results_path, encoding="utf-8") as f:
for line in f:
try:
row = json.loads(line)
if row.get("evaluation_mode") != expected_eval_mode:
rewrite_results = True
continue
done_ids.add(str(row["id"]))
existing.append(row)
except Exception:
rewrite_results = True
pending = [item for item in items if str(item["id"]) not in done_ids]
if not pending and not rewrite_results:
return existing
results = list(existing)
file_mode = "w" if rewrite_results else "a"
with open(results_path, file_mode, encoding="utf-8") as outf, ThreadPoolExecutor(max_workers=workers) as ex:
if rewrite_results:
for row in existing:
outf.write(json.dumps(row, ensure_ascii=False) + "\n")
futs = {
ex.submit(
process_one,
item,
out_root,
skill_content,
max_turns=max_turns,
image_detail=image_detail,
judge_model=judge_model,
judge_max_completion_tokens=judge_max_completion_tokens,
judge_retries=judge_retries,
diagnostic_mode=diagnostic_mode,
diagnostic_instruction=diagnostic_instruction,
diagnostic_trace_context=(diagnostic_trace_context_by_id or {}).get(str(item["id"]), ""),
): item
for item in pending
}
for fut in as_completed(futs):
row = fut.result()
results.append(row)
outf.write(json.dumps(row, ensure_ascii=False) + "\n")
outf.flush()
return results
+15
View File
@@ -0,0 +1,15 @@
# MathVerse Visual Math Heuristics
## Diagram First
- Read the diagram before locking onto an equation or option.
- Recover missing labels, lengths, angles, axes, or object relations from the image when the text is abbreviated.
- If the text seems underspecified, assume the image may contain the decisive constraint.
## Constraint Tracking
- Write down the few constraints that actually determine the answer instead of solving from vague intuition.
- Prefer geometric or functional relations that are directly supported by the figure.
- For multiple-choice questions, compare the final candidate against every option exactly.
## Final Answer
- Use the image and the text consistently.
- Return only the final answer inside <answer>...</answer>.