czty's picture
Add files using upload-large-folder tool
9473841 verified
Raw
History Blame Contribute Delete
21.6 kB
#!/usr/bin/env python3
"""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())