diff --git a/demo/demo.py b/demo/demo.py index e7d9434d..329662c9 100644 --- a/demo/demo.py +++ b/demo/demo.py @@ -1,826 +1,833 @@ """ Copyright (c) Meta Platforms, Inc. and affiliates. + +MLGym Trajectory Visualizer + +This module provides a Streamlit-based web application for visualizing MLGym trajectories. +It allows users to inspect step-by-step progression of agents through various ML tasks, +including their thought processes, actions taken, execution results, and raw logs. + +Usage: + streamlit run trajectory_visualizer.py [--trajectory_dir PATH] """ +import os import json +import streamlit as st +import argparse +from typing import Any, Dict, List, Optional, Union, Tuple +from pathlib import Path +from collections import defaultdict import re -import time -import streamlit as st -from vars import SYSTEM_PROMPT, TASK_DESCRIPTIONS, TOOLS - -# Configure the page with wide layout and custom theme -st.set_page_config( - page_title="MLGym Demo", - page_icon="👩‍🔬", - layout="wide", - initial_sidebar_state="expanded" -) - -# Enhanced CSS with distinct colors for each section -st.markdown(""" - + """, + unsafe_allow_html=True, + ) + +# --- Argument Parsing --- +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="MLGym Trajectory Visualizer") + parser.add_argument( + "--trajectory_dir", + type=str, + default=os.path.join(os.getcwd(), "trajectories"), + help="Directory containing trajectory files", + ) + return parser.parse_known_args()[0] + +# --- Trajectory File Parsing & Loading --- + +def parse_trajectory_path(filepath_str: str, root_dir_str: str) -> Dict[str, Any]: + """Parses a trajectory filepath to extract metadata.""" + filepath = Path(filepath_str) + root_dir = Path(root_dir_str) + + data = { + "filepath": filepath_str, + "filename": filepath.name, + "model_name": "Unknown", + "task_name": "Unknown", + "run_name": "Unknown", + "seed": None, + "display_name": filepath.name, # Default display name + } + + try: + relative_path = filepath.relative_to(root_dir) + # path_parts: e.g., ('bhavul', 'meta-claude-35-sonnet__...', 'contrastiveLearningCifar10Accuracy.traj') + path_parts = relative_path.parts + + if len(path_parts) > 1: + # The folder containing the .traj file is usually path_parts[-2] + # This folder name often is: MODEL_NAME__TASK_NAME__PARAMS__RUN_NAME_WITH_SEED_AND_DATE + run_folder_name = path_parts[-2] + + # The actual task name is often the stem of the .traj file + data["task_name"] = filepath.stem + + folder_parts = run_folder_name.split('__') + if len(folder_parts) >= 1: + data["model_name"] = folder_parts[0] + if len(folder_parts) >= 2: + # task_name_from_folder = folder_parts[1] # This can also be used/checked + pass # data["task_name"] already set from filename stem + + # Run name is usually the last part of the folder_parts, or the whole folder_parts if complex + # For the example, it's the last major segment before date/seed specifics if using full folder name as run_name + # Or more simply, the full unique folder name is the "run_name" + data["run_name"] = run_folder_name + + seed_match = re.search(r'_s(\d+)', run_folder_name) + if seed_match: + data["seed"] = int(seed_match.group(1)) + + # Create a nicer display name + seed_str = f" (Seed {data['seed']})" if data['seed'] is not None else "" + data["display_name"] = f"{data['model_name']} / {data['task_name']} / {data['run_name']}{seed_str}" + # If the run_name is too long because it's the full folder, shorten it. + # Heuristic: if run_name contains model and task, just show the unique part. + shorter_run_name = data["run_name"].replace(data["model_name"], "").replace(data["task_name"], "").strip("_ ") + if shorter_run_name and len(shorter_run_name) < len(data["run_name"]): + data["display_name"] = f"{data['model_name']} / {data['task_name']} / {shorter_run_name}{seed_str}" + else: # Fallback to relative path if parsing is too complex or parts are missing + data["display_name"] = str(relative_path) - /* Expander Styling */ - .streamlit-expanderHeader { - background-color: var(--slate-800) !important; - color: var(--slate-200) !important; - border: 1px solid var(--slate-700) !important; - border-radius: 0.5rem !important; - padding: 1rem !important; - transition: all 0.2s ease; - } - .streamlit-expanderHeader:hover { - background-color: var(--slate-700) !important; - } + except ValueError: # filepath might not be under root_dir, or other parsing issue + data["display_name"] = str(filepath.parent.name) + "/" + filepath.name - .streamlit-expanderContent { - background-color: var(--slate-900) !important; - border: 1px solid var(--slate-700) !important; - border-radius: 0.5rem !important; - padding: 1.5rem !important; - margin-top: 0.5rem !important; - } - .welcome-box, .completion-box { - background: linear-gradient(135deg, rgba(148, 163, 184, 0.1) 0%, rgba(226, 232, 240, 0.15) 100%); - border-radius: 1.5rem; - padding: 3rem; - margin: 2rem 0; - border: 1px solid rgba(203, 213, 225, 0.2); - box-shadow: - 0 20px 40px rgba(0, 0, 0, 0.1), - inset 0 1px 0 rgba(255, 255, 255, 0.1); - backdrop-filter: blur(10px); - } + return data - .welcome-content, .completion-content { - max-width: 900px; - margin: 0 auto; - text-align: center; - } - .welcome-icon, .completion-icon { - font-size: 4rem; - margin-bottom: 1.5rem; - } +def find_trajectory_files(root_dir: str) -> List[Dict[str, Any]]: + root_path = Path(root_dir) + if not root_path.exists(): + st.error(f"Directory not found: {root_dir}") + return [] - .welcome-box h1, .completion-box h1 { - font-size: 2.5rem; - background: linear-gradient(135deg, #93c5fd 0%, #3b82f6 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - margin-bottom: 1.5rem; - line-height: 1.2; - font-weight: 700; - } + traj_files_data = [] + for file in root_path.rglob("*.traj"): + parsed_data = parse_trajectory_path(str(file.resolve()), str(root_path.resolve())) + traj_files_data.append(parsed_data) + + # Sort trajectories: by task, then model, then seed, then run_name + traj_files_data.sort(key=lambda x: (x["task_name"], x["model_name"], x["seed"] if x["seed"] is not None else -1, x["run_name"])) + return traj_files_data + + +def extract_step_from_model_input_block(block_lines: List[str]) -> Optional[int]: + """Helper to parse (Current Step: K, ...) from a block of text.""" + for line in block_lines: + match = re.search(r'\(Current Step: (\d+),', line) + if match: + return int(match.group(1)) + return None + + +def get_log_file_path(trajectory_file_path_str: str) -> Optional[Path]: + """Finds the log file associated with a given trajectory file path.""" + traj_file_path_obj = Path(trajectory_file_path_str) + log_dir = traj_file_path_obj.parent + found_log_path: Optional[Path] = None + + # Attempt 1: Find log files with 'run-' in their name and ending with '.log' + candidate_log_files_with_run = [ + item for item in log_dir.iterdir() + if item.is_file() and "run-" in item.name.lower() and item.name.lower().endswith(".log") + ] - .welcome-box p, .completion-box p { - font-size: 1.25rem; - color: #e2e8f0; - line-height: 1.7; - margin-bottom: 1.5rem; - } + if len(candidate_log_files_with_run) == 1: + found_log_path = candidate_log_files_with_run[0] + elif len(candidate_log_files_with_run) > 1: + exact_match_candidates = [f for f in candidate_log_files_with_run if f.stem == traj_file_path_obj.stem] + if len(exact_match_candidates) == 1: + found_log_path = exact_match_candidates[0] + else: + # Sort by name to have a deterministic choice if still ambiguous + candidate_log_files_with_run.sort(key=lambda p: p.name) + found_log_path = candidate_log_files_with_run[0] + # User will be warned by the calling function if multiple were ambiguous + elif len(candidate_log_files_with_run) == 0: + # Attempt 2: No 'run-' files, so look for any '.log' file. + all_log_files_in_dir = [ + item for item in log_dir.iterdir() + if item.is_file() and item.name.lower().endswith(".log") + ] + if len(all_log_files_in_dir) == 1: + found_log_path = all_log_files_in_dir[0] + elif len(all_log_files_in_dir) > 1: + exact_match_candidates = [f for f in all_log_files_in_dir if f.stem == traj_file_path_obj.stem] + if len(exact_match_candidates) == 1: + found_log_path = exact_match_candidates[0] + else: + default_log_path = traj_file_path_obj.with_suffix(".log") + if default_log_path.exists() and default_log_path in all_log_files_in_dir: + found_log_path = default_log_path + elif all_log_files_in_dir: + all_log_files_in_dir.sort(key=lambda p: p.name) # Sort for determinism + found_log_path = all_log_files_in_dir[0] + return found_log_path + +def load_and_segment_raw_logs(log_filepath: Path) -> Tuple[str, Dict[int, str]]: + """Loads a .log file and segments it by trajectory step.""" + preamble_logs_str = "" + step_log_segments: Dict[int, str] = {} + + if not log_filepath.exists(): + st.warning(f"Log file not found: {log_filepath}") + return preamble_logs_str, step_log_segments + + with open(log_filepath, 'r', encoding='utf-8') as f: + all_log_lines = f.readlines() + + model_input_markers = [] # Stores {"step": K, "index": line_index} + for i, line in enumerate(all_log_lines): + if "TRACE 🤖 MODEL INPUT (primary_0)" in line: + # Look ahead a few lines for (Current Step: K, ...) + # Max 20 lines lookahead for step info robustness + current_step_in_block = extract_step_from_model_input_block(all_log_lines[i : i + 20]) + if current_step_in_block is not None: + model_input_markers.append({"step": current_step_in_block, "index": i}) + + model_input_markers.sort(key=lambda x: x["index"]) # Ensure they are in order of appearance - .feature-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1.5rem; - margin-top: 2.5rem; - } + if not model_input_markers: + preamble_logs_str = "".join(all_log_lines) # Treat all as preamble if no markers + return preamble_logs_str, step_log_segments - .feature-item { - background: rgba(255, 255, 255, 0.05); - padding: 1.5rem; - border-radius: 1rem; - border: 1px solid rgba(255, 255, 255, 0.1); - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - transition: all 0.2s ease-in-out; - backdrop-filter: blur(5px); - } + # Preamble: logs before the first identified MODEL INPUT marker + first_marker_index = model_input_markers[0]["index"] + if first_marker_index > 0: + preamble_logs_str = "".join(all_log_lines[0:first_marker_index]) - .feature-item:hover { - transform: translateY(-4px); - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.2); - box-shadow: - 0 8px 20px rgba(0, 0, 0, 0.1), - inset 0 1px 0 rgba(255, 255, 255, 0.1); - } + for i, marker in enumerate(model_input_markers): + current_step_num = marker["step"] + start_index = marker["index"] + + end_index = len(all_log_lines) + if i + 1 < len(model_input_markers): + end_index = model_input_markers[i+1]["index"] + + step_log_segments[current_step_num] = "".join(all_log_lines[start_index:end_index]) + + return preamble_logs_str, step_log_segments + + +def load_full_trajectory_data(file_path: str) -> Optional[Tuple[str, List[Dict[str, Any]]]]: + """Loads .traj data and corresponding segmented .log data.""" + try: + with open(file_path, "r", encoding='utf-8') as file: + trajectory_steps = json.load(file)["trajectory"] + except FileNotFoundError: + st.error(f"Trajectory file not found: {file_path}") + return None + except json.JSONDecodeError: + st.error(f"Invalid JSON in trajectory file: {file_path}") + return None + except KeyError: + st.error(f"Missing 'trajectory' key in file: {file_path}") + return None + + found_log_path = get_log_file_path(file_path) # Use the new helper + + if found_log_path: + # It's good to inform the user which log file is being used for segmentation here + st.info(f"Using log file for segmentation: {found_log_path.name}") + preamble_logs, step_log_map = load_and_segment_raw_logs(found_log_path) + else: + st.warning(f"No suitable log file found for trajectory '{Path(file_path).name}'. Raw logs segments will be unavailable.") + preamble_logs, step_log_map = "", {} + + augmented_steps = [] + for i, step_data in enumerate(trajectory_steps): + # The 'i' here is the index in the trajectory list, which should correspond to the step number. + step_data['raw_log_segment'] = step_log_map.get(i, "Raw logs not found for this step or step numbering mismatch.") + augmented_steps.append(step_data) + + return preamble_logs, augmented_steps + + +# --- Evaluation Report Processing (largely unchanged) --- +def append_exit(content: Dict[str, Any]) -> Dict[str, Any]: + last_entry = content["history"][-1] + if last_entry["role"] == "system": return content + exit_status = content.get("info", {}).get("exit_status") + if not exit_status: return content + if exit_status.startswith("submitted"): + if "submission" in content["info"]: + content["history"].append({"role": "model_patch", "content": content["info"]["submission"]}) + else: + st.warning("Submission referenced in exit_status but not found in info.") + return content + +def format_metric_value(value: Optional[Union[int, float]]) -> str: + if value is None: return "N/A" + if isinstance(value, float): return f"{value:.2f}" + return f"{value:,}" + +def append_results( + traj_path_obj: Path, # Changed to Path object + instance_id: str, + content: Dict[str, Any], + results: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + stats: List[str] = [] + model_stats = {} + exit_status = None + + if traj_path_obj.exists(): # Use the passed Path object + data = json.loads(traj_path_obj.read_text(encoding='utf-8')) + info = data.get("info", {}) + exit_status = info.get("exit_status") + model_stats = info.get("model_stats", {}) + + instance_cost = format_metric_value(model_stats.get("total_cost")) + tokens_sent = format_metric_value(model_stats.get("tokens_sent")) + tokens_received = format_metric_value(model_stats.get("tokens_received")) + api_calls = format_metric_value(model_stats.get("api_calls")) + + stats.extend([ + "*" * 39, "Run Stats", "*" * 39, + f"Instance Cost: ${instance_cost}", + f"Tokens Sent: {tokens_sent}", + f"Tokens Received: {tokens_received}", + f"API Calls: {api_calls}", + f"Exit Status: {exit_status}", + ]) + + status = process_results(results) + eval_report = {"role": "Evaluation Report", "content": "\n".join([*stats, *status])} + + # Ensure history exists and is a list + if "history" not in content or not isinstance(content["history"], list): + content["history"] = [] - .feature-icon { - font-size: 2rem; - margin-bottom: 0.5rem; - } + content["history"].insert(0, eval_report) + # Avoid appending twice if it's already there or logic is different + # content["history"].append(eval_report) # Original code had this, might be specific + return content - .feature-item span:not(.feature-icon) { - color: #f1f5f9; - font-size: 1.1rem; - font-weight: 500; - } - @keyframes fadeIn { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } - } +def process_results(results: Optional[Dict[str, Any]]) -> List[str]: + if not results: return ["No scores found"] + agent_results = results.get("agent") + baseline_results = results.get("baseline") + if not agent_results and not baseline_results: return ["Baseline and Agent scores not found"] + + status = [] + if baseline_results and agent_results: + status.extend(["*" * 39, "Agent vs Baseline Scores", "*" * 39]) + formatted_scores = defaultdict(dict) + for score_type, score in baseline_results.items(): formatted_scores[score_type]["Baseline"] = score + for i, agent_score in enumerate(agent_results): # Assuming agent_results is a list + for score_type, score in agent_score.items(): formatted_scores[score_type][f"Attempt {i+1}"] = score + for score_type, scores in formatted_scores.items(): + status.append(f"Metric: {score_type}") + status.extend(f" {model}: {score:.3f}" for model, score in scores.items()) + elif baseline_results: + status.append("**** Baseline Scores ****") + status.extend(f" {score_type}: {score}" for score_type, score in baseline_results.items()) + elif agent_results: + status.append("**** Agent Scores ****") + if isinstance(agent_results, list): + for i, agent_res in enumerate(agent_results): + status.append(f"Attempt {i+1}:") + status.extend(f" {score_type}: {score}" for score_type, score in agent_res.items()) + elif isinstance(agent_results, dict): # If it's a single attempt + status.extend(f" {score_type}: {score}" for score_type, score in agent_results.items()) + else: + status.append(str(agent_results)) + return status - @keyframes pulseIcon { - 0% { transform: scale(1); } - 50% { transform: scale(1.1); } - 100% { transform: scale(1); } - } +def load_results(results_path: Path) -> Optional[Dict[str, Any]]: + if not results_path.exists(): return None + with open(results_path, encoding='utf-8') as infile: return json.load(infile) - /* Action Keywords - blue highlight */ - .streaming-text span.action-keyword { - background: linear-gradient(120deg, rgba(59, 130, 246, 0.2) 0%, rgba(59, 130, 246, 0.1) 100%); - color: #60a5fa; - padding: 0.1em 0.3em; - border-radius: 0.3em; - font-weight: 500; - margin: 0 0.1em; - } +def load_evaluation_content(file_name: str) -> Dict[str, Any]: # Renamed for clarity + """Loads .traj content specifically for the evaluation report.""" + filepath = Path(file_name) + try: + with open(filepath, encoding='utf-8') as infile: + content = json.load(infile) + except FileNotFoundError: + st.error(f"Trajectory file for eval content not found: {file_name}") + return {"history": [{"role": "Evaluation Report", "content": "Error: Trajectory file not found."}]} + except json.JSONDecodeError: + st.error(f"Invalid JSON in trajectory file for eval content: {file_name}") + return {"history": [{"role": "Evaluation Report", "content": "Error: Invalid JSON in trajectory file."}]} - /* Analysis Keywords - purple highlight */ - .streaming-text span.analysis-keyword { - background: linear-gradient(120deg, rgba(168, 85, 247, 0.2) 0%, rgba(168, 85, 247, 0.1) 100%); - color: #c084fc; - padding: 0.1em 0.3em; - border-radius: 0.3em; - font-weight: 500; - margin: 0 0.1em; - } - /* Performance Keywords - green highlight */ - .streaming-text span.performance-keyword { - background: linear-gradient(120deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.1) 100%); - color: #4ade80; - padding: 0.1em 0.3em; - border-radius: 0.3em; - font-weight: 500; - margin: 0 0.1em; - } + results_file = filepath.parent / "results.json" + results = load_results(results_file) - /* Error Keywords - red highlight */ - .streaming-text span.error-keyword { - background: linear-gradient(120deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.1) 100%); - color: #f87171; - padding: 0.1em 0.3em; - border-radius: 0.3em; - font-weight: 500; - margin: 0 0.1em; - } + content = append_exit(content) # Modifies content in place + return append_results(filepath, filepath.stem, content, results) - /* Optimization Keywords - orange highlight */ - .streaming-text span.optimization-keyword { - background: linear-gradient(120deg, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.1) 100%); - color: #fb923c; - padding: 0.1em 0.3em; - border-radius: 0.3em; - font-weight: 500; - margin: 0 0.1em; - } - /* Hover effect for all keywords */ - .streaming-text span[class*="-keyword"]:hover { - filter: brightness(1.2); - transform: translateY(-1px); - transition: all 0.2s ease; - } +# --- UI Display Functions --- - -""", unsafe_allow_html=True) +def display_content_summary(content: str) -> None: + if not content: return + # Using st.expander for better native feel + with st.expander("📝 Evaluation Report", expanded=False): + st.text(content) # Using st.text for pre-formatted like display -# Rest of the functions remain the same -def get_preview_lines(text, num_lines=5): - lines = text.split('\n') - preview = '\n'.join(lines[:num_lines]) - return preview, len(lines) > num_lines +def display_step(step_data: Dict[str, Any], step_num: int, total_steps: int) -> None: + st.markdown(f'
Step {step_num + 1} / {total_steps}
', unsafe_allow_html=True) -def stream_text(text, placeholder, is_code=False, language=None, delay=0.01, stream=True): - if not stream: - if is_code: - placeholder.code(text, language=language) - else: - placeholder.markdown(f'
{text}
', unsafe_allow_html=True) - return + # Navigation and Progress (only if in step-by-step view) + # This part was tied to session_state.view_mode == "step", assuming it's always step-by-step for now + # If you re-introduce full vs step view, conditionalize this block + col1, col2 = st.columns(2) + with col1: + if st.button("⬅️ Previous Step", key=f"prev_step_{step_num}", disabled=(step_num == 0), use_container_width=True): + st.session_state.current_step -= 1 + st.rerun() + with col2: + if st.button("Next Step ➡️", key=f"next_step_{step_num}", disabled=(step_num == total_steps - 1), use_container_width=True): + st.session_state.current_step += 1 + st.rerun() + st.progress((step_num + 1) / total_steps) - # For streaming text, use the same placeholder and update it - displayed_text = "" - for char in text: - displayed_text += char - if is_code: - placeholder.code(displayed_text, language=language) - else: - placeholder.markdown(f'
{displayed_text}
', unsafe_allow_html=True) - time.sleep(delay) - - -def highlight_step_keywords(text): - # List of keywords to highlight - keywords = [ - # Action words - r'\b(inspects|reads|understand|runs|reruns|implements|trains|evaluates|overachieving AI Scientist| comes up)\b', - - # Analysis words - r'\b(analyzes|infers)\b', - - # Performance metrics - r'(training accuracy improved significantly from \d+\.\d+% to \d+\.\d+%|test accuracy has improved from \d+\.\d+% to \d+\.\d+%|test accuracy is \d+\.\d+%|test accuracy of \d+\.\d+%)', - - # Error terms - r'\b(execution fails|missing library|timeout)\b', - - # Optimization terms - r'\b(increasing model complexity|adding data augmentation|using a learning rate scheduler|increasing the number of training epochs|make the code faster|reducing the number of epochs|using a smaller|random rotation and crop|convolutional layer|batch normalization|increases the number of training epochs|learning rate scheduler|weight decay|label smoothing|ResNet18 architecture|increased number of epochs|increased batch size|reduced learning rate|architecture|optimizer|learning rate warmup|cosine annealing with restarts|double checks| train a CNN| using a smaller architecture)\b' - ] + # Caption might not be available in step_data directly, adjust if needed + # st.markdown(f"""
{step_data.get('caption', '')}
""", unsafe_allow_html=True) + + display_step_components(step_data) + +def display_step_components(step_data: Dict[str, Any]) -> None: + # --- Thought Process --- + thought = step_data.get("thought", "") # Default to empty string + thought_cleaned = thought.replace("DISCUSSION", "").strip() + + # We'll build the HTML string for the box and inject the content. + # To render markdown within the thought_cleaned string, we can't just embed it. + # So, we use the 3-part st.markdown approach which is usually reliable. + + st.markdown( + '
' + '
💭 Thought Process
' + '
', # This div is for the actual thought content + unsafe_allow_html=True + ) + + if thought_cleaned: + # This is the crucial part: render the thought_cleaned as markdown here. + # It should render into the currently open HTML context. + st.markdown(thought_cleaned, unsafe_allow_html=True) + else: + # If there's no thought, display a placeholder. + st.markdown("No thought recorded.", unsafe_allow_html=True) + + st.markdown( + '
', # Closes .box-content and then .content-box + unsafe_allow_html=True + ) + + # --- Action Taken --- + action = step_data.get("action", "No action recorded.") + # For Action, Observation, and Raw Logs, we directly embed the content + # as pre-formatted text because they are not typically rich markdown. + st.markdown( + f'
' + f'
🤖 Action Taken
' + f'
{action.strip()}
' + f'
', + unsafe_allow_html=True, + ) + + # --- Execution Result --- + observation = step_data.get("observation", "No observation recorded.") + st.markdown( + f'
' + f'
💻 Execution Result
' + f'
{observation.strip()}
' + f'
', + unsafe_allow_html=True, + ) + + # --- Raw Logs Section --- + raw_log_segment = step_data.get("raw_log_segment", "Raw log segment not available.") + with st.expander("📄 Raw Logs for this Step", expanded=False): + # Using st.code is better for log/code blocks as it handles formatting. + st.code(raw_log_segment, language='text', line_numbers=False) # 'log' or 'text' + + +# --- Main Application Logic --- + +def initialize_session_state(args: argparse.Namespace) -> None: + if 'current_step' not in st.session_state: + st.session_state.current_step = 0 + if 'current_trajectory_path' not in st.session_state: # Renamed for clarity + st.session_state.current_trajectory_path = None + if 'all_trajectories' not in st.session_state: + st.session_state.all_trajectories = find_trajectory_files(args.trajectory_dir) - highlighted_text = text - for pattern in keywords: - highlighted_text = re.sub( - pattern, - lambda m: f'{m.group()}', - highlighted_text, - flags=re.IGNORECASE - ) - return highlighted_text - - -def display_transition_page(step_container): - """Display a transition page indicating steps are being skipped""" - step_container.markdown(""" -
-
...
-
Skipping intermediate steps
-
- """, unsafe_allow_html=True) - time.sleep(2) - - -def display_steps(data, index): - # Display welcome message - welcome_box = st.empty() - welcome_box.markdown(""" -
-
-
🧬
-

