Spaces:
Runtime error
Runtime error
| """ | |
| Agent Capability Profiler | |
| Analyzes trajectory data to characterize agent behavior along | |
| research-relevant dimensions. Enables comparative analysis | |
| across different agent architectures. | |
| Research motivation: | |
| Standard benchmarks report only aggregate scores. This profiler | |
| enables fine-grained behavioral analysis: | |
| - Exploration rate: is the agent stuck in a policy rut? | |
| - Reward trajectory shape: does the agent learn within episodes? | |
| - Action distribution: does the agent exploit trivial strategies? | |
| - Severity calibration: how well-calibrated are predictions? | |
| """ | |
| import json | |
| import os | |
| import hashlib | |
| import statistics | |
| from typing import Dict, List, Tuple, Optional | |
| from env.data_generator import SEVERITY_ORDER | |
| class AgentProfiler: | |
| """ | |
| Analyzes trajectory data for research-grade agent characterization. | |
| Use this to compare agent architectures beyond simple score tables. | |
| Generate reports suitable for paper appendices. | |
| """ | |
| def load_trajectories(self, directory: str) -> List[Dict]: | |
| """ | |
| Load all JSONL trajectory files from directory. | |
| Each file is one episode, each line is one (s, a, r, s') transition. | |
| """ | |
| trajectories = [] | |
| if not os.path.exists(directory): | |
| return trajectories | |
| for filename in sorted(os.listdir(directory)): | |
| if filename.endswith(".jsonl"): | |
| filepath = os.path.join(directory, filename) | |
| episode = [] | |
| with open(filepath, "r") as f: | |
| for line in f: | |
| line = line.strip() | |
| if line: | |
| episode.append(json.loads(line)) | |
| if episode: | |
| trajectories.append(episode) | |
| return trajectories | |
| def compute_exploration_rate(self, trajectories: List[List[Dict]]) -> float: | |
| """ | |
| Fraction of unique (state_hash, action_type) pairs visited. | |
| Low exploration = agent stuck in policy rut (always same action). | |
| High exploration = agent adapts to different states. | |
| State hash uses PR title + author_experience to avoid | |
| hash collisions on diff content. | |
| """ | |
| if not trajectories: | |
| return 0.0 | |
| unique_pairs = set() | |
| total_steps = 0 | |
| for episode in trajectories: | |
| for transition in episode: | |
| state = transition.get("state", {}) | |
| action = transition.get("action", {}) | |
| # Hash state on key semantic features | |
| state_key = f"{state.get('pr_id', '')}_{state.get('title', '')}" | |
| state_hash = hashlib.md5(state_key.encode()).hexdigest()[:8] | |
| action_type = action.get("action_type", "unknown") | |
| pair = (state_hash, action_type) | |
| unique_pairs.add(pair) | |
| total_steps += 1 | |
| if total_steps == 0: | |
| return 0.0 | |
| return len(unique_pairs) / total_steps | |
| def compute_reward_trajectory_shape(self, trajectories: List[List[Dict]]) -> Dict: | |
| """ | |
| Analyze how reward evolves across steps within episodes. | |
| Returns: | |
| slope: linear regression slope of reward over steps | |
| variance: reward variance within episodes | |
| monotonic_fraction: fraction of episodes with monotonically | |
| increasing reward (in-context learning signal) | |
| """ | |
| if not trajectories: | |
| return {"slope": 0.0, "variance": 0.0, "monotonic_fraction": 0.0} | |
| all_slopes = [] | |
| all_variances = [] | |
| monotonic_count = 0 | |
| for episode in trajectories: | |
| rewards = [t.get("reward", {}).get("value", 0.0) for t in episode] | |
| if len(rewards) < 2: | |
| continue | |
| # Simple linear regression slope | |
| n = len(rewards) | |
| x_mean = (n - 1) / 2 | |
| y_mean = sum(rewards) / n | |
| numerator = sum((i - x_mean) * (r - y_mean) for i, r in enumerate(rewards)) | |
| denominator = sum((i - x_mean) ** 2 for i in range(n)) | |
| slope = numerator / denominator if denominator != 0 else 0.0 | |
| all_slopes.append(slope) | |
| # Variance | |
| if len(rewards) > 1: | |
| all_variances.append(statistics.variance(rewards)) | |
| # Monotonicity check | |
| is_monotonic = all(rewards[i] <= rewards[i + 1] for i in range(len(rewards) - 1)) | |
| if is_monotonic: | |
| monotonic_count += 1 | |
| n_episodes = len(trajectories) | |
| return { | |
| "slope": statistics.mean(all_slopes) if all_slopes else 0.0, | |
| "variance": statistics.mean(all_variances) if all_variances else 0.0, | |
| "monotonic_fraction": monotonic_count / n_episodes if n_episodes > 0 else 0.0, | |
| } | |
| def compute_action_distribution(self, trajectories: List[List[Dict]]) -> Dict: | |
| """ | |
| Distribution of action_types across all steps. | |
| Reveals if agent exploits (e.g., always approve) or uses | |
| the full action space. Uniform distribution over valid | |
| actions suggests genuine exploration. | |
| """ | |
| counts: Dict[str, int] = {} | |
| total = 0 | |
| for episode in trajectories: | |
| for transition in episode: | |
| action = transition.get("action", {}) | |
| action_type = action.get("action_type", "unknown") | |
| counts[action_type] = counts.get(action_type, 0) + 1 | |
| total += 1 | |
| if total == 0: | |
| return {} | |
| return {k: {"count": v, "fraction": v / total} for k, v in sorted(counts.items())} | |
| def compute_severity_calibration(self, trajectories: List[List[Dict]]) -> Dict: | |
| """ | |
| For easy task: calibration curve of predicted vs true severity. | |
| Similar to probability calibration in classification literature. | |
| Returns fraction of correct predictions per severity level. | |
| """ | |
| correct_by_severity: Dict[str, int] = {s: 0 for s in SEVERITY_ORDER} | |
| total_by_severity: Dict[str, int] = {s: 0 for s in SEVERITY_ORDER} | |
| for episode in trajectories: | |
| for transition in episode: | |
| action = transition.get("action", {}) | |
| if action.get("action_type") != "label_severity": | |
| continue | |
| predicted = action.get("severity", "none") | |
| # Get true severity from reward reason | |
| reason = transition.get("reward", {}).get("reason", "") | |
| true_sev = None | |
| if "Truth:" in reason: | |
| parts = reason.split("Truth:") | |
| if len(parts) > 1: | |
| true_sev = parts[1].strip().split()[0].strip(",") | |
| if true_sev and true_sev in total_by_severity: | |
| total_by_severity[true_sev] += 1 | |
| if predicted == true_sev: | |
| correct_by_severity[true_sev] += 1 | |
| calibration = {} | |
| for sev in SEVERITY_ORDER: | |
| total = total_by_severity[sev] | |
| if total > 0: | |
| calibration[sev] = { | |
| "accuracy": correct_by_severity[sev] / total, | |
| "total": total, | |
| "correct": correct_by_severity[sev], | |
| } | |
| else: | |
| calibration[sev] = {"accuracy": 0.0, "total": 0, "correct": 0} | |
| return calibration | |
| def compare_agents(self, agent_a_dir: str, agent_b_dir: str) -> Dict: | |
| """ | |
| Statistical comparison between two agents. | |
| Uses Mann-Whitney U test for non-parametric comparison | |
| and Cohen's d for effect size measurement. | |
| """ | |
| traj_a = self.load_trajectories(agent_a_dir) | |
| traj_b = self.load_trajectories(agent_b_dir) | |
| scores_a = [ | |
| statistics.mean([t.get("reward", {}).get("value", 0.0) for t in ep]) | |
| for ep in traj_a if ep | |
| ] | |
| scores_b = [ | |
| statistics.mean([t.get("reward", {}).get("value", 0.0) for t in ep]) | |
| for ep in traj_b if ep | |
| ] | |
| if not scores_a or not scores_b: | |
| return {"error": "Insufficient trajectory data for comparison"} | |
| mean_a = statistics.mean(scores_a) | |
| mean_b = statistics.mean(scores_b) | |
| std_a = statistics.stdev(scores_a) if len(scores_a) > 1 else 0.001 | |
| std_b = statistics.stdev(scores_b) if len(scores_b) > 1 else 0.001 | |
| # Cohen's d effect size | |
| pooled_std = ((std_a ** 2 + std_b ** 2) / 2) ** 0.5 | |
| cohens_d = (mean_a - mean_b) / pooled_std if pooled_std > 0 else 0.0 | |
| # Mann-Whitney U (simplified — counts wins) | |
| u_stat = 0 | |
| for sa in scores_a: | |
| for sb in scores_b: | |
| if sa > sb: | |
| u_stat += 1 | |
| elif sa == sb: | |
| u_stat += 0.5 | |
| n_a, n_b = len(scores_a), len(scores_b) | |
| max_u = n_a * n_b | |
| return { | |
| "agent_a": {"mean": mean_a, "std": std_a, "n": n_a}, | |
| "agent_b": {"mean": mean_b, "std": std_b, "n": n_b}, | |
| "cohens_d": cohens_d, | |
| "effect_interpretation": self._interpret_effect(abs(cohens_d)), | |
| "mann_whitney_u": u_stat, | |
| "u_normalized": u_stat / max_u if max_u > 0 else 0.0, | |
| } | |
| def _interpret_effect(d: float) -> str: | |
| """Interpret Cohen's d effect size (Cohen, 1988).""" | |
| if d < 0.2: | |
| return "negligible" | |
| elif d < 0.5: | |
| return "small" | |
| elif d < 0.8: | |
| return "medium" | |
| else: | |
| return "large" | |
| def generate_report(self, trajectories: List[List[Dict]], agent_name: str) -> str: | |
| """ | |
| Generate markdown report with all metrics. | |
| Format suitable for inclusion in paper appendix. | |
| """ | |
| exploration = self.compute_exploration_rate(trajectories) | |
| shape = self.compute_reward_trajectory_shape(trajectories) | |
| actions = self.compute_action_distribution(trajectories) | |
| calibration = self.compute_severity_calibration(trajectories) | |
| # Compute aggregate scores | |
| episode_scores = [] | |
| for ep in trajectories: | |
| if ep: | |
| rewards = [t.get("reward", {}).get("value", 0.0) for t in ep] | |
| episode_scores.append(statistics.mean(rewards)) | |
| report = f"# Agent Profile: {agent_name}\n\n" | |
| report += f"## Summary\n" | |
| report += f"- Episodes: {len(trajectories)}\n" | |
| if episode_scores: | |
| report += f"- Mean score: {statistics.mean(episode_scores):.3f}\n" | |
| if len(episode_scores) > 1: | |
| report += f"- Score std: {statistics.stdev(episode_scores):.3f}\n" | |
| report += f"- Exploration rate: {exploration:.3f}\n" | |
| report += f"- Reward slope: {shape['slope']:.4f}\n" | |
| report += f"- Monotonic episodes: {shape['monotonic_fraction']:.1%}\n\n" | |
| report += f"## Action Distribution\n" | |
| report += "| Action | Count | Fraction |\n" | |
| report += "|--------|-------|----------|\n" | |
| for atype, info in actions.items(): | |
| report += f"| {atype} | {info['count']} | {info['fraction']:.2%} |\n" | |
| report += "\n" | |
| report += f"## Severity Calibration\n" | |
| report += "| Severity | Accuracy | Total | Correct |\n" | |
| report += "|----------|----------|-------|---------|\n" | |
| for sev, info in calibration.items(): | |
| report += f"| {sev} | {info['accuracy']:.2%} | {info['total']} | {info['correct']} |\n" | |
| return report | |