| |
| """Aggregate MCP scaling experiment metrics from Biomni/bioagent-bench run dirs.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import re |
| import time |
| from functools import lru_cache |
| from pathlib import Path |
| from typing import Any |
|
|
| DEFAULT_EXP_DIR = Path(__file__).resolve().parents[1] |
| DEFAULT_GOLD = DEFAULT_EXP_DIR / "gold_tools.json" |
| DEFAULT_MANIFEST = DEFAULT_EXP_DIR / "configs" / "mcp_scale_manifest.json" |
| DEFAULT_DATASET_ROOT = Path("/225040511/project/bioagent-bench/dataset") |
| DEFAULT_PRIMARY_K = 10 |
|
|
| TYPE_MISMATCH_PATTERNS = [ |
| r"typeerror", |
| r"valueerror", |
| r"validationerror", |
| r"pydantic", |
| r"missing required positional argument", |
| r"unexpected keyword argument", |
| r"unsupported operand type", |
| r"input should be", |
| r"should be of type", |
| r"must be (?:a|an)?\s*(?:int|integer|float|bool|boolean|string|str|list|dict|path|file)", |
| r"expected .*?(?:int|integer|float|bool|boolean|string|str|list|dict|path|file)", |
| r"invalid literal for int", |
| r"cannot be interpreted as an integer", |
| r"is not iterable", |
| ] |
|
|
| CONSTRAINT_VIOLATION_PATTERNS = [ |
| r"\bpip install\b", |
| r"\bconda install\b", |
| r"\bapt-get\b", |
| r"\bwget\b", |
| r"\bcurl\b", |
| r"\bgit clone\b", |
| r"https?://", |
| ] |
|
|
|
|
| def load_json(path: Path, default: Any = None) -> Any: |
| if not path.exists(): |
| return default |
| return json.loads(path.read_text(encoding="utf-8")) |
|
|
|
|
| def load_text(path: Path, default: str = "") -> str: |
| if not path.exists(): |
| return default |
| return path.read_text(encoding="utf-8", errors="ignore") |
|
|
|
|
| def load_yaml_if_available(path: Path, default: Any = None) -> Any: |
| if not path.exists(): |
| return default |
| try: |
| import yaml |
| except ModuleNotFoundError: |
| return default |
| return yaml.safe_load(path.read_text(encoding="utf-8")) or default |
|
|
|
|
| def normalize(text: str) -> str: |
| return re.sub(r"[^a-z0-9]+", "", text.lower()) |
|
|
|
|
| def extract_names(obj: Any) -> list[str]: |
| names: list[str] = [] |
| if isinstance(obj, dict): |
| for key, value in obj.items(): |
| if key in {"name", "tool_name", "server", "module"} and isinstance(value, str): |
| names.append(value) |
| names.extend(extract_names(value)) |
| elif isinstance(obj, list): |
| for item in obj: |
| names.extend(extract_names(item)) |
| elif isinstance(obj, str): |
| for token in re.findall(r"\b[a-zA-Z][a-zA-Z0-9_.-]{2,}\b", obj): |
| if any( |
| hint in token.lower() |
| for hint in ( |
| "mcp", |
| "tool", |
| "kraken", |
| "kallisto", |
| "salmon", |
| "scanpy", |
| "star", |
| "bwa", |
| "bcftools", |
| "samtools", |
| "deseq", |
| "kaiju", |
| "megahit", |
| "gatk", |
| "freebayes", |
| ) |
| ): |
| names.append(token) |
| return names |
|
|
|
|
| def recall_at_k(candidates: list[str], gold: list[str], k: int) -> float: |
| if not gold: |
| return 0.0 |
| top = {normalize(x) for x in candidates[:k]} |
| hits = 0 |
| for item in gold: |
| n = normalize(item) |
| if any(n in candidate or candidate in n for candidate in top): |
| hits += 1 |
| return hits / len(gold) |
|
|
|
|
| def gold_hit(candidates: list[str], gold: list[str]) -> bool: |
| return recall_at_k(candidates, gold, max(len(candidates), 1)) > 0 |
|
|
|
|
| def mean_or_none(values: list[float | int | None]) -> float | None: |
| numeric = [float(value) for value in values if isinstance(value, (int, float))] |
| if not numeric: |
| return None |
| return sum(numeric) / len(numeric) |
|
|
|
|
| def workflow_validity(evidence: list[str], workflow: list[dict[str, Any]]) -> dict[str, Any]: |
| found = [] |
| cursor = 0 |
| normalized_evidence = [normalize(x) for x in evidence] |
| for step in workflow: |
| patterns = step.get("tool_patterns", []) |
| matched_at = None |
| for i in range(cursor, len(normalized_evidence)): |
| row = normalized_evidence[i] |
| if any(normalize(pattern) in row for pattern in patterns): |
| matched_at = i |
| break |
| if matched_at is not None: |
| found.append(step.get("name", "step")) |
| cursor = matched_at + 1 |
| return { |
| "valid": len(found) == len(workflow) and bool(workflow), |
| "steps_matched": len(found), |
| "steps_total": len(workflow), |
| "matched_step_names": found, |
| } |
|
|
|
|
| def execution_success(run_dir: Path) -> bool: |
| validation = load_json(run_dir / "output_validation.json", {}) |
| if isinstance(validation, dict): |
| if validation.get("success") is True or validation.get("all_outputs_exist") is True: |
| return True |
| outputs = validation.get("outputs") or validation.get("output_paths") |
| if isinstance(outputs, list) and outputs: |
| return all(bool(x.get("exists")) for x in outputs if isinstance(x, dict)) |
| summary = load_json(run_dir / "run_summary.json", {}) |
| if isinstance(summary, dict): |
| if summary.get("success") is True: |
| return True |
| if summary.get("status") in {"success", "completed"}: |
| return True |
| metadata = load_json(run_dir / "run_metadata.json", {}) |
| for output in metadata.get("output_paths", []) if isinstance(metadata, dict) else []: |
| if not Path(output).exists(): |
| return False |
| return bool(metadata.get("output_paths")) if isinstance(metadata, dict) else False |
|
|
|
|
| def rough_token_count(text: str) -> int: |
| return max(1, len(re.findall(r"\S+", text))) |
|
|
|
|
| def discover_task_ids(dataset_root: Path) -> list[str]: |
| if not dataset_root.exists(): |
| return [] |
| return sorted(p.name for p in dataset_root.iterdir() if p.is_dir()) |
|
|
|
|
| def collect_task_runs(runs_root: Path, task_id: str) -> list[Path]: |
| if not runs_root.exists(): |
| return [] |
| pattern = re.compile(rf"^{re.escape(task_id)}_(\d{{8}}_\d{{6}})$") |
| matched: list[tuple[str, Path]] = [] |
| for run_dir in runs_root.iterdir(): |
| if not run_dir.is_dir(): |
| continue |
| match = pattern.match(run_dir.name) |
| if match: |
| matched.append((match.group(1), run_dir)) |
| matched.sort(key=lambda x: x[0]) |
| return [path for _, path in matched] |
|
|
|
|
| @lru_cache(maxsize=16) |
| def load_mcp_tool_universe(config_path: str) -> list[str]: |
| cfg = load_yaml_if_available(Path(config_path), default={}) |
| if not isinstance(cfg, dict): |
| return [] |
| names: list[str] = [] |
| for server_meta in (cfg.get("mcp_servers") or {}).values(): |
| if not isinstance(server_meta, dict): |
| continue |
| for tool in server_meta.get("tools", []): |
| if isinstance(tool, dict) and tool.get("name"): |
| names.append(tool["name"]) |
| return names |
|
|
|
|
| def selected_tool_names(retrieval_plan: dict[str, Any]) -> list[str]: |
| names = retrieval_plan.get("selected_resource_names", {}).get("tools", []) |
| if names: |
| return [str(name) for name in names if name] |
| tool_items = retrieval_plan.get("selected_resources", {}).get("tools", []) |
| return [item.get("name") for item in tool_items if isinstance(item, dict) and item.get("name")] |
|
|
|
|
| def registered_tool_names(retrieval_plan: dict[str, Any]) -> list[str]: |
| return [str(name) for name in retrieval_plan.get("registered_tool_names", []) if name] |
|
|
|
|
| def stored_selected_tool_count(retrieval_plan: dict[str, Any]) -> int | None: |
| count = retrieval_plan.get("selected_tool_count") |
| return count if isinstance(count, int) else None |
|
|
|
|
| def retrieval_metadata_available(retrieval_plan: dict[str, Any]) -> bool: |
| if selected_tool_names(retrieval_plan): |
| return True |
| if registered_tool_names(retrieval_plan): |
| return True |
| planning_context = retrieval_plan.get("planning_context_text") |
| if isinstance(planning_context, str) and planning_context.strip(): |
| return True |
| query_context = retrieval_plan.get("query_context") |
| if isinstance(query_context, dict) and query_context: |
| return True |
| return False |
|
|
|
|
| def retrieval_candidates_from_plan(retrieval_plan: dict[str, Any]) -> list[str]: |
| candidates: list[str] = [] |
| candidates.extend(selected_tool_names(retrieval_plan)) |
| candidates.extend(registered_tool_names(retrieval_plan)) |
| planning_context = retrieval_plan.get("planning_context_text") |
| if isinstance(planning_context, str) and planning_context.strip(): |
| candidates.extend(extract_names(planning_context)) |
| query_context = retrieval_plan.get("query_context") |
| if isinstance(query_context, dict) and query_context: |
| candidates.extend(extract_names(query_context)) |
| return sorted(dict.fromkeys(name for name in candidates if name)) |
|
|
|
|
| def planning_context_fallback(retrieval_plan: dict[str, Any], metadata: dict[str, Any], run_dir: Path) -> str: |
| planning_context_text = retrieval_plan.get("planning_context_text") |
| if isinstance(planning_context_text, str) and planning_context_text.strip(): |
| return planning_context_text |
|
|
| query_context = retrieval_plan.get("query_context") |
| if isinstance(query_context, dict) and query_context: |
| return json.dumps(query_context, ensure_ascii=False, default=str) |
|
|
| query = retrieval_plan.get("query") |
| if isinstance(query, str) and query.strip(): |
| return query |
|
|
| metadata_query = metadata.get("query") if isinstance(metadata, dict) else None |
| if isinstance(metadata_query, str) and metadata_query.strip(): |
| return metadata_query |
|
|
| task_query = load_text(run_dir / "task_query.txt").strip() |
| if task_query: |
| return task_query |
|
|
| return "" |
|
|
|
|
| def non_human_log_text(execution_log: dict[str, Any], execution_text_path: Path, final_text: str) -> str: |
| entries = execution_log.get("log_entries", []) if isinstance(execution_log, dict) else [] |
| filtered = [] |
| for entry in entries: |
| text = str(entry) |
| if "================================ Human Message" in text: |
| continue |
| filtered.append(text) |
| if filtered: |
| return "\n\n".join(filtered) + "\n" + final_text |
| if execution_text_path.exists(): |
| return execution_text_path.read_text(encoding="utf-8", errors="ignore") + "\n" + final_text |
| return final_text |
|
|
|
|
| def matched_tool_names(text: str, candidate_names: list[str]) -> list[str]: |
| haystack = normalize(text) |
| matched = [] |
| for name in candidate_names: |
| token = normalize(name) |
| if token and token in haystack: |
| matched.append(name) |
| return sorted(set(matched)) |
|
|
|
|
| def execution_attempt_count(text: str, executed_tools: list[str]) -> int: |
| counts = [ |
| len(re.findall(r"<execute>", text, flags=re.IGNORECASE)), |
| len(re.findall(r"command_executed", text, flags=re.IGNORECASE)), |
| len(executed_tools), |
| ] |
| return max(1, *counts) |
|
|
|
|
| def pattern_rate(text: str, patterns: list[str], denominator: int) -> tuple[float, int]: |
| count = 0 |
| for pattern in patterns: |
| count += len(re.findall(pattern, text, flags=re.IGNORECASE)) |
| return min(1.0, count / max(1, denominator)), count |
|
|
|
|
| def biological_constraint_rate(text: str, metadata: dict[str, Any], denominator: int) -> tuple[float, int]: |
| count = 0 |
| for pattern in CONSTRAINT_VIOLATION_PATTERNS: |
| count += len(re.findall(pattern, text, flags=re.IGNORECASE)) |
|
|
| task_id = metadata.get("task_id", "") if isinstance(metadata, dict) else "" |
| if task_id: |
| sibling_pattern = rf"/225040511/project/bioagent-bench/dataset/(?!{re.escape(task_id)})([a-zA-Z0-9_.-]+)" |
| count += len(re.findall(sibling_pattern, text)) |
| count += len(re.findall(r"/225040511/project/bioagent-bench/dataset/.+?/results", text)) |
| return min(1.0, count / max(1, denominator)), count |
|
|
|
|
| def summarize_run(run_dir: Path, gold: dict[str, Any], ks: list[int], primary_k: int) -> dict[str, Any]: |
| metadata = load_json(run_dir / "run_metadata.json", {}) |
| task_id = metadata.get("task_id") if isinstance(metadata, dict) else None |
| if not task_id: |
| task_id = re.sub(r"_20\d{6}_\d{6}$", "", run_dir.name) |
| gold_entry = gold.get(task_id, {}) |
|
|
| retrieval_plan = load_json(run_dir / "retrieval_plan.json", {}) |
| execution_log = load_json(run_dir / "execution_log.json", {}) |
| execution_text_path = run_dir / "execution_log.txt" |
| final_text = (run_dir / "final_answer.txt").read_text(encoding="utf-8", errors="ignore") if (run_dir / "final_answer.txt").exists() else "" |
|
|
| retrieval_candidates = retrieval_candidates_from_plan(retrieval_plan) |
| retrieval_available = retrieval_metadata_available(retrieval_plan) |
| filtered_execution_text = non_human_log_text(execution_log, execution_text_path, final_text) |
| execution_evidence = extract_names(execution_log) + re.findall( |
| r"\b[a-zA-Z][a-zA-Z0-9_.-]{2,}\b", |
| filtered_execution_text, |
| ) |
|
|
| gold_tools = gold_entry.get("gold_tools", []) |
| gold_servers = gold_entry.get("gold_servers", []) |
| all_gold = [*gold_tools, *gold_servers] |
| recall = { |
| str(k): (recall_at_k(retrieval_candidates, all_gold, k) if retrieval_available else None) |
| for k in ks |
| } |
|
|
| planning_context_text = planning_context_fallback(retrieval_plan, metadata, run_dir) |
|
|
| timing = metadata.get("timing", {}) if isinstance(metadata, dict) else {} |
| metrics = metadata.get("metrics", {}) if isinstance(metadata, dict) else {} |
| planning_latency = ( |
| retrieval_plan.get("planning_latency_seconds") |
| or timing.get("planning_latency_seconds") |
| or metrics.get("planning_latency") |
| or metrics.get("plan_seconds") |
| ) |
|
|
| mcp_config_path = retrieval_plan.get("mcp_config") or metadata.get("mcp_config") |
| mcp_tool_universe = load_mcp_tool_universe(str(mcp_config_path)) if mcp_config_path else [] |
| selected_tools = selected_tool_names(retrieval_plan) |
| registered_tools = registered_tool_names(retrieval_plan) |
| retrieved_tool_candidates = set(selected_tools or registered_tools) |
| executed_mcp_tools = matched_tool_names(filtered_execution_text, mcp_tool_universe) if mcp_tool_universe else [] |
| hallucinated_tools = sorted(set(executed_mcp_tools) - retrieved_tool_candidates) |
| hallucinated_tool_rate = ( |
| len(hallucinated_tools) / len(executed_mcp_tools) |
| if executed_mcp_tools and retrieved_tool_candidates |
| else None |
| ) |
|
|
| attempts = execution_attempt_count(filtered_execution_text, executed_mcp_tools) |
| data_type_mismatch_rate, data_type_mismatch_events = pattern_rate(filtered_execution_text, TYPE_MISMATCH_PATTERNS, attempts) |
| biological_constraint_error_rate, biological_constraint_events = biological_constraint_rate( |
| filtered_execution_text, |
| metadata if isinstance(metadata, dict) else {}, |
| attempts, |
| ) |
|
|
| workflow = workflow_validity(execution_evidence, gold_entry.get("workflow", [])) |
| execution_ok = execution_success(run_dir) |
|
|
| return { |
| "task_id": task_id, |
| "run_dir": str(run_dir), |
| "scale_label": run_dir.parent.name, |
| "retrieval_metadata_available": retrieval_available, |
| "retrieval_recall_at_k": recall, |
| "retrieval_recall_at_primary_k": recall.get(str(primary_k)), |
| "retrieval_recall_k": primary_k, |
| "tool_selection_accuracy": gold_hit(execution_evidence, all_gold), |
| "workflow_validity": workflow, |
| "execution_success": execution_ok, |
| "context_tokens_rough": rough_token_count(planning_context_text), |
| "planning_latency_seconds": planning_latency, |
| "retrieved_candidate_count": len(retrieval_candidates), |
| "execution_evidence_count": len(execution_evidence), |
| "selected_tool_count": len(selected_tools) if selected_tools else stored_selected_tool_count(retrieval_plan) or 0, |
| "registered_tool_count": len(registered_tools), |
| "executed_mcp_tools": executed_mcp_tools, |
| "hallucinated_tools": hallucinated_tools, |
| "hallucinated_tool_rate": hallucinated_tool_rate, |
| "data_type_mismatch_rate": data_type_mismatch_rate, |
| "data_type_mismatch_events": data_type_mismatch_events, |
| "biological_constraint_error_rate": biological_constraint_error_rate, |
| "biological_constraint_error_events": biological_constraint_events, |
| "execution_attempts_estimate": attempts, |
| } |
|
|
|
|
| def aggregate_results(results: list[dict[str, Any]], ks: list[int], primary_k: int) -> dict[str, Any]: |
| aggregate: dict[str, Any] = {"run_count": len(results), "retrieval_recall_k": primary_k} |
| if not results: |
| return aggregate |
|
|
| for k in ks: |
| vals = [r["retrieval_recall_at_k"][str(k)] for r in results] |
| aggregate[f"mean_retrieval_recall@{k}"] = mean_or_none(vals) |
| aggregate["tool_selection_accuracy"] = sum(r["tool_selection_accuracy"] for r in results) / len(results) |
| aggregate["workflow_validity"] = sum(r["workflow_validity"]["valid"] for r in results) / len(results) |
| aggregate["execution_success_rate"] = sum(r["execution_success"] for r in results) / len(results) |
| aggregate["mean_context_tokens_rough"] = sum(r["context_tokens_rough"] for r in results) / len(results) |
| latencies = [r["planning_latency_seconds"] for r in results if isinstance(r["planning_latency_seconds"], (int, float))] |
| aggregate["mean_planning_latency_seconds"] = sum(latencies) / len(latencies) if latencies else None |
| aggregate["retrieval_metadata_coverage"] = ( |
| sum(r["retrieval_metadata_available"] for r in results) / len(results) |
| ) |
| aggregate["mean_hallucinated_tool_rate"] = mean_or_none([r["hallucinated_tool_rate"] for r in results]) |
| aggregate["mean_data_type_mismatch_rate"] = mean_or_none([r["data_type_mismatch_rate"] for r in results]) |
| aggregate["mean_biological_constraint_error_rate"] = mean_or_none( |
| [r["biological_constraint_error_rate"] for r in results] |
| ) |
|
|
| aggregate["Retrieval Recall@k"] = aggregate.get(f"mean_retrieval_recall@{primary_k}") |
| aggregate["Workflow Validity"] = aggregate["workflow_validity"] |
| aggregate["Execution Success Rate"] = aggregate["execution_success_rate"] |
| aggregate["Context Tokens"] = aggregate["mean_context_tokens_rough"] |
| aggregate["Planning Latency"] = aggregate["mean_planning_latency_seconds"] |
| aggregate["Hallucinated Tool Rate"] = aggregate["mean_hallucinated_tool_rate"] |
| aggregate["Data-Type Mismatch Rate"] = aggregate["mean_data_type_mismatch_rate"] |
| aggregate["Biological Constraint Error Rate"] = aggregate["mean_biological_constraint_error_rate"] |
| return aggregate |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--runs-root", type=Path, required=True, help="Directory containing tier/task run dirs.") |
| parser.add_argument( |
| "--dataset-root", |
| type=Path, |
| default=DEFAULT_DATASET_ROOT, |
| help="Benchmark dataset root used to enumerate task IDs for per-task outputs.", |
| ) |
| parser.add_argument("--gold", type=Path, default=DEFAULT_GOLD) |
| parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) |
| parser.add_argument("--out", type=Path, default=DEFAULT_EXP_DIR / "results" / "metrics_summary.json") |
| parser.add_argument( |
| "--per-task-dir", |
| type=Path, |
| default=DEFAULT_EXP_DIR / "results" / "by_task", |
| help="Directory where one metrics JSON per task is written.", |
| ) |
| parser.add_argument("--k", type=int, nargs="+", default=[1, 3, 5, 10, 20, 50]) |
| parser.add_argument("--primary-k", type=int, default=DEFAULT_PRIMARY_K) |
| args = parser.parse_args() |
|
|
| started = time.time() |
| gold = load_json(args.gold, {}) |
| manifest = load_json(args.manifest, {}) |
| task_ids = discover_task_ids(args.dataset_root) |
| args.per_task_dir.mkdir(parents=True, exist_ok=True) |
|
|
| results: list[dict[str, Any]] = [] |
| task_index = [] |
| for task_id in task_ids: |
| task_run_dirs = collect_task_runs(args.runs_root, task_id) |
| task_results = [summarize_run(run_dir, gold, args.k, args.primary_k) for run_dir in task_run_dirs] |
| results.extend(task_results) |
| task_payload = { |
| "evaluated_at_unix": started, |
| "task_id": task_id, |
| "runs_root": str(args.runs_root), |
| "dataset_root": str(args.dataset_root), |
| "manifest": manifest, |
| "aggregate": aggregate_results(task_results, args.k, args.primary_k), |
| "results": task_results, |
| } |
| task_out = args.per_task_dir / f"{task_id}.json" |
| task_out.write_text(json.dumps(task_payload, indent=2, ensure_ascii=False), encoding="utf-8") |
| task_index.append({"task_id": task_id, "run_count": len(task_results), "output": str(task_out)}) |
|
|
| aggregate = aggregate_results(results, args.k, args.primary_k) |
| payload = { |
| "evaluated_at_unix": started, |
| "runs_root": str(args.runs_root), |
| "dataset_root": str(args.dataset_root), |
| "manifest": manifest, |
| "tasks": task_index, |
| "aggregate": aggregate, |
| "results": results, |
| } |
| args.out.parent.mkdir(parents=True, exist_ok=True) |
| args.out.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") |
| print(json.dumps(payload["aggregate"], indent=2, ensure_ascii=False)) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|