Welcome to the MLGym Demo

-

The MLGym Agent is tasked with maximising performance on a classical image classification task.

-

Watch as it iteratively improves performance through:

-
-
- 🔬 - Idea Generation -
-
- - Implementation -
-
- 📊 - Experimentation -
-
- 🔄 - Iteration -
-
-
+ # Initialize from query params + query_params = st.query_params + if "trajectory_path" in query_params and not st.session_state.current_trajectory_path: + # Validate if this path is in our list of known trajectories + path_from_query = query_params["trajectory_path"] + if any(t['filepath'] == path_from_query for t in st.session_state.all_trajectories): + st.session_state.current_trajectory_path = path_from_query + st.session_state.current_step = 0 # Reset step + else: + st.warning("Trajectory path from URL not found or invalid.") + st.query_params.clear() # Clear invalid param + +def display_welcome_message() -> None: + st.markdown( + """ +
+

👋 Welcome to the MLGym Trajectory Visualizer

+

+ Select a task from the sidebar to view the MLGym Agent's trajectory. +

- """, unsafe_allow_html=True) - time.sleep(5) # Show welcome message for 5 seconds - welcome_box.empty() - - # Create persistent containers for each step - step_container = st.empty() - cols = st.columns(3) - - # Create containers for column contents - thought_containers = { - 'main': cols[0].empty(), - 'content': cols[0].empty() - } + """, + unsafe_allow_html=True, + ) + +def setup_sidebar(args: argparse.Namespace) -> None: + with st.sidebar: + st.markdown("# 👩‍🔬 MLGym Visualizer") + st.markdown(f"**Directory:** `{args.trajectory_dir}`") + + all_trajectories = st.session_state.all_trajectories + if not all_trajectories: + st.warning("No trajectory files found.") + return + + # --- Search and Filters --- + search_term = st.text_input("Search Trajectories", placeholder="Search by name, model, task...") + + model_names = sorted(list(set(t["model_name"] for t in all_trajectories))) + task_names = sorted(list(set(t["task_name"] for t in all_trajectories))) + + selected_models = st.multiselect("Filter by Model", model_names) + selected_tasks = st.multiselect("Filter by Task", task_names) + + # --- Apply Filters and Search --- + filtered_trajectories = all_trajectories + if search_term: + search_lower = search_term.lower() + filtered_trajectories = [ + t for t in filtered_trajectories + if search_lower in t["display_name"].lower() or \ + search_lower in t["filepath"].lower() + ] + if selected_models: + filtered_trajectories = [t for t in filtered_trajectories if t["model_name"] in selected_models] + if selected_tasks: + filtered_trajectories = [t for t in filtered_trajectories if t["task_name"] in selected_tasks] + + # --- Display Trajectories --- + st.markdown("### Select Trajectory") + if not filtered_trajectories: + st.info("No trajectories match your criteria.") + + # Group by task for better organization + trajectories_by_task = defaultdict(list) + for t in filtered_trajectories: + trajectories_by_task[t['task_name']].append(t) + + for task_name, trajectories_in_task in trajectories_by_task.items(): + with st.expander(f"Task: {task_name} ({len(trajectories_in_task)})"): + for trajectory_data in trajectories_in_task: + # Use a more specific key for the button + button_key = f"btn_{trajectory_data['filepath']}" + if st.button(trajectory_data["display_name"], key=button_key, use_container_width=True): + st.session_state.current_trajectory_path = trajectory_data["filepath"] + st.session_state.current_step = 0 + st.query_params["trajectory_path"] = trajectory_data["filepath"] # Update URL + st.rerun() # Rerun to reflect selection and URL change immediately + + +def display_trajectory_content() -> None: + if not st.session_state.current_trajectory_path: + display_welcome_message() + return - action_containers = { - 'main': cols[1].empty(), - 'content': cols[1].empty() - } + st.title("👩‍🔬 Trajectory Details") - result_containers = { - 'main': cols[2].empty(), - 'content': cols[2].empty() - } + # --- Log and SLURM File Download Buttons --- + current_traj_path_obj = Path(st.session_state.current_trajectory_path) + log_dir = current_traj_path_obj.parent + + # Main Log File + main_log_file_to_download = get_log_file_path(st.session_state.current_trajectory_path) # Uses existing helper + + # SLURM Files - We'll search for them directly here + slurm_out_files = sorted(list(log_dir.glob("slurm_*.out")), reverse=True) # Get newest first if multiple + slurm_err_files = sorted(list(log_dir.glob("slurm_*.err")), reverse=True) # Get newest first if multiple + + # Create columns for the buttons + num_buttons = 0 + if main_log_file_to_download and main_log_file_to_download.exists(): + num_buttons += 1 + if slurm_out_files: + num_buttons +=1 + if slurm_err_files: + num_buttons +=1 - timestamp_container = st.empty() - - - for i in range(index, len(data)): - step_data = data[i] - - # Clear all containers at the start of each step - for containers in [thought_containers, action_containers, result_containers]: - containers['main'].empty() - containers['content'].empty() - - # Update step indicator with highlighted keywords - if "caption" in step_data: - highlighted_caption = highlight_step_keywords(step_data["caption"]) - step_container.markdown(f""" -
- 🔍 Step {i + 1} / {len(data)}: {highlighted_caption} -
- """, unsafe_allow_html=True) - time.sleep(2) - else: - step_container.markdown(f""" -
- 🔍 Step {i + 1} / {len(data)} -
- """, unsafe_allow_html=True) - time.sleep(2) - - # Display Thought Process - thought_containers['main'].markdown('
💭 Thought Process
', unsafe_allow_html=True) - thought_text = step_data["thought"].replace("DISCUSSION", "") - preview, has_more = get_preview_lines(thought_text) - - if has_more: - with thought_containers['content'].container(): - stream_text(preview, st.empty()) - with st.expander("💭 Full Thought Process", expanded=False): - st.markdown(thought_text) - else: - stream_text(thought_text, thought_containers['content']) - time.sleep(1) - - # Display Action - action_containers['main'].markdown('
🤖 Action Taken
', unsafe_allow_html=True) - action_text = step_data["action"] - preview, has_more = get_preview_lines(action_text) + if num_buttons > 0: + cols = st.columns(num_buttons) + col_idx = 0 + + if main_log_file_to_download and main_log_file_to_download.exists(): + with cols[col_idx]: + try: + with open(main_log_file_to_download, "rb") as fp: + st.download_button( + label="📥 Download App Log", # Renamed for clarity + data=fp, + file_name=main_log_file_to_download.name, + mime="text/plain", + use_container_width=True + ) + except Exception as e: + st.error(f"Error (App Log): {e}") + col_idx +=1 + elif main_log_file_to_download: # File was identified but doesn't exist + with cols[col_idx]: # Still take up a column to show the warning + st.warning(f"App log '{main_log_file_to_download.name}' not found.") + col_idx +=1 + + + if slurm_out_files: + slurm_out_file_to_download = slurm_out_files[0] # Pick the first one (newest due to sort) + with cols[col_idx]: + if slurm_out_file_to_download.exists(): + try: + with open(slurm_out_file_to_download, "rb") as fp: + st.download_button( + label="📄 Download Slurm Out", + data=fp, + file_name=slurm_out_file_to_download.name, + mime="text/plain", + use_container_width=True + ) + except Exception as e: + st.error(f"Error (Slurm Out): {e}") + else: # Should not happen if glob found it, but good check + st.warning(f"Slurm out file '{slurm_out_file_to_download.name}' not found.") + col_idx+=1 - if has_more: - with action_containers['content'].container(): - stream_text(preview, st.empty(), is_code=True, language="python") - with st.expander("🤖 Full Action Taken", expanded=False): - st.code(action_text, language="python") - else: - stream_text(action_text, action_containers['content'], is_code=True, language="python") - time.sleep(min(step_data["execution_time"], 2)) + if slurm_err_files: + slurm_err_file_to_download = slurm_err_files[0] # Pick the first one (newest due to sort) + with cols[col_idx]: + if slurm_err_file_to_download.exists(): + try: + with open(slurm_err_file_to_download, "rb") as fp: + st.download_button( + label="⚠️ Download Slurm Err", + data=fp, + file_name=slurm_err_file_to_download.name, + mime="text/plain", + use_container_width=True + ) + except Exception as e: + st.error(f"Error (Slurm Err): {e}") + else: # Should not happen if glob found it + st.warning(f"Slurm err file '{slurm_err_file_to_download.name}' not found.") + col_idx+=1 - # Display Result - result_containers['main'].markdown('
💻 Execution Result
', unsafe_allow_html=True) - result_text = step_data["observation"] - preview, has_more = get_preview_lines(result_text) - - if has_more: - with result_containers['content'].container(): - stream_text(preview, st.empty(), is_code=True, language="bash", stream=False) - with st.expander("💻 Full Execution Result", expanded=False): - st.code(result_text, language="bash") - else: - stream_text(result_text, result_containers['content'], is_code=True, language="bash", stream=False) - - time.sleep(2) - # Clear all containers at the end of the step - for containers in [thought_containers, action_containers, result_containers]: - containers['main'].empty() - containers['content'].empty() - - - # Display completion message after all steps - step_container.empty() - completion_box = st.empty() - completion_box.markdown(""" -
-
-
🚀
-

