""" Spatial Atlas — MLE-Bench Domain Handler Orchestrates the complete ML competition pipeline: 1. Extract competition data from tar.gz 2. Analyze competition description 3. Generate ML pipeline code 4. Execute code to produce submission.csv 5. Self-heal on failure (retry with error context) 6. Return submission CSV bytes Receives: instructions text + competition.tar.gz from green agent Returns: (csv_bytes, summary_text) """ import base64 import io import logging import re import tarfile import tempfile import time from pathlib import Path import pandas as pd from a2a.server.tasks import TaskUpdater from a2a.types import TaskState from a2a.utils import new_agent_text_message from config import Config from llm import LLMClient from mlebench.analyzer import CompetitionAnalyzer from mlebench.codegen import MLCodeGenerator from mlebench.executor import CodeExecutor logger = logging.getLogger("spatial-atlas.mlebench") # Matches lines like 'VALIDATION_SCORE: 0.8341' anywhere in stdout. # Tolerates trailing text on the same line so the model can print a # parenthetical comment after the number. _VALIDATION_SCORE_RE = re.compile( r"VALIDATION_SCORE:\s*(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)" ) def _parse_validation_score(stdout: str) -> float | None: """ Extract the last VALIDATION_SCORE emitted by the pipeline. Taking the LAST match (not first) lets a refined pipeline print a pre-training score followed by the real post-training score without us grabbing the wrong one. """ if not stdout: return None matches = _VALIDATION_SCORE_RE.findall(stdout) if not matches: return None try: return float(matches[-1]) except ValueError: return None def _score_is_better( new: float, old: float, metric_direction: str ) -> bool: """ Compare scores given the direction of the competition metric. metric_direction is a free-form string from CompetitionAnalysis. We treat it as 'maximize' unless it obviously says otherwise, because most Kaggle metrics in MLE-Bench are maximize (AUC, accuracy, F1). """ direction = (metric_direction or "").lower() minimize_keywords = ("min", "lower", "loss", "error", "rmse", "mae", "mse") minimize = any(kw in direction for kw in minimize_keywords) return (new < old) if minimize else (new > old) class MLEBenchHandler: """Handle MLE-Bench competition tasks end-to-end.""" def __init__(self, config: Config, llm: LLMClient): self.config = config self.llm = llm self.analyzer = CompetitionAnalyzer(llm) self.codegen = MLCodeGenerator(llm) self.executor = CodeExecutor(timeout=config.code_execution_timeout) async def handle( self, text: str, file_parts: list[tuple[str, str, str | bytes]], updater: TaskUpdater, ) -> tuple[bytes, str]: """ Process an MLE-Bench competition end-to-end. Args: text: Instructions text from green agent file_parts: List of (name, mime_type, data) — should include competition.tar.gz updater: A2A task updater for progress reporting Returns: (csv_bytes, summary_text) tuple """ # 1. Extract competition tar.gz to temp directory await updater.update_status( TaskState.working, new_agent_text_message("Extracting competition data..."), ) work_dir = self._extract_competition(file_parts) data_dir = self._find_data_dir(work_dir) logger.info(f"Competition data extracted to {work_dir}, data at {data_dir}") # 2. Read competition description description = self._read_description(data_dir) file_listing = self._list_data_files(data_dir) data_preview = self._preview_data(data_dir) logger.info(f"Description: {len(description)} chars, files: {file_listing[:200]}") # 3. Analyze competition await updater.update_status( TaskState.working, new_agent_text_message("Analyzing competition..."), ) analysis = await self.analyzer.analyze( description=description, file_listing=file_listing, data_preview=data_preview, ) logger.info( f"Analysis: type={analysis.task_type}, metric={analysis.metric}, " f"strategy={analysis.strategy}" ) # 4. Generate ML pipeline code await updater.update_status( TaskState.working, new_agent_text_message( f"Generating {analysis.strategy} ML pipeline..." ), ) submission_path = str(work_dir / "submission.csv") code = await self.codegen.generate( description=description, data_dir=str(data_dir), file_listing=file_listing, data_preview=data_preview, analysis=analysis, submission_path=submission_path, ) # 5. Execute code with self-healing retries csv_bytes = None for attempt in range(self.config.max_code_iterations): await updater.update_status( TaskState.working, new_agent_text_message( f"Running ML pipeline (attempt {attempt + 1}/{self.config.max_code_iterations})..." ), ) csv_bytes = await self.executor.execute( code=code, working_dir=work_dir, submission_path=Path(submission_path), ) if csv_bytes is not None: logger.info(f"Pipeline succeeded on attempt {attempt + 1}") break # Self-heal: fix the code based on the error if attempt < self.config.max_code_iterations - 1: logger.info( f"Pipeline failed on attempt {attempt + 1}, self-healing..." ) await updater.update_status( TaskState.working, new_agent_text_message( f"Pipeline failed, fixing code (attempt {attempt + 2})..." ), ) code = await self.codegen.fix( code=code, error=self.executor.last_error or "Unknown error", stdout=self.executor.last_stdout, description=description, file_listing=file_listing, ) if csv_bytes is None: # Last resort: generate a dummy submission logger.error("All attempts failed, generating dummy submission") csv_bytes = self._generate_dummy_submission(data_dir, analysis) refinement_note = "no refinement (pipeline never succeeded)" else: # 6. Score-driven refinement loop. # # The baseline pipeline already works; now ask the strong model # to propose targeted improvements. Each iteration re-runs the # full pipeline, parses VALIDATION_SCORE from stdout, and keeps # the better submission. Bail out on wall-time budget. csv_bytes, refinement_note = await self._refine_until_best( updater=updater, initial_code=code, initial_csv=csv_bytes, work_dir=work_dir, submission_path=Path(submission_path), description=description, file_listing=file_listing, analysis=analysis, ) summary = ( f"Competition: {analysis.task_type} ({analysis.metric})\n" f"Strategy: {analysis.strategy}\n" f"Submission: {len(csv_bytes)} bytes\n" f"Refinement: {refinement_note}" ) return csv_bytes, summary async def _refine_until_best( self, *, updater: TaskUpdater, initial_code: str, initial_csv: bytes, work_dir: Path, submission_path: Path, description: str, file_listing: str, analysis, ) -> tuple[bytes, str]: """ Run the score-driven refinement loop. Returns (best_csv_bytes, human_readable_note). The caller does not need to know how many iterations ran or whether any of them improved; the note is for the summary only. """ max_iters = self.config.max_refinement_iterations wall_budget = self.config.refinement_wall_time_seconds if max_iters <= 0: return initial_csv, "disabled (max_refinement_iterations=0)" initial_score = _parse_validation_score(self.executor.last_stdout) if initial_score is None: logger.info( "Refinement skipped: no VALIDATION_SCORE found in pipeline stdout. " "Either the pipeline did not print one or parsing failed." ) return initial_csv, "skipped (no VALIDATION_SCORE printed)" best_code = initial_code best_csv = initial_csv best_score = initial_score start = time.monotonic() improvements = 0 logger.info( f"Refinement loop: baseline score={initial_score}, " f"max_iters={max_iters}, wall_budget={wall_budget}s" ) for i in range(max_iters): if time.monotonic() - start > wall_budget: logger.info("Refinement wall-time budget exhausted; stopping") break await updater.update_status( TaskState.working, new_agent_text_message( f"Refining pipeline (iteration {i + 1}/{max_iters}, " f"best score={best_score:.4f})..." ), ) try: refined_code = await self.codegen.refine( code=best_code, current_score=best_score, metric=analysis.metric, metric_direction=analysis.metric_direction, description=description, file_listing=file_listing, ) except Exception as e: logger.warning(f"Refinement codegen failed on iter {i + 1}: {e}") continue # Re-run with the refined script. On error we keep the previous # best and fall through; we do NOT call fix() here because fix # exists for first-pass errors, not for refinement regressions. csv_bytes = await self.executor.execute( code=refined_code, working_dir=work_dir, submission_path=submission_path, ) if csv_bytes is None: logger.info( f"Refined pipeline on iter {i + 1} failed to run; " "keeping previous best" ) continue new_score = _parse_validation_score(self.executor.last_stdout) if new_score is None: logger.info( f"Refined pipeline on iter {i + 1} ran but printed no " "VALIDATION_SCORE; treating as non-improvement" ) continue if _score_is_better(new_score, best_score, analysis.metric_direction): logger.info( f"Refinement iter {i + 1}: improved " f"{best_score:.4f} -> {new_score:.4f}" ) best_code = refined_code best_csv = csv_bytes best_score = new_score improvements += 1 else: logger.info( f"Refinement iter {i + 1}: no improvement " f"({new_score:.4f} vs {best_score:.4f}); keeping previous" ) note = ( f"baseline={initial_score:.4f}, best={best_score:.4f}, " f"improvements={improvements}/{max_iters}" ) return best_csv, note def _extract_competition( self, file_parts: list[tuple[str, str, str | bytes]] ) -> Path: """Extract competition.tar.gz to a temporary directory.""" tar_data = None for name, mime, data in file_parts: if name and ("tar" in name or "gz" in name): tar_data = data break if mime and ("tar" in mime or "gzip" in mime): tar_data = data break if tar_data is None: # Use the first file attachment as tar if file_parts: _, _, tar_data = file_parts[0] else: raise ValueError("No competition data file received") # Decode if base64 if isinstance(tar_data, str): if tar_data.startswith("data:"): tar_data = tar_data.split(",", 1)[1] tar_data = base64.b64decode(tar_data) # Extract to temp directory work_dir = Path(tempfile.mkdtemp(prefix="atlas_mle_")) try: with tarfile.open(fileobj=io.BytesIO(tar_data), mode="r:gz") as tar: tar.extractall(work_dir, filter="data") except tarfile.ReadError: # Try uncompressed tar with tarfile.open(fileobj=io.BytesIO(tar_data), mode="r:") as tar: tar.extractall(work_dir, filter="data") logger.info(f"Extracted competition to {work_dir}") return work_dir def _find_data_dir(self, work_dir: Path) -> Path: """Find the data directory within extracted competition.""" # MLE-Bench structure: home/data/ candidates = [ work_dir / "home" / "data", work_dir / "data", work_dir, ] for candidate in candidates: if candidate.is_dir() and any(candidate.iterdir()): return candidate return work_dir def _read_description(self, data_dir: Path) -> str: """Read competition description.""" for name in ["description.md", "README.md", "description.txt"]: desc_path = data_dir / name if desc_path.exists(): return desc_path.read_text(errors="replace") # Look one level up parent = data_dir.parent for name in ["description.md", "README.md"]: desc_path = parent / name if desc_path.exists(): return desc_path.read_text(errors="replace") return "[No description file found]" def _list_data_files(self, data_dir: Path) -> str: """List all data files with sizes.""" lines = [] for f in sorted(data_dir.rglob("*")): if f.is_file(): rel = f.relative_to(data_dir) size = f.stat().st_size if size > 1_000_000: size_str = f"{size / 1_000_000:.1f}MB" elif size > 1_000: size_str = f"{size / 1_000:.1f}KB" else: size_str = f"{size}B" lines.append(f" - {rel} ({size_str})") return "\n".join(lines) if lines else " [No files found]" def _preview_data(self, data_dir: Path, max_rows: int = 5) -> str: """Preview CSV files in the data directory.""" previews = [] csv_files = sorted(data_dir.glob("*.csv"))[:3] # first 3 CSVs for csv_path in csv_files: try: df = pd.read_csv(csv_path, nrows=max_rows) previews.append( f"### {csv_path.name}\n" f"Shape: {df.shape}\n" f"Columns: {list(df.columns)}\n" f"Dtypes:\n{df.dtypes.to_string()}\n" f"Head:\n{df.to_string()}\n" ) except Exception as e: previews.append(f"### {csv_path.name}\nError reading: {e}\n") return "\n".join(previews) if previews else "[No CSV files to preview]" def _generate_dummy_submission( self, data_dir: Path, analysis ) -> bytes: """Generate a minimal valid submission as last resort.""" # Try to read test.csv and create a dummy submission test_path = data_dir / "test.csv" if not test_path.exists(): for candidate in data_dir.glob("test*.csv"): test_path = candidate break try: test_df = pd.read_csv(test_path) submission = pd.DataFrame() # Try to find ID column for col in test_df.columns: if "id" in col.lower(): submission[col] = test_df[col] break # Add target column with dummy value if analysis.target_column: submission[analysis.target_column] = 0 else: submission["target"] = 0 buf = io.BytesIO() submission.to_csv(buf, index=False) return buf.getvalue() except Exception as e: logger.error(f"Dummy submission failed: {e}") return b"id,target\n0,0\n"