| |
| """Evaluate Hypo_Bio_OS BioAgent Bench runs. |
| |
| This evaluator follows the BioAgent Bench paper's grader design: |
| evaluate each trial as a pipeline execution, prioritize demonstrable |
| pipeline completion over exact numeric agreement, and return an |
| EvaluationResults-style JSON object. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import csv |
| import gzip |
| import json |
| import os |
| import re |
| from bisect import bisect_right |
| from datetime import timezone, datetime |
| from difflib import SequenceMatcher |
| from pathlib import Path |
| from statistics import mean |
| from typing import Any |
|
|
|
|
| DATASET_ROOT = Path("/225040511/project/bioagent-bench/dataset") |
| METADATA_PATH = Path("/225040511/project/bioagent-bench/src/task_metadata.json") |
| DEFAULT_RUNS_ROOT = Path("/225040511/project/Hypo_Bio_OS/bioagent-bench-runs") |
|
|
|
|
| FLEXIBLE_TABLE_MATCH_CONFIGS: dict[tuple[str, str], dict[str, Any]] = { |
| ( |
| "alzheimer-mouse", |
| "pathway_comparison.csv", |
| ): { |
| "soft_key_columns": ["Pathway"], |
| "threshold": 0.62, |
| "text_weight": 0.9, |
| "numeric_weight": 0.1, |
| }, |
| ( |
| "comparative-genomics", |
| "cluster_annotation_mapping.csv", |
| ): { |
| "soft_key_columns": ["consensus_annotation"], |
| "threshold": 0.68, |
| }, |
| ( |
| "metagenomics", |
| "phylum_relative_abundances.csv", |
| ): { |
| "soft_key_columns": ["Phylum"], |
| "threshold": 0.9, |
| "text_weight": 1.0, |
| "numeric_weight": 0.0, |
| }, |
| ( |
| "single-cell", |
| "all_clusters_de_genes.csv", |
| ): { |
| "soft_key_columns": ["gene_name", "cluster_id"], |
| "threshold": 0.78, |
| "text_weight": 0.85, |
| "numeric_weight": 0.15, |
| }, |
| ( |
| "viral-metagenomics", |
| "taxonomy.csv", |
| ): { |
| "soft_key_columns": ["domain", "species"], |
| "threshold": 0.72, |
| "text_weight": 0.85, |
| "numeric_weight": 0.15, |
| }, |
| } |
|
|
|
|
| TASK_CONFIGS: dict[str, dict[str, Any]] = { |
| "alzheimer-mouse": { |
| "truth_files": ["pathway_comparison.csv"], |
| "result_files": ["pathway_comparison.csv"], |
| "key_columns": ["Pathway"], |
| "numeric_columns": ["5xFAD_pvalue", "3xTG_AD_pvalue", "PS3O1S_pvalue"], |
| "pipeline_steps": [ |
| "inspect mouse count/DEA inputs", |
| "prepare metadata and count matrices for 5xFAD and 3xTG-AD", |
| "perform differential expression for 5xFAD", |
| "perform differential expression for 3xTG-AD", |
| "use provided PS3O1S differential expression results", |
| "run pathway enrichment per model", |
| "merge shared/comparative pathway p-values into final CSV", |
| ], |
| "results_match_guidance": ( |
| "Treat mouse mmu/Mus musculus pathway labels as semantically compatible with hsa/Homo sapiens labels " |
| "when the pathway identity is the same. Do not require exact p-value equality if the pipeline is plausible." |
| ), |
| }, |
| "comparative-genomics": { |
| "truth_files": ["cluster_annotation_mapping.csv"], |
| "result_files": ["cluster_annotation_mapping.csv"], |
| "key_columns": ["cluster_number", "consensus_annotation"], |
| "numeric_columns": [], |
| "pipeline_steps": [ |
| "inspect Micrococcus FASTA/GFF/reference inputs", |
| "predict or extract protein-coding genes", |
| "identify orthologous/co-evolving clusters across genomes", |
| "filter clusters present across intended genomes and coding-only", |
| "assign high-confidence consensus annotations", |
| "write cluster_number,consensus_annotation CSV", |
| ], |
| "results_match_guidance": ( |
| "Prioritize whether the output represents conserved annotated coding clusters. Exact cluster numbering " |
| "can vary by method, but placeholder annotations or unrelated organisms should not match." |
| ), |
| }, |
| "cystic-fibrosis": { |
| "truth_files": ["cf_variants.csv"], |
| "result_files": ["cf_variants.csv"], |
| "key_columns": ["chromosome", "position", "reference", "alternate"], |
| "numeric_columns": [], |
| "verifiable": True, |
| "pipeline_steps": [ |
| "inspect family description and variant VCF", |
| "filter variants by recessive inheritance in affected siblings", |
| "exclude variants inconsistent with unaffected relatives/parents", |
| "annotate candidate variant with ClinVar/reference metadata", |
| "write the requested causal-variant CSV schema", |
| ], |
| "results_match_guidance": ( |
| "The result should identify the CFTR pathogenic recessive variant. Exact textual disease lists may differ, " |
| "but chromosome, position, ref/alt, gene, and clinical interpretation must be consistent." |
| ), |
| }, |
| "deseq": { |
| "truth_files": ["up_regulated_genes.csv"], |
| "result_files": ["up_regulated_genes.csv"], |
| "key_columns": ["gene_id"], |
| "numeric_columns": ["log2FoldChange", "pvalue", "padj"], |
| "pipeline_steps": [ |
| "inspect RNA-seq reads and Candida reference files", |
| "prepare genome annotation/index", |
| "align reads or otherwise quantify genes", |
| "count reads per gene", |
| "construct biofilm/planktonic sample metadata", |
| "run differential expression", |
| "filter up-regulated significant genes and write final CSV", |
| ], |
| "results_match_guidance": ( |
| "Prioritize evidence of a complete RNA-seq DE pipeline and a plausible up-regulated gene table. " |
| "Do not require exact equality for all p-values/log fold changes." |
| ), |
| }, |
| "evolution": { |
| "truth_files": ["variants_shared.csv", "gene_annotations.csv"], |
| "result_files": ["variants_shared.csv", "gene_annotations.csv"], |
| "key_columns": { |
| "variants_shared.csv": ["CHROM", "POS", "REF", "ALT"], |
| "gene_annotations.csv": ["Gene_Name"], |
| }, |
| "numeric_columns": {"variants_shared.csv": [], "gene_annotations.csv": []}, |
| "pipeline_steps": [ |
| "inspect ancestor/evolved-line reads", |
| "prepare or identify valid E. coli reference/assembly", |
| "align ancestor and evolved reads", |
| "call variants for all samples", |
| "identify variants shared by evolved lines and absent from ancestor", |
| "annotate variant/gene effects", |
| "write variants_shared.csv and gene_annotations.csv", |
| ], |
| "results_match_guidance": ( |
| "Reward a biologically coherent shared-variant workflow. Penalize using forbidden external/sibling references " |
| "or hallucinated annotations even if the final schema exists." |
| ), |
| }, |
| "giab": { |
| "truth_files": ["HG001_GRCh38_1_22_v4.2.1_benchmark.vcf.gz"], |
| "result_files": ["predicted.vcf.gz"], |
| "vcf": True, |
| "verifiable": True, |
| "pipeline_steps": [ |
| "inspect paired reads, BED targets, and GRCh38 reference", |
| "align reads to reference", |
| "sort/index BAM", |
| "mark duplicates or prepare analysis-ready BAM", |
| "call variants", |
| "compress/index final VCF if needed", |
| "write predicted.vcf.gz", |
| ], |
| "results_match_guidance": ( |
| "Use GIAB variant concordance as the correctness signal. The f1_score field should reflect variant-level " |
| "overlap where available." |
| ), |
| }, |
| "metagenomics": { |
| "truth_files": ["phylum_relative_abundances.csv"], |
| "result_files": ["phylum_relative_abundances.csv"], |
| "key_columns": ["OTU"], |
| "numeric_columns": ["JP4D", "JC1A"], |
| "pipeline_steps": [ |
| "inspect paired metagenomic reads and reference database", |
| "classify reads taxonomically", |
| "aggregate classifications at bacterial phylum level", |
| "normalize relative abundances for JP4D and JC1A", |
| "write OTU,Kingdom,Phylum,JP4D,JC1A CSV", |
| ], |
| "results_match_guidance": ( |
| "Prioritize correct use of the bacterial/metagenomic reference and plausible phylum-level abundance table. " |
| "Small abundance differences are acceptable." |
| ), |
| }, |
| "single-cell": { |
| "truth_files": ["all_clusters_de_genes.csv"], |
| "result_files": ["all_clusters_de_genes.csv"], |
| "key_columns": ["cluster_id", "gene_name"], |
| "numeric_columns": ["logfoldchanges", "pvals", "pvals_adj", "abs_logfc"], |
| "pipeline_steps": [ |
| "load 10X matrices and metadata", |
| "perform QC/normalization", |
| "cluster cells and preserve cluster IDs", |
| "annotate cell types using marker evidence", |
| "compare pre/post exercise within cell types or clusters", |
| "write all significant DE genes in requested schema", |
| ], |
| "results_match_guidance": ( |
| "Cell-type labels and cluster identities may vary, so prioritize marker-supported annotation, DE evidence, " |
| "and schema correctness over exact row equality." |
| ), |
| }, |
| "transcript-quant": { |
| "truth_files": ["truth.tsv"], |
| "result_files": ["truth.tsv"], |
| "tsv_no_header": True, |
| "key_columns": ["transcript_id"], |
| "numeric_columns": ["count"], |
| "verifiable": True, |
| "pipeline_steps": [ |
| "inspect paired RNA-seq reads and transcriptome reference", |
| "build transcriptome index", |
| "quantify transcript abundance/counts", |
| "extract transcript_id and count columns", |
| "write no-header two-column TSV", |
| ], |
| "results_match_guidance": ( |
| "Because the data is simulated, counts should closely reproduce the truth. Headerless two-column TSV format " |
| "is required." |
| ), |
| }, |
| "viral-metagenomics": { |
| "truth_files": ["taxonomy.csv"], |
| "result_files": ["taxonomy.csv"], |
| "key_columns": ["domain", "species"], |
| "numeric_columns": ["contig_count"], |
| "verifiable": True, |
| "pipeline_steps": [ |
| "inspect dolphin metagenomic reads and viral reference resources", |
| "assemble reads into contigs", |
| "classify contigs against compatible viral reference resources", |
| "aggregate contig counts by domain/species", |
| "write contig_count,domain,species CSV", |
| ], |
| "results_match_guidance": ( |
| "Prioritize using this task's viral reference resources and correctly identifying viral species. " |
| "Unclassified contigs are allowed when supported by classification output." |
| ), |
| }, |
| } |
|
|
|
|
| def utc_timestamp() -> str: |
| return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") |
|
|
|
|
| def normalize_text(value: Any) -> str: |
| return " ".join(str(value).strip().split()).lower() |
|
|
|
|
| def normalize_for_soft_match(value: Any) -> str: |
| text = normalize_text(value) |
| text = re.sub(r"\b(homo sapiens|mus musculus|human|mouse)\b", " ", text) |
| text = re.sub(r"\b(hsa|mmu)(?=\d)", " ", text) |
| text = re.sub(r"[^a-z0-9]+", " ", text) |
| return " ".join(text.split()) |
|
|
|
|
| def normalize_chrom(value: Any) -> str: |
| chrom = normalize_text(value) |
| if chrom.startswith("chr"): |
| chrom = chrom[3:] |
| return {"m": "mt", "mitochondria": "mt"}.get(chrom, chrom) |
|
|
|
|
| def maybe_float(value: Any) -> float | None: |
| try: |
| return float(str(value).strip()) |
| except Exception: |
| return None |
|
|
|
|
| def get_row_value(row: dict, column: str, default: str = ""): |
| if column in row: |
| return row.get(column, default) |
| target = normalize_text(column) |
| for key, value in row.items(): |
| if normalize_text(key) == target: |
| return value |
| return default |
|
|
|
|
| def token_similarity(left: str, right: str) -> float: |
| left_tokens = set(left.split()) |
| right_tokens = set(right.split()) |
| if not left_tokens and not right_tokens: |
| return 1.0 |
| if not left_tokens or not right_tokens: |
| return 0.0 |
| return len(left_tokens & right_tokens) / len(left_tokens | right_tokens) |
|
|
|
|
| def text_similarity(left: Any, right: Any) -> float: |
| left_norm = normalize_for_soft_match(left) |
| right_norm = normalize_for_soft_match(right) |
| if left_norm == right_norm: |
| return 1.0 |
| if not left_norm or not right_norm: |
| return 0.0 |
| sequence_score = SequenceMatcher(None, left_norm, right_norm).ratio() |
| token_score = token_similarity(left_norm, right_norm) |
| return max(sequence_score, token_score) |
|
|
|
|
| def numeric_similarity(left: Any, right: Any, rel_scale: float = 0.1) -> float | None: |
| left_value = maybe_float(left) |
| right_value = maybe_float(right) |
| if left_value is None or right_value is None: |
| return None |
| if left_value == right_value: |
| return 1.0 |
| scale = max(abs(left_value), abs(right_value), 1e-12) |
| relative_error = abs(left_value - right_value) / scale |
| return max(0.0, 1.0 - (relative_error / rel_scale)) |
|
|
|
|
| def load_task_metadata(metadata_path: Path) -> dict[str, dict]: |
| if not metadata_path.exists(): |
| return {} |
| payload = json.loads(metadata_path.read_text(encoding="utf-8")) |
| return {item["task_id"]: item for item in payload} |
|
|
|
|
| def load_run_metadata(run_dir: Path) -> dict: |
| path = run_dir / "run_metadata.json" |
| if not path.exists(): |
| return {} |
| try: |
| return json.loads(path.read_text(encoding="utf-8")) |
| except json.JSONDecodeError: |
| return {} |
|
|
|
|
| def load_csv_rows(path: Path) -> list[dict]: |
| with path.open("r", encoding="utf-8", errors="ignore", newline="") as handle: |
| return list(csv.DictReader(handle)) |
|
|
|
|
| def load_tsv_no_header(path: Path) -> list[dict]: |
| rows = [] |
| with path.open("r", encoding="utf-8", errors="ignore") as handle: |
| for line in handle: |
| line = line.rstrip("\n") |
| if not line: |
| continue |
| parts = line.split("\t") |
| if len(parts) >= 2: |
| rows.append({"transcript_id": parts[0], "count": parts[1]}) |
| return rows |
|
|
|
|
| def read_text_preview(path: Path, max_lines: int = 80, max_chars: int = 24000) -> str: |
| opener = gzip.open if path.suffix == ".gz" else open |
| preview_lines = [] |
| total_chars = 0 |
| with opener(path, "rt", encoding="utf-8", errors="ignore") as handle: |
| for i, line in enumerate(handle): |
| if i >= max_lines: |
| preview_lines.append("... [truncated]") |
| break |
| preview_lines.append(line.rstrip("\n")) |
| total_chars += len(line) |
| if total_chars >= max_chars: |
| preview_lines.append("... [truncated]") |
| break |
| return "\n".join(preview_lines) |
|
|
|
|
| def load_prediction_rows(task_id: str, file_name: str, prediction_path: Path) -> list[dict]: |
| if TASK_CONFIGS[task_id].get("tsv_no_header"): |
| return load_tsv_no_header(prediction_path) |
| return load_csv_rows(prediction_path) |
|
|
|
|
| def open_text_auto(path: Path): |
| return gzip.open(path, "rt", encoding="utf-8", errors="ignore") if path.suffix == ".gz" else path.open( |
| "r", encoding="utf-8", errors="ignore" |
| ) |
|
|
|
|
| def parse_bed_intervals(path: Path) -> dict[str, list[tuple[int, int]]]: |
| intervals: dict[str, list[tuple[int, int]]] = {} |
| if not path.exists(): |
| return intervals |
| with open_text_auto(path) as handle: |
| for line in handle: |
| if not line.strip() or line.startswith(("#", "track", "browser")): |
| continue |
| fields = line.rstrip("\n").split("\t") |
| if len(fields) < 3: |
| continue |
| try: |
| start = int(fields[1]) |
| end = int(fields[2]) |
| except ValueError: |
| continue |
| chrom = normalize_chrom(fields[0]) |
| intervals.setdefault(chrom, []).append((start, end)) |
| for chrom in intervals: |
| intervals[chrom].sort() |
| return intervals |
|
|
|
|
| def position_in_intervals(chrom: str, pos: str, intervals: dict[str, list[tuple[int, int]]] | None) -> bool: |
| if not intervals: |
| return True |
| try: |
| pos_1_based = int(pos) |
| except ValueError: |
| return False |
| pos_0_based = pos_1_based - 1 |
| chrom_intervals = intervals.get(normalize_chrom(chrom), []) |
| interval_index = bisect_right(chrom_intervals, (pos_0_based, 10**18)) - 1 |
| if interval_index < 0: |
| return False |
| start, end = chrom_intervals[interval_index] |
| return start <= pos_0_based < end |
|
|
|
|
| def load_vcf_keys(path: Path, intervals: dict[str, list[tuple[int, int]]] | None = None) -> set[tuple[str, str, str, str]]: |
| keys = set() |
| with open_text_auto(path) as handle: |
| for line in handle: |
| if not line or line.startswith("#"): |
| continue |
| fields = line.rstrip("\n").split("\t") |
| if len(fields) < 5: |
| continue |
| chrom, pos, _vid, ref, alt = fields[:5] |
| if not position_in_intervals(chrom, pos, intervals): |
| continue |
| for alt_item in alt.split(","): |
| keys.add((normalize_chrom(chrom), normalize_text(pos), normalize_text(ref), normalize_text(alt_item))) |
| return keys |
|
|
|
|
| def f1_from_counts(tp: int, pred_count: int, truth_count: int) -> dict[str, float]: |
| precision = tp / pred_count if pred_count else 0.0 |
| recall = tp / truth_count if truth_count else 0.0 |
| f1 = 0.0 if precision + recall == 0 else (2 * precision * recall) / (precision + recall) |
| return {"precision": precision, "recall": recall, "f1": f1} |
|
|
|
|
| def compare_variant_sets(pred_keys: set[tuple[str, str, str, str]], truth_keys: set[tuple[str, str, str, str]]) -> dict: |
| tp = len(pred_keys & truth_keys) |
| metrics = f1_from_counts(tp, len(pred_keys), len(truth_keys)) |
| return { |
| "pred_variant_count": len(pred_keys), |
| "truth_variant_count": len(truth_keys), |
| "shared_variant_count": tp, |
| "precision": metrics["precision"], |
| "recall": metrics["recall"], |
| "f1": metrics["f1"], |
| } |
|
|
|
|
| def compare_vcf(prediction_path: Path, truth_path: Path, task_id: str, dataset_root: Path) -> dict: |
| bed_candidates = [ |
| dataset_root / task_id / "data" / "Agilent_v7.chr.bed", |
| dataset_root / task_id / "results" / "HG001_GRCh38_1_22_v4.2.1_benchmark.bed", |
| ] |
| bed_path = next((path for path in bed_candidates if path.exists()), None) |
| if not bed_path: |
| unfiltered_pred_keys = load_vcf_keys(prediction_path) |
| unfiltered_truth_keys = load_vcf_keys(truth_path) |
| unfiltered = compare_variant_sets(unfiltered_pred_keys, unfiltered_truth_keys) |
| return { |
| **unfiltered, |
| "truth_scope": "all_truth_variants", |
| "unfiltered_f1": unfiltered["f1"], |
| "note": "Approximate VCF comparison by normalized CHROM,POS,REF,ALT; no BED scope was available.", |
| } |
|
|
| intervals = parse_bed_intervals(bed_path) |
| scoped_pred_keys = load_vcf_keys(prediction_path, intervals=intervals) |
| scoped_truth_keys = load_vcf_keys(truth_path, intervals=intervals) |
| scoped = compare_variant_sets(scoped_pred_keys, scoped_truth_keys) |
| return { |
| **scoped, |
| "truth_scope": "target_bed", |
| "target_bed": str(bed_path), |
| "unfiltered_f1": None, |
| "note": "Primary F1 is restricted to the task target BED and uses normalized CHROM,POS,REF,ALT.", |
| } |
|
|
|
|
| def row_similarity( |
| pred: dict, |
| truth: dict, |
| soft_key_columns: list[str], |
| numeric_columns: list[str], |
| text_weight: float, |
| numeric_weight: float, |
| ) -> float: |
| text_scores = [ |
| text_similarity(get_row_value(pred, column, ""), get_row_value(truth, column, "")) |
| for column in soft_key_columns |
| ] |
| text_score = sum(text_scores) / len(text_scores) if text_scores else 0.0 |
|
|
| numeric_scores = [ |
| score |
| for column in numeric_columns |
| if (score := numeric_similarity(get_row_value(pred, column, ""), get_row_value(truth, column, ""))) is not None |
| ] |
| if not numeric_scores or numeric_weight <= 0: |
| return text_score |
| numeric_score = sum(numeric_scores) / len(numeric_scores) |
| total_weight = text_weight + numeric_weight |
| return ((text_score * text_weight) + (numeric_score * numeric_weight)) / total_weight |
|
|
|
|
| def greedy_soft_row_match( |
| pred_rows: list[dict], |
| truth_rows: list[dict], |
| soft_key_columns: list[str], |
| numeric_columns: list[str], |
| threshold: float, |
| text_weight: float, |
| numeric_weight: float, |
| ) -> tuple[list[tuple[int, int, float]], list[int], list[int]]: |
| candidates = [] |
| first_soft_column = soft_key_columns[0] if soft_key_columns else None |
| truth_buckets: dict[str, list[tuple[int, dict]]] = {} |
| use_bucketed_candidates = first_soft_column is not None and len(pred_rows) * len(truth_rows) > 200_000 |
| if use_bucketed_candidates: |
| for truth_index, truth in enumerate(truth_rows): |
| bucket_key = normalize_for_soft_match(get_row_value(truth, first_soft_column, "")) |
| truth_buckets.setdefault(bucket_key, []).append((truth_index, truth)) |
|
|
| for pred_index, pred in enumerate(pred_rows): |
| if use_bucketed_candidates: |
| bucket_key = normalize_for_soft_match(get_row_value(pred, first_soft_column, "")) |
| candidate_truth_rows = truth_buckets.get(bucket_key, []) |
| else: |
| candidate_truth_rows = list(enumerate(truth_rows)) |
| for truth_index, truth in candidate_truth_rows: |
| score = row_similarity(pred, truth, soft_key_columns, numeric_columns, text_weight, numeric_weight) |
| if score >= threshold: |
| candidates.append((score, pred_index, truth_index)) |
|
|
| candidates.sort(reverse=True) |
| used_pred = set() |
| used_truth = set() |
| matches = [] |
| for score, pred_index, truth_index in candidates: |
| if pred_index in used_pred or truth_index in used_truth: |
| continue |
| used_pred.add(pred_index) |
| used_truth.add(truth_index) |
| matches.append((pred_index, truth_index, score)) |
| unmatched_pred = [index for index in range(len(pred_rows)) if index not in used_pred] |
| unmatched_truth = [index for index in range(len(truth_rows)) if index not in used_truth] |
| return matches, unmatched_pred, unmatched_truth |
|
|
|
|
| def compare_table_rows(task_id: str, file_name: str, pred_rows: list[dict], truth_rows: list[dict]) -> dict: |
| config = TASK_CONFIGS[task_id] |
| key_columns = config["key_columns"][file_name] if isinstance(config["key_columns"], dict) else config["key_columns"] |
| numeric_columns = ( |
| config["numeric_columns"][file_name] if isinstance(config["numeric_columns"], dict) else config["numeric_columns"] |
| ) |
|
|
| pred_map = { |
| tuple(normalize_text(get_row_value(row, col, "")) for col in key_columns): row |
| for row in pred_rows |
| } |
| truth_map = { |
| tuple(normalize_text(get_row_value(row, col, "")) for col in key_columns): row |
| for row in truth_rows |
| } |
|
|
| pred_keys = set(pred_map) |
| truth_keys = set(truth_map) |
| shared_keys = pred_keys & truth_keys |
| metrics = f1_from_counts(len(shared_keys), len(pred_keys), len(truth_keys)) |
|
|
| numeric_diffs = {col: [] for col in numeric_columns} |
| for key in shared_keys: |
| pred = pred_map[key] |
| truth = truth_map[key] |
| for col in numeric_columns: |
| pred_value = maybe_float(get_row_value(pred, col, "")) |
| truth_value = maybe_float(get_row_value(truth, col, "")) |
| if pred_value is not None and truth_value is not None: |
| numeric_diffs[col].append(abs(pred_value - truth_value)) |
|
|
| summary = { |
| "pred_row_count": len(pred_rows), |
| "truth_row_count": len(truth_rows), |
| "shared_key_count": len(shared_keys), |
| "exact_key_precision": metrics["precision"], |
| "exact_key_recall": metrics["recall"], |
| "exact_key_f1": metrics["f1"], |
| "key_precision": metrics["precision"], |
| "key_recall": metrics["recall"], |
| "key_f1": metrics["f1"], |
| "match_strategy": "exact_key", |
| "match_precision": metrics["precision"], |
| "match_recall": metrics["recall"], |
| "match_f1": metrics["f1"], |
| } |
|
|
| flexible_config = FLEXIBLE_TABLE_MATCH_CONFIGS.get((task_id, file_name)) |
| if flexible_config: |
| soft_key_columns = flexible_config.get("soft_key_columns", key_columns) |
| threshold = flexible_config.get("threshold", 0.75) |
| text_weight = flexible_config.get("text_weight", 1.0) |
| numeric_weight = flexible_config.get("numeric_weight", 0.0) |
| matches, unmatched_pred, unmatched_truth = greedy_soft_row_match( |
| pred_rows, |
| truth_rows, |
| soft_key_columns, |
| numeric_columns, |
| threshold, |
| text_weight, |
| numeric_weight, |
| ) |
| soft_metrics = f1_from_counts(len(matches), len(pred_rows), len(truth_rows)) |
| summary.update( |
| { |
| "match_strategy": "soft_row_similarity", |
| "soft_key_columns": soft_key_columns, |
| "match_threshold": threshold, |
| "soft_match_count": len(matches), |
| "match_precision": soft_metrics["precision"], |
| "match_recall": soft_metrics["recall"], |
| "match_f1": soft_metrics["f1"], |
| "mean_match_score": mean([score for *_unused, score in matches]) if matches else 0.0, |
| "unmatched_prediction_examples": [ |
| {column: get_row_value(pred_rows[index], column, "") for column in soft_key_columns} |
| for index in unmatched_pred[:5] |
| ], |
| "unmatched_truth_examples": [ |
| {column: get_row_value(truth_rows[index], column, "") for column in soft_key_columns} |
| for index in unmatched_truth[:5] |
| ], |
| } |
| ) |
| if numeric_columns: |
| summary["numeric_mae"] = { |
| col: (sum(values) / len(values) if values else None) for col, values in numeric_diffs.items() |
| } |
| return summary |
|
|
|
|
| def summarize_file_for_judge(task_id: str, path: Path) -> dict: |
| config = TASK_CONFIGS[task_id] |
| if not path.exists(): |
| return {"path": str(path), "exists": False} |
| if config.get("vcf"): |
| return { |
| "path": str(path), |
| "exists": True, |
| "type": "vcf.gz", |
| "size_bytes": path.stat().st_size, |
| "preview": read_text_preview(path, max_lines=60, max_chars=20000), |
| } |
| if path.suffix.lower() == ".tsv" and config.get("tsv_no_header"): |
| rows = load_tsv_no_header(path) |
| return { |
| "path": str(path), |
| "exists": True, |
| "type": "tsv", |
| "row_count": len(rows), |
| "columns": ["transcript_id", "count"], |
| "preview": read_text_preview(path, max_lines=80, max_chars=22000), |
| } |
| rows = load_csv_rows(path) |
| return { |
| "path": str(path), |
| "exists": True, |
| "type": path.suffix.lower().lstrip(".") or "text", |
| "row_count": len(rows), |
| "columns": list(rows[0].keys()) if rows else [], |
| "preview": read_text_preview(path, max_lines=80, max_chars=22000), |
| } |
|
|
|
|
| def locate_prediction_file(task_id: str, run_dir: Path, file_name: str) -> Path | None: |
| candidate = run_dir / file_name |
| if candidate.exists(): |
| return candidate |
| matches = sorted(run_dir.rglob(file_name)) |
| return matches[0] if matches else None |
|
|
|
|
| def infer_latest_run_dir(task_id: str, runs_root: Path) -> Path | None: |
| candidates = sorted(path for path in runs_root.glob(f"{task_id}_*") if path.is_dir()) |
| return candidates[-1] if candidates else None |
|
|
|
|
| def build_processing_tree(run_dir: Path, max_entries: int = 600) -> list[str]: |
| entries = [] |
| for path in sorted(run_dir.rglob("*")): |
| rel = path.relative_to(run_dir) |
| if len(entries) >= max_entries: |
| entries.append("... [truncated]") |
| break |
| if path.is_dir(): |
| entries.append(f"{rel}/") |
| else: |
| entries.append(f"{rel}\t{path.stat().st_size} bytes") |
| return entries |
|
|
|
|
| def collect_trace_path_evidence(run_dir: Path) -> dict: |
| """Collect only folders/file paths from the run, matching the paper's trace input.""" |
| execution_log = run_dir / "execution_log.txt" |
| path_mentions = [] |
| if execution_log.exists(): |
| text = execution_log.read_text(encoding="utf-8", errors="ignore") |
| for token in text.replace('"', " ").replace("'", " ").split(): |
| if token.startswith("/") and ("/bioagent-bench/" in token or "/bioagent-bench-runs/" in token): |
| cleaned = token.rstrip("),.;:<>") |
| if cleaned not in path_mentions: |
| path_mentions.append(cleaned) |
| if len(path_mentions) >= 250: |
| break |
| return { |
| "processing_tree": build_processing_tree(run_dir), |
| "path_mentions_from_trace": path_mentions, |
| } |
|
|
|
|
| def build_artifact_metrics(task_id: str, run_dir: Path, dataset_root: Path) -> list[dict]: |
| config = TASK_CONFIGS[task_id] |
| truth_dir = dataset_root / task_id / "results" |
| artifacts = [] |
| for truth_name, result_name in zip(config["truth_files"], config["result_files"], strict=False): |
| truth_path = truth_dir / truth_name |
| prediction_path = locate_prediction_file(task_id, run_dir, result_name) |
| entry = { |
| "truth_file": str(truth_path), |
| "prediction_file": str(prediction_path) if prediction_path else None, |
| "prediction_exists": bool(prediction_path and prediction_path.exists()), |
| } |
| if not prediction_path or not prediction_path.exists() or not truth_path.exists(): |
| entry["metrics"] = {"error": "missing prediction or truth file"} |
| artifacts.append(entry) |
| continue |
| if config.get("vcf"): |
| entry["metrics"] = compare_vcf(prediction_path, truth_path, task_id, dataset_root) |
| else: |
| pred_rows = load_prediction_rows(task_id, truth_name, prediction_path) |
| truth_rows = load_prediction_rows(task_id, truth_name, truth_path) |
| entry["metrics"] = compare_table_rows(task_id, truth_name, pred_rows, truth_rows) |
| artifacts.append(entry) |
| return artifacts |
|
|
|
|
| def result_artifact_summaries(task_id: str, run_dir: Path, dataset_root: Path) -> tuple[list[dict], list[dict]]: |
| config = TASK_CONFIGS[task_id] |
| truth_dir = dataset_root / task_id / "results" |
| result_summaries = [] |
| truth_summaries = [] |
| for truth_name, result_name in zip(config["truth_files"], config["result_files"], strict=False): |
| prediction_path = locate_prediction_file(task_id, run_dir, result_name) |
| truth_path = truth_dir / truth_name |
| result_summaries.append( |
| summarize_file_for_judge(task_id, prediction_path) if prediction_path else {"path": result_name, "exists": False} |
| ) |
| truth_summaries.append(summarize_file_for_judge(task_id, truth_path)) |
| return result_summaries, truth_summaries |
|
|
|
|
| def infer_rule_steps(task_id: str, artifacts: list[dict], trace_evidence: dict) -> tuple[int, int, list[str]]: |
| config = TASK_CONFIGS[task_id] |
| expected_steps = config["pipeline_steps"] |
| total = len(expected_steps) |
| haystack = "\n".join(trace_evidence["processing_tree"] + trace_evidence["path_mentions_from_trace"]).lower() |
| completed = 0 |
| evidence = [] |
| keyword_sets = { |
| "inspect": ["task_query", "run_metadata", "data"], |
| "metadata": ["metadata", "counts"], |
| "differential": ["deseq", "de_results", "differential", "log2fold"], |
| "enrichment": ["kegg", "enrichment", "pathway"], |
| "protein": ["prodigal", ".faa", "protein"], |
| "ortholog": ["orthofinder", "mmseqs", "diamond", "cluster"], |
| "variant": [".vcf", "bcftools", "variant"], |
| "align": [".bam", ".sam", "bwa", "star", "aligned"], |
| "count": ["feature_counts", "counts", "abundance"], |
| "classify": ["kraken", "kaiju", "classification", "report"], |
| "assemble": ["spades", "megahit", "contigs", "assembly"], |
| "single-cell": ["matrix", "scanpy", "cluster", "umap", "markers"], |
| "index": ["index", ".idx", "star_index"], |
| "final": [artifact["prediction_file"] or "" for artifact in artifacts], |
| } |
| for step in expected_steps: |
| step_l = step.lower() |
| if ( |
| ("write" in step_l or "final" in step_l) |
| and any(name in step_l for name in ["csv", "tsv", "vcf", "predicted"]) |
| and not any(artifact.get("prediction_exists") for artifact in artifacts) |
| ): |
| continue |
| if ( |
| ("write" in step_l or "final" in step_l) |
| and any(name in step_l for name in ["csv", "tsv", "vcf", "predicted"]) |
| and all(artifact.get("prediction_exists") for artifact in artifacts) |
| ): |
| completed += 1 |
| evidence.append(step) |
| continue |
| keys = [] |
| for concept, words in keyword_sets.items(): |
| if concept in step_l or any(word in step_l for word in words[:2]): |
| keys.extend(words) |
| if not keys: |
| generic = {"and", "or", "the", "to", "a", "an", "write", "final", "requested"} |
| keys = [token for token in step_l.replace(",", " ").split() if len(token) > 3 and token not in generic][:4] |
| if any(key and key.lower() in haystack for key in keys): |
| completed += 1 |
| evidence.append(step) |
|
|
| |
| final_files = [artifact for artifact in artifacts if artifact.get("prediction_exists")] |
| if final_files and completed < total: |
| completed = max(completed, total - 1) |
| evidence.append("final artifact(s) exist; inferred most upstream steps completed") |
| return min(completed, total), total, evidence |
|
|
|
|
| def rule_trial_judge(task_id: str, artifacts: list[dict], trace_evidence: dict) -> dict: |
| steps_completed, steps_to_completion, evidence = infer_rule_steps(task_id, artifacts, trace_evidence) |
| final_result_reached = all(artifact.get("prediction_exists") for artifact in artifacts) |
| metric_scores = [] |
| giab_f1 = None |
| for artifact in artifacts: |
| metrics = artifact.get("metrics", {}) |
| if "match_f1" in metrics: |
| metric_scores.append(metrics["match_f1"]) |
| elif "f1" in metrics: |
| metric_scores.append(metrics["f1"]) |
| if task_id == "giab": |
| giab_f1 = metrics["f1"] |
| elif "key_f1" in metrics: |
| metric_scores.append(metrics["key_f1"]) |
| mean_metric_score = mean(metric_scores) if metric_scores else 0.0 |
|
|
| |
| if TASK_CONFIGS[task_id].get("verifiable"): |
| threshold = 0.5 if task_id == "giab" else 0.8 |
| results_match = final_result_reached and mean_metric_score >= threshold |
| else: |
| results_match = final_result_reached and steps_completed == steps_to_completion |
|
|
| notes = ( |
| "Rule-mode approximation of the paper's LLM grader. " |
| f"Evidence-backed steps: {', '.join(evidence[:8]) if evidence else 'none detected'}." |
| ) |
| return { |
| "steps_completed": steps_completed, |
| "steps_to_completion": steps_to_completion, |
| "final_result_reached": final_result_reached, |
| "notes": notes, |
| "results_match": results_match, |
| "f1_score": giab_f1, |
| } |
|
|
|
|
| def resolve_judge_client(provider: str, model: str | None, base_url: str | None, api_key: str | None): |
| try: |
| from openai import OpenAI |
| except ImportError as exc: |
| raise ImportError("openai package is required for LLM judging.") from exc |
|
|
| provider = provider.lower().strip() |
| if provider == "deepseek": |
| resolved_model = model or os.getenv("DEEPSEEK_MODEL_NAME", "deepseek-chat") |
| resolved_base_url = base_url or os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") |
| resolved_api_key = api_key or os.getenv("DEEPSEEK_API_KEY") |
| if not resolved_api_key: |
| raise RuntimeError("Missing DEEPSEEK_API_KEY for DeepSeek LLM judge.") |
| return OpenAI(api_key=resolved_api_key, base_url=resolved_base_url), resolved_model, provider |
| if provider == "openai": |
| resolved_model = model or os.getenv("OPENAI_MODEL_NAME", "gpt-5.1") |
| resolved_api_key = api_key or os.getenv("OPENAI_API_KEY") |
| if not resolved_api_key: |
| raise RuntimeError("Missing OPENAI_API_KEY for OpenAI LLM judge.") |
| return OpenAI(api_key=resolved_api_key), resolved_model, provider |
| raise ValueError(f"Unsupported LLM judge provider: {provider}") |
|
|
|
|
| def extract_json_object(text: str) -> dict: |
| text = text.strip() |
| if not text: |
| raise ValueError("Empty LLM judge response.") |
| try: |
| return json.loads(text) |
| except json.JSONDecodeError: |
| start = text.find("{") |
| end = text.rfind("}") |
| if start != -1 and end != -1 and end > start: |
| return json.loads(text[start : end + 1]) |
| raise |
|
|
|
|
| def llm_trial_judge( |
| task_id: str, |
| task_prompt: str, |
| input_data_path: Path, |
| reference_data_path: Path, |
| result_summaries: list[dict], |
| truth_summaries: list[dict], |
| trace_evidence: dict, |
| artifacts: list[dict], |
| provider: str, |
| model: str | None, |
| base_url: str | None, |
| api_key: str | None, |
| ) -> dict: |
| client, resolved_model, resolved_provider = resolve_judge_client(provider, model, base_url, api_key) |
| config = TASK_CONFIGS[task_id] |
| compact_tree = "\n".join(trace_evidence["processing_tree"][:450]) |
| path_mentions = "\n".join(trace_evidence["path_mentions_from_trace"][:180]) |
|
|
| user_prompt = f""" |
| You are a strict, impartial Bioinformatics Pipeline Judge. Evaluate an LLM agent's work for executing a bioinformatics pipeline instructed by the prompt. |
| |
| Inputs: |
| 1. Input data: {input_data_path} |
| 2. Reference data: {reference_data_path if reference_data_path.exists() else "<none>"} |
| 3. Processing tree: |
| {compact_tree} |
| |
| Trace path mentions: |
| {path_mentions or "<none>"} |
| |
| 4. Results: |
| {json.dumps(result_summaries, ensure_ascii=False, indent=2)} |
| |
| 5. Truth: |
| {json.dumps(truth_summaries, ensure_ascii=False, indent=2)} |
| |
| 6. Prompt: |
| {task_prompt} |
| |
| Task expected pipeline steps: |
| {json.dumps(config["pipeline_steps"], ensure_ascii=False, indent=2)} |
| |
| Auxiliary rule-based artifact metrics: |
| {json.dumps(artifacts, ensure_ascii=False, indent=2)} |
| |
| Evaluation rules: |
| - Prioritize evaluation of pipeline completion over exact numerical correctness. |
| - If gene names are of different naming conventions, the result can still be valid. |
| - Estimate steps_to_completion from bioinformatics-relevant steps required for this task. |
| - Count upstream steps only if their expected artifacts are present. |
| - Do not count placeholders or mock completion as completed steps. |
| - For p-values, log-fold values, abundances, or other statistics, check that plausible prior steps generated them. |
| - Task-specific results-match guidance: {config.get("results_match_guidance", "")} |
| |
| Return JSON only with exactly these fields: |
| - steps_completed: integer |
| - steps_to_completion: integer |
| - final_result_reached: boolean |
| - notes: string |
| - results_match: boolean |
| - f1_score: number or null; only use a real F1 for GIAB/variant concordance, otherwise null |
| """.strip() |
|
|
| response = client.chat.completions.create( |
| model=resolved_model, |
| temperature=0, |
| response_format={"type": "json_object"}, |
| messages=[ |
| {"role": "system", "content": "Return strict JSON only. Follow the BioAgent Bench EvaluationResults schema."}, |
| {"role": "user", "content": user_prompt}, |
| ], |
| ) |
| parsed = extract_json_object(response.choices[0].message.content or "{}") |
| parsed["provider"] = resolved_provider |
| parsed["model"] = resolved_model |
| return parsed |
|
|
|
|
| def normalize_evaluation_results(raw: dict) -> dict: |
| steps_completed = int(raw.get("steps_completed", 0) or 0) |
| steps_to_completion = int(raw.get("steps_to_completion", 0) or 0) |
| final_result_reached = bool(raw.get("final_result_reached", False)) |
| results_match = bool(raw.get("results_match", False)) |
| f1_score = raw.get("f1_score") |
| if f1_score is not None: |
| try: |
| f1_score = float(f1_score) |
| except Exception: |
| f1_score = None |
| completion_rate = steps_completed / steps_to_completion if steps_to_completion else 0.0 |
| return { |
| "steps_completed": steps_completed, |
| "steps_to_completion": steps_to_completion, |
| "completion_rate": completion_rate, |
| "final_result_reached": final_result_reached, |
| "results_match": results_match, |
| "f1_score": f1_score, |
| "notes": str(raw.get("notes", "")), |
| } |
|
|
|
|
| def evaluate_task( |
| task_id: str, |
| run_dir: Path, |
| dataset_root: Path, |
| task_metadata: dict[str, dict], |
| judge_mode: str = "rule", |
| llm_provider: str = "deepseek", |
| llm_model: str | None = None, |
| llm_base_url: str | None = None, |
| llm_api_key: str | None = None, |
| ) -> dict: |
| task_dir = dataset_root / task_id |
| run_metadata = load_run_metadata(run_dir) |
| task_prompt = ( |
| run_metadata.get("benchmark_task_context", {}).get("task_prompt") |
| or task_metadata.get(task_id, {}).get("task_prompt") |
| or run_metadata.get("query") |
| or "" |
| ) |
| trace_evidence = collect_trace_path_evidence(run_dir) |
| artifacts = build_artifact_metrics(task_id, run_dir, dataset_root) |
| result_summaries, truth_summaries = result_artifact_summaries(task_id, run_dir, dataset_root) |
|
|
| rule_raw = rule_trial_judge(task_id, artifacts, trace_evidence) |
| rule_result = normalize_evaluation_results(rule_raw) |
|
|
| selected_raw = rule_raw |
| llm_result = None |
| if judge_mode in {"llm", "both"}: |
| selected_raw = llm_trial_judge( |
| task_id=task_id, |
| task_prompt=task_prompt, |
| input_data_path=task_dir / "data", |
| reference_data_path=task_dir / "reference", |
| result_summaries=result_summaries, |
| truth_summaries=truth_summaries, |
| trace_evidence=trace_evidence, |
| artifacts=artifacts, |
| provider=llm_provider, |
| model=llm_model, |
| base_url=llm_base_url, |
| api_key=llm_api_key, |
| ) |
| llm_result = normalize_evaluation_results(selected_raw) |
|
|
| selected = rule_result if judge_mode == "rule" else llm_result |
| assert selected is not None |
| return { |
| "task_id": task_id, |
| "run_dir": str(run_dir), |
| "evaluated_at_utc": utc_timestamp(), |
| "judge_mode": judge_mode, |
| "evaluation_results": selected, |
| "overall_score": selected["completion_rate"], |
| "score_definition": ( |
| "BioAgent Bench-style completion rate: steps_completed / steps_to_completion. " |
| "results_match and f1_score are reported separately." |
| ), |
| "rule_evaluation_results": rule_result, |
| "llm_evaluation_results": llm_result, |
| "artifacts": artifacts, |
| "result_summaries": result_summaries, |
| "truth_summaries": truth_summaries, |
| "trace_evidence": trace_evidence, |
| "paper_alignment": { |
| "grader_inputs": [ |
| "input data path", |
| "reference data path", |
| "expected outcome/truth as text summary", |
| "agent outcome as text summary", |
| "agent trace represented as folders/file paths", |
| "task prompt and grading logic", |
| ], |
| "grader_outputs": [ |
| "steps_completed", |
| "steps_to_completion", |
| "final_result_reached", |
| "notes", |
| "results_match", |
| "f1_score", |
| ], |
| }, |
| } |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description="Evaluate Hypo_Bio_OS outputs on bioagent-bench.") |
| parser.add_argument("--task", action="append", help="Task ID to evaluate. Can be provided multiple times.") |
| parser.add_argument("--all", action="store_true", help="Evaluate all tasks that have run directories.") |
| parser.add_argument("--run-dir", help="Specific run directory for single-task evaluation.") |
| parser.add_argument("--runs-root", default=str(DEFAULT_RUNS_ROOT)) |
| parser.add_argument("--dataset-root", default=str(DATASET_ROOT)) |
| parser.add_argument("--metadata", default=str(METADATA_PATH)) |
| parser.add_argument("--output", default=None, help="Where to save the evaluation JSON.") |
| parser.add_argument( |
| "--judge-mode", |
| choices=["rule", "llm", "both"], |
| default="rule", |
| help="Use local paper-shaped heuristic grading, LLM grading, or both.", |
| ) |
| parser.add_argument( |
| "--llm-provider", |
| choices=["openai", "deepseek"], |
| default=os.getenv("BIOAGENT_BENCH_JUDGE_PROVIDER", "deepseek"), |
| help="OpenAI-compatible backend provider for the LLM judge.", |
| ) |
| parser.add_argument("--llm-model", default=None, help="Override model name for the LLM judge.") |
| parser.add_argument("--llm-base-url", default=None, help="Override base URL for the LLM judge.") |
| parser.add_argument("--llm-api-key", default=None, help="Override API key for the LLM judge.") |
| return parser.parse_args() |
|
|
|
|
| def main() -> int: |
| args = parse_args() |
| dataset_root = Path(args.dataset_root) |
| runs_root = Path(args.runs_root) |
| task_metadata = load_task_metadata(Path(args.metadata)) |
|
|
| if args.all: |
| task_ids = list(TASK_CONFIGS) |
| else: |
| task_ids = args.task or [] |
| if not task_ids: |
| raise SystemExit("Provide --task <task_id> or use --all.") |
|
|
| results = [] |
| for task_id in task_ids: |
| if task_id not in TASK_CONFIGS: |
| raise SystemExit(f"Unsupported task ID: {task_id}") |
| if args.run_dir and len(task_ids) == 1: |
| run_dir = Path(args.run_dir) |
| else: |
| run_dir = infer_latest_run_dir(task_id, runs_root) |
| if run_dir is None: |
| print(f"Skipping {task_id}: no run directory found under {runs_root}") |
| continue |
| result = evaluate_task( |
| task_id=task_id, |
| run_dir=run_dir, |
| dataset_root=dataset_root, |
| task_metadata=task_metadata, |
| judge_mode=args.judge_mode, |
| llm_provider=args.llm_provider, |
| llm_model=args.llm_model, |
| llm_base_url=args.llm_base_url, |
| llm_api_key=args.llm_api_key, |
| ) |
| results.append(result) |
| print(json.dumps(result, ensure_ascii=False, indent=2)) |
|
|
| completion_rates = [item["evaluation_results"]["completion_rate"] for item in results] |
| payload = { |
| "evaluated_at_utc": utc_timestamp(), |
| "judge_mode": args.judge_mode, |
| "primary_metric": "completion_rate", |
| "mean_completion_rate": mean(completion_rates) if completion_rates else 0.0, |
| "results": results, |
| } |
| if args.output: |
| output_path = Path(args.output) |
| else: |
| task_eval_dir = runs_root / "evaluation_results" |
| task_eval_dir.mkdir(parents=True, exist_ok=True) |
| output_path = task_eval_dir / f"evaluation_{task_id}.json" |
| output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") |
| print(f"Saved evaluation summary to: {output_path}") |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|