Future of AI Research

-

This demo illustrates the potential of AI Research Assistants. We envision a future where AI Research Assistants:

-
-
- 🤖 - Enhance the process of generating novel ideas and algorithms -
-
- ⚙️ - Assist with implementation, experimentation, and optimization -
-
- 📈 - Self-improve over extended periods -
-
- 🔄 - Accelerate research cycles and innovation -
-
-

Together, human researchers and AI assistants can accelerate scientific progress and push the boundaries of what's possible.

-
-
- """, unsafe_allow_html=True) - - -## Initialize session states -if "current_trajectory" not in st.session_state: - st.session_state.current_trajectory = None -if "index" not in st.session_state: - st.session_state.index = 0 -if "is_playing" not in st.session_state: - st.session_state.is_playing = False - -def load_trajectory(file_path): - with open(file_path, "r") as file: - return json.load(file)["trajectory"] - -# Sidebar with trajectory selection and system prompt -with st.sidebar: - st.markdown("# 👩‍🔬 MLGym Agent") + st.markdown("---") # Add a separator after the buttons + else: # No log files of any kind found to offer for download + st.info("No application log or SLURM log files found for download in the trajectory directory.") + st.markdown("---") - # Add system prompt to sidebar - st.markdown(""" - - """, unsafe_allow_html=True) - - # Full system prompt in an expander - with st.expander("📖 View Full System Prompt"): - st.markdown(f""" -
- {SYSTEM_PROMPT} -
- """, unsafe_allow_html=True) - - # Full system prompt in an expander - with st.expander("🛠️ Tools"): - st.markdown(f""" -
- {TOOLS} -
- """, unsafe_allow_html=True) - - st.markdown("### Select Task") + # Display Evaluation Report Summary + eval_content_data = load_evaluation_content(st.session_state.current_trajectory_path) + if eval_content_data and "history" in eval_content_data and eval_content_data["history"]: + # Assuming the first item is the main report string after processing + report_str = eval_content_data["history"][0].get("content", "Evaluation report not available.") + display_content_summary(report_str) + else: + st.info("Evaluation report data could not be loaded or is empty.") + + # Load and display trajectory steps with raw logs + trajectory_data = load_full_trajectory_data(st.session_state.current_trajectory_path) + if not trajectory_data: + return # Error already shown by load_full_trajectory_data + + preamble_logs, augmented_steps = trajectory_data - trajectory_dir = "trajectories/mlgym_bench_v0" - trajectories = [ - { - "name": "Image Classification (CIFAR-10)", - "icon": "🖼️", - "description": "Train a model to classify images into 10 categories.", - "path": f"{trajectory_dir}/metagen-claude-35-sonnet__imageClassificationCifar10__better_thought_action_parser_with_insert__t-0.00__p-0.95__c-4.00__install-0__parallel_agents_device_5/imageClassificationCifar10.traj", - "full_description": f"{TASK_DESCRIPTIONS['cifar10']}" - }, - { - "name": "House Price Prediction (Kaggle)", - "icon": "🏠", - "description": "Predict house prices using regression.", - "path": f"{trajectory_dir}/metagen-claude-35-sonnet__regressionKaggleHousePrice__better_thought_action_parser_with_insert__t-0.00__p-0.95__c-4.00__install-0__parallel_agents_device_2/regressionKaggleHousePrice.traj", - "full_description": f"{TASK_DESCRIPTIONS['house_price']}" - }, - { - "name": "Language Modeling (FineWeb)", - "icon": "📝", - "description": "Decrease perplexity on FineWeb.", - "path": f"{trajectory_dir}/metagen-claude-35-sonnet-new__languageModelingFineWeb__better_thought_action_parser_with_insert__t-0.00__p-0.95__c-4.00__install-0__submission_forma_test_run_0/languageModelingFineWeb.traj", - "full_description": f"{TASK_DESCRIPTIONS['language_modeling']}" - }, - { - "name": "Reinforcement Learning (MountainCar)", - "icon": "🚗", - "description": "Maximize reward in MountainCar by controlling the car to drive up a steep hill.", - "path": f"{trajectory_dir}/metagen-gemini-15-pro__rlMountainCarContinuous__better_thought_action_parser_with_insert__t-0.00__p-0.95__c-4.00__install-0__parallel_agents_run_2/rlMountainCarContinuous.traj", - "full_description": f"{TASK_DESCRIPTIONS['mountain_car']}" - }, - { - "name": "Game Theory (Battle of Sexes)", - "icon": "🎮", - "description": "Find a winning strategy in the Battle of Sexes game. Batle of Sexes is a coordination game between two players with different preferences (e.g. a couple deciding how to spend their weekend).", - "path": f"{trajectory_dir}/metagen-gpt-o1__battleOfSexes__better_thought_action_parser_with_insert__t-0.00__p-0.95__c-4.00__install-0__parallel_agents_run_0/battleOfSexes.traj", - "full_description": f"{TASK_DESCRIPTIONS['battle_of_sexes']}" - }, - ] - - for traj in trajectories: - st.markdown(f""" -
-

