rockyaaos's picture
ChemGraph Loop: guarded real-agent API (EMT/TBLite single-point energy)
c509967 verified
Raw
History Blame Contribute Delete
24.9 kB
"""Benchmark runner for ChemGraph multi-model evaluation.
Iterates over ``(model, workflow, query)`` combinations, collects
tool-call outputs, and scores them against ground truth using an
LLM-as-judge approach.
"""
import datetime
import inspect
import json
import os
import traceback
from typing import Any, Dict, List
from chemgraph.agent.llm_agent import ChemGraph
from chemgraph.eval.config import BenchmarkConfig
from chemgraph.eval.datasets import GroundTruthItem, load_dataset
from chemgraph.eval.llm_judge import (
aggregate_judge_results,
judge_single_query,
load_judge_model,
)
from chemgraph.eval.reporter import (
print_summary_table,
write_json_report,
write_markdown_report,
write_model_detail,
)
from chemgraph.eval.structured_output_judge import (
aggregate_structured_results,
judge_structured_output,
)
from chemgraph.utils.get_workflow_from_llm import get_workflow_from_state
from chemgraph.utils.logging_config import setup_logger
logger = setup_logger(__name__)
def _safe_path_component(value: str) -> str:
"""Return a filesystem-safe path component for eval artifacts."""
text = str(value)
safe = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in text)
return safe.strip("_") or "unknown"
class ModelBenchmarkRunner:
"""Run evaluation benchmarks across multiple LLM models and workflows.
Uses an LLM judge to compare the agent's final answer against the
ground-truth result (binary: correct/wrong).
Parameters
----------
config : BenchmarkConfig
Evaluation configuration specifying models, workflows, dataset,
and output settings.
Examples
--------
>>> from chemgraph.eval import ModelBenchmarkRunner, BenchmarkConfig
>>> config = BenchmarkConfig(
... models=["gpt-4o-mini", "gemini-2.5-flash"],
... judge_model="gpt-4o",
... )
>>> runner = ModelBenchmarkRunner(config)
>>> results = asyncio.run(runner.run_all())
>>> runner.report()
"""
def __init__(self, config: BenchmarkConfig):
"""Initialize the benchmark runner.
Parameters
----------
config : BenchmarkConfig
Validated benchmark configuration.
"""
self.config = config
full_dataset: List[GroundTruthItem] = load_dataset(config.dataset)
# Apply max_queries limit if configured (0 = no limit).
if config.max_queries > 0:
self.dataset = full_dataset[: config.max_queries]
logger.info(
f"Limiting evaluation to {config.max_queries} of "
f"{len(full_dataset)} queries"
)
else:
self.dataset = full_dataset
self.results: Dict[str, Dict[str, dict]] = {}
self._run_metadata: dict = {}
# Load judge model only when LLM judge is requested.
self._judge_llm = None
if config.judge_type in ("llm", "both"):
logger.info(f"Loading judge model: {config.judge_model}")
judge_base_url = config.get_base_url(config.judge_model)
judge_argo_user = config.get_argo_user()
self._judge_llm = load_judge_model(
config.judge_model,
base_url=judge_base_url,
argo_user=judge_argo_user,
)
if config.judge_type in ("structured", "both"):
n_with_so = sum(
1
for item in self.dataset
if item.expected_structured_output is not None
)
logger.info(
f"Structured output judge enabled: {n_with_so}/{len(self.dataset)} "
f"queries have expected structured output"
)
# ------------------------------------------------------------------
# Checkpointing
# ------------------------------------------------------------------
def _checkpoint_dir(self) -> str:
"""Return the checkpoint directory path, creating it if needed."""
d = os.path.join(self.config.output_dir, "checkpoints")
os.makedirs(d, exist_ok=True)
return d
def _checkpoint_path(self, model_name: str, workflow_type: str) -> str:
"""Return the JSONL checkpoint file path for a model/workflow pair.
Parameters
----------
model_name : str
Model identifier being evaluated.
workflow_type : str
Workflow type being evaluated.
Returns
-------
str
Checkpoint JSONL file path.
"""
safe_name = model_name.replace("/", "_").replace(":", "_")
return os.path.join(
self._checkpoint_dir(),
f"{safe_name}_{workflow_type}.jsonl",
)
def _save_query_checkpoint(
self,
model_name: str,
workflow_type: str,
query_id: str,
query_idx: int,
query_result: dict,
) -> None:
"""Append a single query result to the checkpoint file.
Each line in the JSONL file is a self-contained JSON object with
the query ID, index, and full result (raw output + judge scores).
Append-only writes make this crash-safe: at worst the last line
may be truncated (one query lost, not all).
Parameters
----------
model_name : str
Model identifier being evaluated.
workflow_type : str
Workflow type being evaluated.
query_id : str
Ground-truth query identifier.
query_idx : int
Query index used as the LangGraph thread ID.
query_result : dict
Result payload to checkpoint.
"""
record = {
"query_id": query_id,
"query_idx": query_idx,
**query_result,
}
path = self._checkpoint_path(model_name, workflow_type)
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, default=str) + "\n")
def _load_checkpoint(self, model_name: str, workflow_type: str) -> Dict[str, dict]:
"""Load completed query results from a checkpoint file.
Parameters
----------
model_name : str
Model identifier being evaluated.
workflow_type : str
Workflow type being evaluated.
Returns
-------
dict
``{query_id: {"raw": ..., "judge": ..., "structured_judge": ...}}``
for each successfully checkpointed query. Corrupt lines
(e.g. from a mid-write crash) are silently skipped.
"""
path = self._checkpoint_path(model_name, workflow_type)
completed: Dict[str, dict] = {}
if not os.path.exists(path):
return completed
with open(path, "r", encoding="utf-8") as f:
for line_no, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
qid = record.get("query_id")
if qid is not None:
completed[str(qid)] = {
"raw": record.get("raw"),
"judge": record.get("judge"),
"structured_judge": record.get("structured_judge"),
}
except json.JSONDecodeError:
logger.warning(
f"Skipping corrupt checkpoint line {line_no} in "
f"{path} (possible mid-write crash)"
)
if completed:
logger.info(
f"Loaded {len(completed)} checkpointed queries for "
f"{model_name}/{workflow_type}"
)
return completed
def _clear_checkpoint(self, model_name: str, workflow_type: str) -> None:
"""Remove the checkpoint file for a (model, workflow) pair.
Called when *not* resuming, so that stale checkpoint data from a
previous run does not leak into the current run.
Parameters
----------
model_name : str
Model identifier being evaluated.
workflow_type : str
Workflow type being evaluated.
"""
path = self._checkpoint_path(model_name, workflow_type)
if os.path.exists(path):
os.remove(path)
logger.debug(f"Cleared stale checkpoint: {path}")
# ------------------------------------------------------------------
# Core execution
# ------------------------------------------------------------------
async def _run_single_model_workflow(
self,
model_name: str,
workflow_type: str,
) -> dict:
"""Run all queries for one (model, workflow) pair.
Parameters
----------
model_name : str
Model identifier to evaluate.
workflow_type : str
Workflow type to evaluate.
Returns
-------
dict
Contains ``"judge_aggregate"``, ``"judge_details"``, and
``"raw_tool_calls"``.
"""
logger.info(
f"Starting evaluation: model={model_name}, workflow={workflow_type}"
)
# Isolate log directory per model+workflow so parallel runs don't clash.
run_log_dir = self._workflow_log_dir(model_name, workflow_type)
os.makedirs(run_log_dir, exist_ok=True)
try:
# Resolve per-model base_url and argo_user from config.toml.
base_url = self.config.get_base_url(model_name)
argo_user = self.config.get_argo_user()
# Build desired kwargs and filter to only those accepted by
# the installed ChemGraph version, so the runner works even
# against older releases that lack newer parameters.
desired_kwargs = {
"model_name": model_name,
"workflow_type": workflow_type,
"structured_output": self.config.structured_output,
"return_option": "state",
"recursion_limit": self.config.recursion_limit,
"enable_memory": False,
"base_url": base_url,
"argo_user": argo_user,
"log_dir": run_log_dir,
}
sig = inspect.signature(ChemGraph.__init__)
valid_params = set(sig.parameters.keys()) - {"self"}
filtered_kwargs = {
k: v for k, v in desired_kwargs.items() if k in valid_params
}
cg = ChemGraph(**filtered_kwargs)
except Exception as e:
logger.error(f"Failed to initialise ChemGraph for {model_name}: {e}")
return self._make_error_result(
f"Initialisation failed: {e}",
len(self.dataset),
)
raw_tool_calls: List[dict] = []
per_query_judge_results: List[dict] = []
per_query_structured_results: List[dict] = []
# Load checkpoint for resume, or clear stale data for a fresh run.
checkpoint: Dict[str, dict] = {}
if self.config.resume:
checkpoint = self._load_checkpoint(model_name, workflow_type)
else:
self._clear_checkpoint(model_name, workflow_type)
n_skipped = 0
for idx, item in enumerate(self.dataset):
# Resume: reuse checkpointed result if available.
if item.id in checkpoint:
query_result = checkpoint[item.id]
n_skipped += 1
logger.debug(
f"Skipping query {idx} ({item.id}): loaded from checkpoint"
)
else:
query_result = await self._run_single_query(
cg, item, idx, model_name, workflow_type
)
# Checkpoint immediately after each query completes.
self._save_query_checkpoint(
model_name, workflow_type, item.id, idx, query_result
)
raw_tool_calls.append(query_result["raw"])
if query_result.get("judge") is not None:
per_query_judge_results.append(query_result["judge"])
if query_result.get("structured_judge") is not None:
per_query_structured_results.append(query_result["structured_judge"])
if n_skipped:
logger.info(
f"Resumed {model_name}/{workflow_type}: "
f"{n_skipped} queries from checkpoint, "
f"{len(self.dataset) - n_skipped} newly evaluated"
)
result: Dict[str, Any] = {
"raw_tool_calls": raw_tool_calls,
}
# LLM judge results.
if self.config.judge_type in ("llm", "both"):
judge_agg = aggregate_judge_results(per_query_judge_results)
result["judge_aggregate"] = judge_agg
result["judge_details"] = per_query_judge_results
# Structured output judge results.
if self.config.judge_type in ("structured", "both"):
struct_agg = aggregate_structured_results(per_query_structured_results)
result["structured_judge_aggregate"] = struct_agg
result["structured_judge_details"] = per_query_structured_results
# Log summary.
parts = [f"Completed eval {model_name}/{workflow_type}:"]
if "judge_aggregate" in result:
jagg = result["judge_aggregate"]
parts.append(
f"llm_judge={jagg['accuracy']:.1%} "
f"({jagg['n_correct']}/{jagg['n_queries']})"
)
if "structured_judge_aggregate" in result:
sagg = result["structured_judge_aggregate"]
parts.append(
f"struct_judge={sagg['accuracy']:.1%} "
f"({sagg['n_correct']}/{sagg['n_queries']})"
)
logger.info(" ".join(parts))
return result
async def _run_single_query(
self,
cg: ChemGraph,
item: GroundTruthItem,
idx: int,
model_name: str,
workflow_type: str,
) -> dict:
"""Execute and evaluate a single query.
Returns ``{"raw": ..., "judge": ..., "structured_judge": ...}``.
Parameters
----------
cg : ChemGraph
Initialized ChemGraph agent.
item : GroundTruthItem
Ground-truth query item.
idx : int
Query index used as the LangGraph thread ID.
model_name : str
Model identifier being evaluated.
workflow_type : str
Workflow type being evaluated.
Returns
-------
dict
Query result containing raw output and judge results.
"""
try:
config = {"configurable": {"thread_id": str(idx)}}
query_log_dir = self._query_log_dir(
model_name=model_name,
workflow_type=workflow_type,
query_idx=idx,
query_id=item.id,
)
os.makedirs(query_log_dir, exist_ok=True)
old_log_dir = getattr(cg, "log_dir", None)
old_env_log_dir = os.environ.get("CHEMGRAPH_LOG_DIR")
cg.log_dir = query_log_dir
os.environ["CHEMGRAPH_LOG_DIR"] = query_log_dir
try:
state = await cg.run(item.query, config)
finally:
cg.log_dir = old_log_dir
if old_env_log_dir is None:
os.environ.pop("CHEMGRAPH_LOG_DIR", None)
else:
os.environ["CHEMGRAPH_LOG_DIR"] = old_env_log_dir
llm_workflow = get_workflow_from_state(state)
model_tool_calls = llm_workflow.get("tool_calls", [])
model_result = llm_workflow.get("result", "")
except Exception as e:
logger.warning(f"Query {idx} failed for {model_name}/{workflow_type}: {e}")
logger.debug(traceback.format_exc())
model_tool_calls = []
model_result = f"ERROR: {e}"
llm_workflow = {"tool_calls": [], "result": model_result}
result: Dict[str, Any] = {"raw": llm_workflow}
# --- LLM judge ---
if self.config.judge_type in ("llm", "both") and self._judge_llm is not None:
judge_result = await judge_single_query(
judge_llm=self._judge_llm,
query=item.query,
expected_result=item.expected_result,
model_result=model_result,
expected_tool_calls=item.expected_tool_calls,
model_tool_calls=model_tool_calls,
)
judge_result["query_id"] = item.id
judge_result["query"] = item.query
judge_result["category"] = item.category
result["judge"] = judge_result
# --- Structured output judge ---
if self.config.judge_type in ("structured", "both"):
if item.expected_structured_output is not None:
struct_result = judge_structured_output(
expected=item.expected_structured_output,
actual=model_result,
)
struct_result["query_id"] = item.id
struct_result["query"] = item.query
struct_result["category"] = item.category
result["structured_judge"] = struct_result
else:
logger.debug(
f"Query {idx}: no expected_structured_output, "
f"skipping structured judge"
)
return result
def _workflow_log_dir(self, model_name: str, workflow_type: str) -> str:
"""Return the base log directory for one model/workflow pair."""
return os.path.join(
self.config.output_dir,
"logs",
_safe_path_component(model_name),
_safe_path_component(workflow_type),
)
def _query_log_dir(
self,
model_name: str,
workflow_type: str,
query_idx: int,
query_id: str,
) -> str:
"""Return an isolated artifact directory for one benchmark query."""
return os.path.join(
self._workflow_log_dir(model_name, workflow_type),
f"query_{query_idx:03d}_{_safe_path_component(query_id)}",
)
async def run_all(self) -> Dict[str, Dict[str, dict]]:
"""Execute the full benchmark: all models x all workflows.
Models are run **sequentially** to avoid API rate-limit issues
and to keep log directories clean. Within a model, queries run
sequentially as well (the ``ChemGraph.run`` method already uses
async streaming internally).
Returns
-------
dict
``{model_name: {workflow_type: {"judge_aggregate": ..., ...}}}``
"""
timestamp = datetime.datetime.now().isoformat()
self._run_metadata = {
"timestamp": timestamp,
"dataset": self.config.dataset,
"n_queries": len(self.dataset),
"models": self.config.models,
"workflow_types": self.config.workflow_types,
"judge_model": self.config.judge_model,
"judge_type": self.config.judge_type,
"structured_output": self.config.structured_output,
"resume": self.config.resume,
"tags": self.config.tags,
}
self.results = {}
for model_name in self.config.models:
self.results[model_name] = {}
for workflow_type in self.config.workflow_types:
result = await self._run_single_model_workflow(
model_name, workflow_type
)
self.results[model_name][workflow_type] = result
# Write per-model detail file immediately so partial
# results survive if a later model fails.
write_model_detail(
model_name=model_name,
workflow_type=workflow_type,
raw_tool_calls=result["raw_tool_calls"],
per_query_results=[],
output_dir=self.config.output_dir,
judge_results=result.get("judge_details"),
structured_judge_results=result.get("structured_judge_details"),
)
# Write incremental ("running") aggregate report so a
# usable summary exists even if a later model crashes.
self._write_running_report()
return self.results
# ------------------------------------------------------------------
# Reporting
# ------------------------------------------------------------------
def _write_running_report(self) -> None:
"""Write/overwrite an incremental aggregate report.
Called after each ``(model, workflow)`` pair completes inside
``run_all()``. The "running" files contain whatever results have
been collected so far, providing a usable summary even if the
process crashes before ``report()`` is called.
The running files are cleaned up by ``report()`` once the final
timestamped reports are successfully written.
"""
if not self.results or not self._run_metadata:
return
json_path = os.path.join(self.config.output_dir, "benchmark_running.json")
md_path = os.path.join(self.config.output_dir, "benchmark_running.md")
try:
write_json_report(
results=self.results,
metadata=self._run_metadata,
output_path=json_path,
)
write_markdown_report(
results=self.results,
metadata=self._run_metadata,
output_path=md_path,
)
except Exception as e:
logger.warning(f"Failed to write running report: {e}")
def _cleanup_running_report(self) -> None:
"""Remove the incremental running report files.
Called after ``report()`` has successfully written the final
timestamped reports.
"""
for suffix in ("json", "md"):
path = os.path.join(self.config.output_dir, f"benchmark_running.{suffix}")
if os.path.exists(path):
try:
os.remove(path)
logger.debug(f"Cleaned up running report: {path}")
except OSError as e:
logger.warning(f"Could not remove {path}: {e}")
def report(self, format: str = "all") -> None:
"""Generate and write evaluation reports.
Parameters
----------
format : str
``"json"``, ``"markdown"``, ``"console"``, or ``"all"``
(default).
"""
if not self.results:
logger.warning("No results to report. Run run_all() first.")
return
ts = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
if format in ("json", "all"):
write_json_report(
results=self.results,
metadata=self._run_metadata,
output_path=os.path.join(
self.config.output_dir, f"benchmark_{ts}.json"
),
)
if format in ("markdown", "all"):
write_markdown_report(
results=self.results,
metadata=self._run_metadata,
output_path=os.path.join(self.config.output_dir, f"benchmark_{ts}.md"),
)
if format in ("console", "all"):
print_summary_table(self.results)
# Clean up incremental running report files now that the final
# timestamped reports have been written successfully.
self._cleanup_running_report()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _make_error_result(error_msg: str, n_queries: int) -> dict:
"""Build an error placeholder result for a failed model init.
Parameters
----------
error_msg : str
Error message to store in the aggregate result.
n_queries : int
Number of benchmark queries that were skipped.
Returns
-------
dict
Placeholder aggregate result.
"""
return {
"judge_aggregate": {
"n_queries": n_queries,
"n_correct": 0,
"accuracy": 0.0,
"n_parse_errors": 0,
"error": error_msg,
},
"judge_details": [],
"raw_tool_calls": [],
}