""" GRPO (Group Relative Policy Optimization) training script for OlmOCR. """ import argparse import base64 import glob import json import logging import os import re import subprocess import sys from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from functools import lru_cache from io import BytesIO from typing import Any, Dict, List, Optional, Tuple import torch import torch.distributed as dist import wandb from PIL import Image from rapidfuzz import distance, fuzz from torch.utils.data import Dataset from transformers import ( AutoProcessor, Qwen2_5_VLForConditionalGeneration, Qwen3VLForConditionalGeneration, TrainerCallback, ) from trl import GRPOConfig, GRPOTrainer from olmocr.bench.table_parsing import parse_html_tables from olmocr.bench.tests import load_single_test from olmocr.data.renderpdf import render_pdf_to_base64png from olmocr.prompts import PageResponse, build_no_anchoring_v4_yaml_prompt from olmocr.train.front_matter import FrontMatterParser # Configure logging logging.basicConfig( format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", datefmt="%m/%d/%Y %H:%M:%S", level=logging.INFO, ) logger = logging.getLogger(__name__) # Global variable for bench type filtering _bench_type_filter: Optional[List[str]] = None def _make_type_stats(): """Factory function for creating type stats dicts (picklable, unlike lambdas).""" return {"total_passed": 0, "total_tests": 0, "completion_count": 0} class DetailedRewardLogger: """Aggregates and logs detailed reward statistics by test type and JSONL file.""" def __init__(self): self.clear() def clear(self): self.batch_stats = [] self.accumulated_stats = { "total_completions": 0, "by_type": defaultdict(_make_type_stats), "by_jsonl": defaultdict(_make_type_stats), "overall": {"passed": 0, "total": 0}, } def add_batch_stats(self, batch_detailed_stats: List[Optional[Dict]]): """Add statistics from a batch of completions.""" self.batch_stats.append(batch_detailed_stats) for stats in batch_detailed_stats: if stats is None: continue self.accumulated_stats["total_completions"] += 1 # Aggregate overall stats if "overall" in stats: self.accumulated_stats["overall"]["passed"] += stats["overall"]["passed"] self.accumulated_stats["overall"]["total"] += stats["overall"]["total"] # Aggregate by test type for test_type, type_stats in stats.get("by_type", {}).items(): self.accumulated_stats["by_type"][test_type]["total_passed"] += type_stats["passed"] self.accumulated_stats["by_type"][test_type]["total_tests"] += type_stats["total"] self.accumulated_stats["by_type"][test_type]["completion_count"] += 1 # Aggregate by JSONL file if "jsonl_file" in stats: # Extract just the filename from the full path jsonl_name = os.path.basename(stats["jsonl_file"]) if "overall" in stats: self.accumulated_stats["by_jsonl"][jsonl_name]["total_passed"] += stats["overall"]["passed"] self.accumulated_stats["by_jsonl"][jsonl_name]["total_tests"] += stats["overall"]["total"] self.accumulated_stats["by_jsonl"][jsonl_name]["completion_count"] += 1 def get_summary_stats(self) -> Dict: """Get summary statistics for logging.""" summary = {"bench_reward/total_completions": self.accumulated_stats["total_completions"]} # Overall pass rate if self.accumulated_stats["overall"]["total"] > 0: summary["bench_reward/overall_pass_rate"] = self.accumulated_stats["overall"]["passed"] / self.accumulated_stats["overall"]["total"] # Calculate average pass rates by type for test_type, stats in self.accumulated_stats["by_type"].items(): if stats["total_tests"] > 0: summary[f"bench_reward/{test_type}/pass_rate"] = stats["total_passed"] / stats["total_tests"] summary[f"bench_reward/{test_type}/total_tests"] = stats["total_tests"] summary[f"bench_reward/{test_type}/avg_tests_per_completion"] = stats["total_tests"] / max(stats["completion_count"], 1) # Calculate average pass rates by JSONL file for jsonl_name, stats in self.accumulated_stats["by_jsonl"].items(): if stats["total_tests"] > 0: summary[f"bench_reward/jsonl_{jsonl_name}/pass_rate"] = stats["total_passed"] / stats["total_tests"] summary[f"bench_reward/jsonl_{jsonl_name}/total_tests"] = stats["total_tests"] return summary def get_batch_summary(self, batch_detailed_stats: List[Optional[Dict]]) -> Dict: """Compute summary statistics for a single batch.""" summary = { "by_type": defaultdict(lambda: {"passed": 0, "total": 0, "count": 0}), "by_jsonl": defaultdict(lambda: {"passed": 0, "total": 0, "count": 0}), "overall": {"passed": 0, "total": 0}, } for stats in batch_detailed_stats: if stats is None: continue # Aggregate overall stats if "overall" in stats: summary["overall"]["passed"] += stats["overall"]["passed"] summary["overall"]["total"] += stats["overall"]["total"] # Aggregate by type for test_type, type_stats in stats.get("by_type", {}).items(): summary["by_type"][test_type]["passed"] += type_stats["passed"] summary["by_type"][test_type]["total"] += type_stats["total"] summary["by_type"][test_type]["count"] += 1 # Aggregate by JSONL file if "jsonl_file" in stats: jsonl_name = os.path.basename(stats["jsonl_file"]) if "overall" in stats: summary["by_jsonl"][jsonl_name]["passed"] += stats["overall"]["passed"] summary["by_jsonl"][jsonl_name]["total"] += stats["overall"]["total"] summary["by_jsonl"][jsonl_name]["count"] += 1 # Calculate pass rates if summary["overall"]["total"] > 0: summary["overall"]["pass_rate"] = summary["overall"]["passed"] / summary["overall"]["total"] for test_type, stats in summary["by_type"].items(): if stats["total"] > 0: stats["pass_rate"] = stats["passed"] / stats["total"] for jsonl_name, stats in summary["by_jsonl"].items(): if stats["total"] > 0: stats["pass_rate"] = stats["passed"] / stats["total"] return summary def _gather_across_ranks(self): """Gather accumulated stats from all ranks to rank 0.""" if not (dist.is_available() and dist.is_initialized()): return world_size = dist.get_world_size() gathered = [None] * world_size # Convert defaultdicts to regular dicts for pickling stats_to_send = { "total_completions": self.accumulated_stats["total_completions"], "overall": self.accumulated_stats["overall"], "by_type": dict(self.accumulated_stats["by_type"]), "by_jsonl": dict(self.accumulated_stats["by_jsonl"]), } dist.all_gather_object(gathered, stats_to_send) if is_main_process(): # Merge all stats into self.accumulated_stats merged = self.accumulated_stats for other in gathered[1:]: # Skip rank 0 (already in merged) merged["total_completions"] += other["total_completions"] merged["overall"]["passed"] += other["overall"]["passed"] merged["overall"]["total"] += other["overall"]["total"] for t, s in other["by_type"].items(): merged["by_type"][t]["total_passed"] += s["total_passed"] merged["by_type"][t]["total_tests"] += s["total_tests"] merged["by_type"][t]["completion_count"] += s["completion_count"] for j, s in other["by_jsonl"].items(): merged["by_jsonl"][j]["total_passed"] += s["total_passed"] merged["by_jsonl"][j]["total_tests"] += s["total_tests"] merged["by_jsonl"][j]["completion_count"] += s["completion_count"] def log_to_wandb(self, step: int): """Log accumulated statistics to wandb.""" self._gather_across_ranks() if is_main_process(): summary = self.get_summary_stats() wandb.log(summary) # Don't pass in step to wandb, or else it can get confused logger.info(f"Logged detailed reward stats at step {step}") # Log a formatted summary to console logger.info("=" * 60) logger.info("Detailed Reward Statistics Summary:") logger.info(f"Total completions evaluated: {self.accumulated_stats['total_completions']}") if self.accumulated_stats["overall"]["total"] > 0: overall_rate = self.accumulated_stats["overall"]["passed"] / self.accumulated_stats["overall"]["total"] logger.info( f"Overall pass rate: {overall_rate:.3%} ({self.accumulated_stats['overall']['passed']}/{self.accumulated_stats['overall']['total']})" ) logger.info("\nBreakdown by test type:") for test_type in sorted(self.accumulated_stats["by_type"].keys()): stats = self.accumulated_stats["by_type"][test_type] if stats["total_tests"] > 0: pass_rate = stats["total_passed"] / stats["total_tests"] logger.info(f" {test_type:12s}: {pass_rate:6.2%} ({stats['total_passed']:4d}/{stats['total_tests']:4d} tests)") logger.info("\nBreakdown by JSONL file:") for jsonl_name in sorted(self.accumulated_stats["by_jsonl"].keys()): stats = self.accumulated_stats["by_jsonl"][jsonl_name] if stats["total_tests"] > 0: pass_rate = stats["total_passed"] / stats["total_tests"] logger.info(f" {jsonl_name:20s}: {pass_rate:6.2%} ({stats['total_passed']:4d}/{stats['total_tests']:4d} tests)") logger.info("=" * 60) # Global instance for tracking detailed reward statistics detailed_reward_logger = DetailedRewardLogger() class DetailedRewardLoggingCallback(TrainerCallback): """Callback to log detailed reward statistics during training.""" def on_log(self, args, state, control, logs=None, **kwargs): """Called when trainer logs metrics.""" if hasattr(detailed_reward_logger, "accumulated_stats"): detailed_reward_logger.log_to_wandb(state.global_step) detailed_reward_logger.clear() class S3SyncCallback(TrainerCallback): """Callback to sync entire output directory to S3 after saving.""" def __init__(self, s3_save_path: str, output_dir: str): """ Initialize the S3 sync callback. Args: s3_save_path: S3 path to sync checkpoints to (e.g., s3://bucket/path/) output_dir: Local output directory containing checkpoints """ self.s3_save_path = s3_save_path.rstrip("/") + "/" self.output_dir = output_dir def _sync_to_s3(self): """Sync entire output directory to S3 using s5cmd.""" try: # Build s5cmd sync command # Using --delete to remove files in S3 that don't exist locally cmd = [ "s5cmd", "sync", "--delete", "--exclude", "*.lock", # Exclude lock files "--exclude", ".git/*", # Exclude git files if any f"{self.output_dir}/*", self.s3_save_path, ] logger.info(f"Syncing entire output directory to S3: {self.output_dir} -> {self.s3_save_path}") logger.debug(f"Running command: {' '.join(cmd)}") # Run s5cmd result = subprocess.run(cmd, capture_output=True, text=True, timeout=60 * 25) # 25 minute timeout if result.returncode == 0: logger.info(f"Successfully synced to S3: {self.s3_save_path}") else: logger.error(f"Failed to sync to S3. Return code: {result.returncode}") logger.error(f"stderr: {result.stderr}") logger.error(f"stdout: {result.stdout}") except subprocess.TimeoutExpired: logger.error(f"S3 sync timed out after 5 minutes") except FileNotFoundError: logger.error("s5cmd not found. Please ensure s5cmd is installed and in PATH") except Exception as e: logger.error(f"Error syncing to S3: {e}") def on_save(self, args, state, control, **kwargs): """Called after a checkpoint is saved.""" # Only sync on main process if is_main_process(): self._sync_to_s3() def on_train_end(self, args, state, control, **kwargs): """Called at the end of training.""" # Final sync at the end of training if is_main_process(): logger.info("Final S3 sync at end of training") self._sync_to_s3() def get_rank(): """Get the rank of the current process in distributed training.""" # Check environment variables for rank information rank = 0 # Try different environment variables that might contain rank if "LOCAL_RANK" in os.environ: rank = int(os.environ["LOCAL_RANK"]) elif "RANK" in os.environ: rank = int(os.environ["RANK"]) elif dist.is_available() and dist.is_initialized(): rank = dist.get_rank() return rank def is_main_process(): """Check if this is the main process (rank 0).""" return get_rank() == 0 class OlmOCRBenchDataset(Dataset): """Dataset for loading PDF pages from Olmocr-bench format JSONL files.""" def __init__( self, bench_data_folder: str, processor, max_samples: Optional[int] = None, target_longest_image_dim: int = 1288, jsonl_filter: Optional[str] = None, ): self.bench_data_folder = bench_data_folder self.processor = processor self.target_longest_image_dim = target_longest_image_dim self.max_samples = max_samples self.jsonl_filter = jsonl_filter # Find PDF folder self.pdf_folder = os.path.join(bench_data_folder, "pdfs") if not os.path.exists(self.pdf_folder): raise ValueError(f"PDFs folder not found at {self.pdf_folder}") # Set claude_original folder path self.claude_original_folder = os.path.join(bench_data_folder, "claude_original") if os.path.exists(self.claude_original_folder): logger.info(f"Found claude_original folder at {self.claude_original_folder}") else: logger.warning(f"No claude_original folder found at {self.claude_original_folder}") # Load unique PDFs from JSONL files self.samples = self._load_unique_pdfs_from_jsonl() logger.info(f"Created dataset with {len(self.samples)} unique PDF samples") def _load_claude_original(self, pdf_name: str, page: int) -> Optional[str]: """Load the claude_original markdown file for a given PDF and page.""" if not os.path.exists(self.claude_original_folder): return None # Extract the base PDF name and construct the expected filename # pdf_name like "s2pdf/pdf_00017_page2.pdf" -> construct the markdown filename pdf_base = os.path.basename(pdf_name).replace(".pdf", "") # Handle case where page is already in the filename if "_page" in pdf_base: pdf_base_parts = pdf_base.split("_page") pdf_base_name = pdf_base_parts[0] # Use the page from the filename if it exists page_from_name = int(pdf_base_parts[1]) if len(pdf_base_parts) > 1 and pdf_base_parts[1].isdigit() else page else: pdf_base_name = pdf_base page_from_name = page # Extract folder structure from pdf_name (e.g., "s2pdf/" or "arxiv_math/") pdf_dir = os.path.dirname(pdf_name) # Construct the expected claude_original filename # Format: pdf_00017_page2_pg1_repeat1.md claude_filename = f"{pdf_base_name}_page{page_from_name}_pg1_repeat1.md" # Build the full path to the claude_original file claude_file_path = os.path.join(self.claude_original_folder, pdf_dir, claude_filename) if os.path.exists(claude_file_path): try: with open(claude_file_path, "r", encoding="utf-8") as f: content = f.read() # Parse the frontmatter to validate the content parser = FrontMatterParser(front_matter_class=PageResponse) try: front_matter, text = parser._extract_front_matter_and_text(content) _page_response = parser._parse_front_matter(front_matter, text) # Parsing succeeded, return the original content return content except Exception as parse_error: logger.error(f"CRITICAL: Failed to parse frontmatter from claude_original file {claude_file_path}") logger.error(f"Parse error: {type(parse_error).__name__}: {str(parse_error)}") logger.error("Aborting run due to invalid claude_original file format") sys.exit(1) except Exception as e: logger.warning(f"Failed to read claude_original file {claude_file_path}: {e}") else: logger.debug(f"Claude original file not found: {claude_file_path}") return None def _load_unique_pdfs_from_jsonl(self) -> List[Dict[str, Any]]: """Load unique PDFs from JSONL files in the bench_data folder, tracking all test cases per PDF.""" jsonl_files = sorted(glob.glob(os.path.join(self.bench_data_folder, "*.jsonl"))) if not jsonl_files: raise ValueError(f"No JSONL files found in {self.bench_data_folder}") # Apply jsonl_filter if provided if self.jsonl_filter: try: filter_pattern = re.compile(self.jsonl_filter, re.IGNORECASE) filtered_files = [] for jsonl_file in jsonl_files: basename = os.path.basename(jsonl_file) if filter_pattern.search(basename): filtered_files.append(jsonl_file) logger.info(f"Including JSONL file: {basename} (matched filter '{self.jsonl_filter}')") else: logger.debug(f"Excluding JSONL file: {basename} (did not match filter '{self.jsonl_filter}')") jsonl_files = filtered_files if not jsonl_files: raise ValueError(f"No JSONL files matched filter '{self.jsonl_filter}' in {self.bench_data_folder}") except re.error as e: raise ValueError(f"Invalid regex pattern '{self.jsonl_filter}': {e}") logger.info(f"Found {len(jsonl_files)} JSONL files" + (f" after filtering with '{self.jsonl_filter}'" if self.jsonl_filter else "")) # Track unique PDFs and their test cases pdf_data: Dict[str, Dict[str, Any]] = {} for jsonl_file in jsonl_files: logger.info(f"Processing {os.path.basename(jsonl_file)}") with open(jsonl_file, "r") as f: for line in f: try: entry = json.loads(line.strip()) pdf_name = entry.get("pdf") page = entry.get("page", 0) test_id = entry.get("id") if pdf_name and test_id: # Create unique key for PDF+page combination pdf_page_key = f"{pdf_name}::{page}" if pdf_page_key not in pdf_data: # First time seeing this PDF+page pdf_path = os.path.join(self.pdf_folder, pdf_name) claude_original = self._load_claude_original(pdf_name, page) pdf_data[pdf_page_key] = { "pdf_path": pdf_path, "pdf_name": pdf_name, "page": page, "jsonl_file": jsonl_file, "test_ids": [test_id], "entries": [entry], "claude_original": claude_original, } else: # Add test case to existing PDF+page pdf_data[pdf_page_key]["test_ids"].append(test_id) pdf_data[pdf_page_key]["entries"].append(entry) except json.JSONDecodeError as e: logger.warning(f"Failed to parse line in {jsonl_file}: {e}") continue except Exception as e: logger.warning(f"Error processing entry in {jsonl_file}: {e}") continue # Convert to list with sorted keys for reproducibility samples = [pdf_data[key] for key in sorted(pdf_data.keys())] if self.max_samples: samples = samples[: self.max_samples] return samples def __len__(self): return len(self.samples) def __getitem__(self, idx): sample = self.samples[idx] pdf_path = sample["pdf_path"] page_num = sample["page"] jsonl_file = sample["jsonl_file"] test_ids = sample["test_ids"] try: # Render PDF page to base64 image image_base64 = render_pdf_to_base64png(pdf_path, page_num, target_longest_image_dim=self.target_longest_image_dim) # Convert base64 to PIL Image image_bytes = base64.b64decode(image_base64) image = Image.open(BytesIO(image_bytes)).convert("RGB") # Build the text prompt text_prompt = build_no_anchoring_v4_yaml_prompt() # Create messages in the format expected by Qwen2-VL messages = [ { "role": "user", "content": [ {"type": "text", "text": text_prompt}, {"type": "image"}, ], } ] # Return the required format return { "prompt": messages, "pdf_path": pdf_path, "jsonl_file": jsonl_file, "test_ids": test_ids, "image": image, # Include the PIL image for processing later "claude_original": sample.get("claude_original"), # Include claude_original if available } except Exception as e: logger.error(f"Failed to process sample {idx}: {e}") # Return None if processing fails return None @lru_cache(maxsize=1024) def load_specific_tests_cached(jsonl_file: str, test_ids_tuple: tuple): """ Cached version that loads specific tests by their IDs from a JSONL file. Uses load_single_test to parse individual test entries. Args: jsonl_file: Path to the JSONL file containing test definitions test_ids_tuple: Tuple of test IDs to load (tuple for hashability in lru_cache) Returns: List of test objects matching the specified IDs """ test_ids = set(test_ids_tuple) relevant_tests = [] with open(jsonl_file, "r") as f: for line in f: line = line.strip() if not line: continue try: # Parse just enough to get the ID test_data = json.loads(line) if test_data.get("id") in test_ids: # Use load_single_test to properly parse and validate the test test = load_single_test(test_data) relevant_tests.append(test) # Early exit if we've found all tests if len(relevant_tests) == len(test_ids): break except (json.JSONDecodeError, Exception) as e: logger.warning(f"Error parsing test line: {e}") continue return relevant_tests def evaluate_single_completion(args: Tuple[int, Any, str, str, List[str]]) -> Tuple[int, Optional[float], Optional[Dict[str, Any]]]: """ Helper function to evaluate a single completion against its tests. Args: args: Tuple of (index, completion, jsonl_file, pdf_path, test_ids) Returns: Tuple of (index, reward, detailed_stats) where detailed_stats contains breakdown by test type """ i, completion, comp_jsonl_file, comp_pdf_path, comp_test_ids = args logger.info(f"Completion {i}: PDF: {comp_pdf_path}, JSONL: {comp_jsonl_file}, Test IDs: {comp_test_ids}") if completion is None or not (isinstance(completion, str) or isinstance(completion, list)): logger.warning(f"Invalid completion at index {i}: {type(completion)}") logger.warning(f"completion: {completion}") return i, None, None if comp_jsonl_file is None or comp_test_ids is None or len(comp_test_ids) == 0: logger.warning(f"Missing metadata for completion {i}") return i, None, None if isinstance(completion, list): completion = completion[0]["content"] try: # Load only the specific tests we need from the JSONL file (cached) # Convert list to tuple for hashability in lru_cache relevant_tests = load_specific_tests_cached(comp_jsonl_file, tuple(comp_test_ids)) if not relevant_tests: logger.warning(f"No relevant tests found for test IDs: {comp_test_ids}") return i, None, None # Filter tests by type if bench_type_filter is set if _bench_type_filter: relevant_tests = [t for t in relevant_tests if getattr(t, "type", "unknown") in _bench_type_filter] if not relevant_tests: logger.warning(f"No tests remaining after type filter {_bench_type_filter} for completion {i}") return i, None, None logger.info(f"Found {len(relevant_tests)} relevant tests for completion {i}") # Track stats by test type using defaultdict stats_by_type = defaultdict(lambda: {"passed": 0, "total": 0}) overall_stats = {"passed": 0, "total": len(relevant_tests)} for test in relevant_tests: # Get test type from the test object test_type = getattr(test, "type", "unknown") stats_by_type[test_type]["total"] += 1 try: test_passed, failure_reason = test.run(completion) if test_passed: stats_by_type[test_type]["passed"] += 1 overall_stats["passed"] += 1 else: logger.debug(f"Test {test.id} ({test_type}) failed: {failure_reason}") except Exception as e: logger.warning(f"Error running test {test.id} ({test_type}): {e}") # Count errored tests as failures continue # Calculate overall reward overall_reward = overall_stats["passed"] / overall_stats["total"] if overall_stats["total"] > 0 else 0.0 # Calculate per-type pass rates for test_type, type_stats in stats_by_type.items(): type_stats["pass_rate"] = type_stats["passed"] / type_stats["total"] if type_stats["total"] > 0 else 0.0 detailed_stats = { "overall": overall_stats, "by_type": dict(stats_by_type), # Convert defaultdict to regular dict for serialization "reward": overall_reward, "pdf_path": comp_pdf_path, "jsonl_file": comp_jsonl_file, } logger.info(f"Completion {i}: {overall_stats['passed']}/{overall_stats['total']} tests passed, reward={overall_reward:.3f}") # Log breakdown by type for test_type, type_stats in stats_by_type.items(): logger.info(f" {test_type}: {type_stats['passed']}/{type_stats['total']} passed (rate: {type_stats['pass_rate']:.3f})") return i, overall_reward, detailed_stats except Exception as e: logger.error(f"Error processing completion {i}: {e}") return i, None, None def bench_edit_distance_reward(prompts, completions: list[str] | list[list[dict]], claude_original: list[Optional[str]], **kwargs): """ Reward function based on edit distance similarity to claude_original files. Calculates the normalized edit distance between each completion and its corresponding claude_original reference. Returns 1.0 for perfect match, lower for more distance. Args: prompts: List of prompts completions: List of generated completions (model outputs) claude_original: List of claude_original reference texts (one per completion) **kwargs: Additional arguments Returns: List of reward scores between 0 and 1, where 1.0 is perfect match """ logger.info(f"Running bench edit distance reward function for {len(completions)} completions") rewards = [] for i, completion in enumerate(completions): # Extract text from completion if isinstance(completion, list): comp_text = completion[0]["content"] if completion else "" elif isinstance(completion, str): comp_text = completion else: comp_text = "" # Get the corresponding claude_original reference reference = claude_original[i] if i < len(claude_original) else None if reference is None: logger.warning(f"No claude_original reference for completion {i}") rewards.append(0.0) continue # Calculate edit distance similarity_ratio = fuzz.ratio(comp_text, reference) / 100.0 rewards.append(similarity_ratio) logger.info(f"Bench edit distance rewards range: [{min(rewards) if rewards else 0:.3f}, {max(rewards) if rewards else 0:.3f}]") return rewards def medoid_reward(prompts, completions: list[str] | list[list[dict]], **kwargs): """ Reward function based on edit distance to the medoid completion. The medoid is the completion with the minimum average edit distance to all others. Rewards are calculated as 1 - normalized_distance_to_medoid. Args: prompts: List of prompts completions: List of generated completions (model outputs) **kwargs: Additional arguments Returns: List of reward scores between 0 and 1, where medoid gets 1.0 """ logger.info(f"Running medoid reward function for {len(completions)} completions") # Extract text from completions completion_texts = [] for completion in completions: if isinstance(completion, list): text = completion[0]["content"] if completion else "" elif isinstance(completion, str): text = completion else: text = "" completion_texts.append(text) n = len(completion_texts) # Handle edge cases if n == 0: return [] if n == 1: return [1.0] # Calculate pairwise edit distances distances = [[0.0] * n for _ in range(n)] max_distance = 0.0 for i in range(n): for j in range(i + 1, n): # Calculate Levenshtein distance dist = distance.Levenshtein.distance(completion_texts[i], completion_texts[j]) distances[i][j] = dist distances[j][i] = dist max_distance = max(max_distance, dist) # Find the medoid (completion with minimum average distance to others) avg_distances = [sum(distances[i]) / (n - 1) if n > 1 else 0 for i in range(n)] medoid_idx = min(range(n), key=lambda i: avg_distances[i]) # Calculate rewards based on distance from medoid rewards = [] medoid_distances = distances[medoid_idx] # Normalize distances and compute rewards for i in range(n): if i == medoid_idx: rewards.append(1.0) else: # Normalize distance to [0, 1] range if max_distance > 0: normalized_dist = medoid_distances[i] / max_distance else: normalized_dist = 0.0 # Reward is 1 minus normalized distance reward = 1.0 - normalized_dist rewards.append(max(0.0, reward)) # Ensure non-negative logger.info(f"Medoid at index {medoid_idx}, rewards range: [{min(rewards):.3f}, {max(rewards):.3f}]") return rewards def reward_front_matter(prompts, completions: list[str] | list[list[dict]], claude_original: list[Optional[str]] = None, **kwargs): """ Reward function that checks if completions can be successfully parsed by FrontMatterParser and compares fields to claude_original values. Scoring: - 0.0: Cannot parse frontmatter at all - 0.5: Can parse frontmatter successfully - +0.1: For each matching field (primary_language, is_rotation_valid, rotation_correction, is_table, is_diagram) Maximum score: 1.0 (0.5 + 5 * 0.1) Args: prompts: List of prompts completions: List of generated completions (model outputs) claude_original: List of claude_original markdown content (optional) **kwargs: Additional arguments Returns: List of reward scores between 0.0 and 1.0 """ logger.info(f"Running front matter reward function for {len(completions)} completions") rewards = [] parser = FrontMatterParser(front_matter_class=PageResponse) # Fields to compare fields_to_compare = ["primary_language", "is_rotation_valid", "rotation_correction", "is_table", "is_diagram"] for i, completion in enumerate(completions): # Extract text from completion if isinstance(completion, list): if completion and "content" in completion[0]: model_response_markdown = completion[0]["content"] else: model_response_markdown = "" elif isinstance(completion, str): model_response_markdown = completion else: model_response_markdown = "" reward = 0 try: # Try to parse the completion front_matter, text = parser._extract_front_matter_and_text(model_response_markdown) completion_response = parser._parse_front_matter(front_matter, text) # Parsing succeeded - base reward of 5/10 points reward = 5 logger.debug(f"Completion {i}: Successfully parsed frontmatter (base reward: 0.5)") # Try to compare with claude_original if available if claude_original and i < len(claude_original) and claude_original[i]: try: # Parse claude_original frontmatter claude_fm, claude_text = parser._extract_front_matter_and_text(claude_original[i]) claude_response = parser._parse_front_matter(claude_fm, claude_text) # Compare each field fields_matched = 0 for field in fields_to_compare: completion_value = getattr(completion_response, field, None) claude_value = getattr(claude_response, field, None) if completion_value == claude_value: fields_matched += 1 reward += 1 logger.debug(f" Field {field} matches: {completion_value}") else: logger.debug(f" Field {field} mismatch: completion={completion_value}, claude={claude_value}") logger.debug(f"Completion {i}: Matched {fields_matched}/{len(fields_to_compare)} fields") except Exception as e: logger.warning(f"Failed to parse claude_original for comparison at index {i}: {e}") # Keep the base 0.5 reward for successful parsing else: logger.debug(f"Completion {i}: No claude_original available for comparison") except Exception as e: # Any parsing error results in 0 reward reward = 0 logger.debug(f"Completion {i}: Failed to parse frontmatter - {type(e).__name__}: {str(e)}") rewards.append(reward / 10.0) # Log summary statistics zero_rewards = sum(1 for r in rewards if r == 0.0) partial_rewards = sum(1 for r in rewards if 0.0 < r < 1.0) perfect_rewards = sum(1 for r in rewards if r == 1.0) avg_reward = sum(rewards) / len(rewards) if rewards else 0.0 logger.info(f"Front matter rewards summary: {zero_rewards} failed, {partial_rewards} partial, " f"{perfect_rewards} perfect. Average: {avg_reward:.3f}") return rewards def reward_element_count(prompts, completions: list[str] | list[list[dict]], claude_original: list[Optional[str]] = None, **kwargs): """ Reward function based on matching element counts between completion and claude_original. Counts HTML tables (