{traj['icon']} {traj['name']}

-

{traj['description']}

-
- """, unsafe_allow_html=True) - - # Add expandable task description - with st.expander("📋 View Full Task Description"): - st.markdown(traj['full_description']) - - if st.button("▶️ Replay Experiment", key=traj["path"]): - st.session_state.current_trajectory = traj["path"] - st.session_state.index = 0 - st.session_state.is_playing = True - st.rerun() - -# Main content area -if st.session_state.current_trajectory: - st.title("👩‍🔬 MLGym Agent") - - # Load and display trajectory - data = load_trajectory(st.session_state.current_trajectory) - if st.session_state.is_playing: - display_steps(data, st.session_state.index) - st.session_state.index = len(data) - st.session_state.is_playing = False + if preamble_logs: + with st.expander("📜 Log Preamble (System Prompts, Initial Setup)", expanded=False): + st.text(preamble_logs) - # # Progress indicator - # progress_percentage = (len(data) / 50) * 100 - # st.markdown(f""" - #
- #

Experiment Progress

- # Step {len(data)} of 50 ({progress_percentage:.1f}% complete) - #
- # """, unsafe_allow_html=True) - -else: - st.markdown(""" -
-

👋 Welcome to the MLGym Demo

-

- Select a task from the sidebar to watch the MLGym Agent in action. -

-

- Note: This is a replay of previously generated experiments, not real-time execution. -

-
- """, unsafe_allow_html=True) + if not augmented_steps: + st.info("No steps found in this trajectory.") + return + + # View mode selection (optional, if you want full vs. step-by-step) + # For now, always step-by-step + current_step_idx = st.session_state.current_step + if 0 <= current_step_idx < len(augmented_steps): + display_step(augmented_steps[current_step_idx], current_step_idx, len(augmented_steps)) + else: + st.warning(f"Current step index {current_step_idx} is out of bounds for {len(augmented_steps)} steps. Resetting.") + st.session_state.current_step = 0 + st.rerun() + + +def main() -> None: + args = parse_args() + configure_page_style() + initialize_session_state(args) # Pass args for initial loading + setup_sidebar(args) + display_trajectory_content() + +if __name__ == "__main__": + main()