feat: Update Honcho system benchmarks (#393)

* feat: Update Honcho system benchmarks

* fix: Align with runner common functions and fixed basedpyright issues

* fix: Address coderabbit issues

* chore: Address Ruff Errors

---------

Co-authored-by: 3un01a <3un01a@plasticlabs.ai>
Co-authored-by: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com>
This commit is contained in:
3un01a 2026-02-24 11:30:30 +09:00 committed by GitHub
parent eba9279af2
commit 2631209256
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 2662 additions and 2096 deletions

1300
tests/bench/coverage.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1222
tests/bench/molecular.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,643 +0,0 @@
"""
Deriver Observation Extraction Benchmark.
Evaluates the deriver's ability to extract observations from messages by comparing
extracted observations against ground truth using embedding similarity.
Test data sources (915 total cases):
- LoCoMo: 543 cases with human-curated observations
- LongMem: 170 cases with Sonnet-generated observations
- BEAM: 202 cases with Sonnet-generated observations
Usage:
uv run python tests/bench/obex.py # Run full evaluation
uv run python tests/bench/obex.py --limit 10 # Test with limited cases
uv run python tests/bench/obex.py --source locomo # Evaluate specific source
uv run python tests/bench/obex.py --threshold 0.80 # Custom similarity threshold
"""
import argparse
import asyncio
import json
import sys
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
import numpy as np
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.config import settings
from src.deriver.prompts import minimal_deriver_prompt
from src.embedding_client import EmbeddingClient
from src.utils.clients import honcho_llm_call
from src.utils.representation import PromptRepresentation
CANDIDATES_DIR = Path(__file__).parent / "obexeval_data" / "candidates"
EVAL_RESULTS_DIR = Path(__file__).parent / "eval_results"
# =============================================================================
# Data Classes
# =============================================================================
def f1_score(precision: float, recall: float) -> float:
"""Calculate F1 score from precision and recall."""
if precision + recall == 0:
return 0.0
return 2 * (precision * recall) / (precision + recall)
@dataclass
class CaseResult:
"""Result for a single test case."""
case_id: str
source: str
difficulty: str
explicit_precision: float
explicit_recall: float
# deductive_precision: float
# deductive_recall: float
num_extracted: int
num_expected: int
# Actual model output
extracted_explicit: list[str] = field(default_factory=list)
# extracted_deductive: list[str] = field(default_factory=list)
error: str | None = None
@property
def explicit_f1(self) -> float:
return f1_score(self.explicit_precision, self.explicit_recall)
# @property
# def deductive_f1(self) -> float:
# return f1_score(self.deductive_precision, self.deductive_recall)
@dataclass
class SourceStats:
"""Statistics for a source or difficulty grouping."""
count: int
explicit_precision: float
explicit_recall: float
explicit_f1: float
# deductive_precision: float
# deductive_recall: float
# deductive_f1: float
@dataclass
class AggregateResults:
"""Aggregate results across all cases."""
total_cases: int
explicit_precision: float
explicit_recall: float
explicit_f1: float
# deductive_precision: float
# deductive_recall: float
# deductive_f1: float
by_source: dict[str, SourceStats] = field(default_factory=dict)
by_difficulty: dict[str, SourceStats] = field(default_factory=dict)
# =============================================================================
# Data Loading
# =============================================================================
TestCase = dict[str, Any]
def load_test_cases(source: str | None = None) -> list[TestCase]:
"""Load test cases from candidate files."""
all_cases: list[TestCase] = []
for json_file in CANDIDATES_DIR.glob("*_candidates.json"):
with open(json_file) as f:
data: dict[str, Any] = json.load(f)
all_cases.extend(data.get("candidates", []))
if source:
all_cases = [tc for tc in all_cases if tc.get("source") == source]
return all_cases
# =============================================================================
# Deriver Execution
# =============================================================================
def format_messages(messages: list[dict[str, Any]]) -> str:
"""Format messages for the deriver prompt."""
formatted: list[str] = []
for msg in messages:
author: str = msg.get("author", "unknown")
content: str = msg.get("content", "")
timestamp: str = msg.get("timestamp", "")
if timestamp:
try:
dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
time_str = dt.strftime("%Y-%m-%d %H:%M")
formatted.append(f"[{time_str}] {author}: {content}")
except ValueError:
formatted.append(f"{author}: {content}")
else:
formatted.append(f"{author}: {content}")
return "\n".join(formatted)
async def run_deriver(
messages: list[dict[str, Any]], target_peer: str
) -> PromptRepresentation:
"""Run the deriver on messages and return extracted observations."""
formatted = format_messages(messages)
prompt = minimal_deriver_prompt(
peer_id=target_peer,
messages=formatted,
)
response = await honcho_llm_call(
llm_settings=settings.DERIVER,
prompt=prompt,
max_tokens=settings.DERIVER.MAX_OUTPUT_TOKENS or 2000,
track_name="Deriver Eval",
response_model=PromptRepresentation,
json_mode=True,
stop_seqs=[" \n", "\n\n\n\n"],
enable_retry=True,
retry_attempts=3,
)
return response.content
# =============================================================================
# Embedding-based Matching
# =============================================================================
class ObservationMatcher:
"""Matches observations using embedding similarity."""
threshold: float
client: EmbeddingClient
def __init__(self, threshold: float = 0.85):
self.threshold = threshold
self.client = EmbeddingClient()
self._cache: dict[str, list[float]] = {}
async def _get_embeddings(self, texts: list[str]) -> list[list[float]]:
"""Get embeddings with caching."""
uncached = [t for t in texts if t not in self._cache]
if uncached:
embeddings = await self.client.simple_batch_embed(uncached)
for text, emb in zip(uncached, embeddings, strict=True):
self._cache[text] = emb
return [self._cache[t] for t in texts]
def _cosine_sim(self, a: list[float], b: list[float]) -> float:
"""Compute cosine similarity."""
a_np, b_np = np.array(a), np.array(b)
return float(np.dot(a_np, b_np) / (np.linalg.norm(a_np) * np.linalg.norm(b_np)))
async def match(
self, extracted: list[str], expected: list[str]
) -> tuple[float, float]:
"""
Match extracted to expected using greedy similarity matching.
Returns: (precision, recall)
"""
if not extracted and not expected:
return 1.0, 1.0
if not extracted:
return 0.0, 0.0
if not expected:
return 0.0, 1.0
# Get embeddings
all_texts = extracted + expected
all_embs = await self._get_embeddings(all_texts)
ext_embs = all_embs[: len(extracted)]
exp_embs = all_embs[len(extracted) :]
# Build similarity matrix
sim_matrix = np.zeros((len(extracted), len(expected)))
for i, e1 in enumerate(ext_embs):
for j, e2 in enumerate(exp_embs):
sim_matrix[i, j] = self._cosine_sim(e1, e2)
# Greedy matching
matches = 0
used_ext: set[int] = set()
used_exp: set[int] = set()
pairs = sorted(
[
(sim_matrix[i, j], i, j)
for i in range(len(extracted))
for j in range(len(expected))
],
reverse=True,
)
for sim, i, j in pairs:
if sim < self.threshold:
break
if i in used_ext or j in used_exp:
continue
matches += 1
used_ext.add(i)
used_exp.add(j)
precision = matches / len(extracted)
recall = matches / len(expected)
return precision, recall
# =============================================================================
# Evaluation
# =============================================================================
async def evaluate_case(case: TestCase, matcher: ObservationMatcher) -> CaseResult:
"""Evaluate a single test case."""
case_id: str = case["id"]
source: str = case.get("source", "unknown")
difficulty: str = case.get("difficulty", "unknown")
messages: list[dict[str, Any]] = case.get("messages", [])
target_peer: str = case.get("target_peer", "user")
expected: dict[str, Any] = case.get("expected_observations", {})
exp_explicit: list[str] = [o["content"] for o in expected.get("explicit", [])]
# exp_deductive: list[str] = [o["conclusion"] for o in expected.get("deductive", [])]
ext_explicit: list[str]
# ext_deductive: list[str]
error: str | None
try:
result = await run_deriver(messages, target_peer)
ext_explicit = [o.content for o in result.explicit]
# ext_deductive = [o.conclusion for o in result.deductive]
error = None
except Exception as e:
ext_explicit = [] # , ext_deductive = [], []
error = str(e)
# Match explicit
exp_p, exp_r = await matcher.match(ext_explicit, exp_explicit)
# Match deductive
# ded_p, ded_r = await matcher.match(ext_deductive, exp_deductive)
return CaseResult(
case_id=case_id,
source=source,
difficulty=difficulty,
explicit_precision=exp_p,
explicit_recall=exp_r,
# deductive_precision=ded_p,
# deductive_recall=ded_r,
num_extracted=len(ext_explicit), # + len(ext_deductive),
num_expected=len(exp_explicit), # + len(exp_deductive),
extracted_explicit=ext_explicit,
# extracted_deductive=ext_deductive,
error=error,
)
def aggregate_results(results: list[CaseResult]) -> AggregateResults:
"""Compute aggregate statistics."""
if not results:
return AggregateResults(0, 0, 0, 0) # , 0, 0, 0)
n = len(results)
exp_p = sum(r.explicit_precision for r in results) / n
exp_r = sum(r.explicit_recall for r in results) / n
# ded_p = sum(r.deductive_precision for r in results) / n
# ded_r = sum(r.deductive_recall for r in results) / n
agg = AggregateResults(
total_cases=n,
explicit_precision=exp_p,
explicit_recall=exp_r,
explicit_f1=f1_score(exp_p, exp_r),
# deductive_precision=ded_p,
# deductive_recall=ded_r,
# deductive_f1=f1_score(ded_p, ded_r),
)
# By source
by_source: dict[str, list[CaseResult]] = {}
for r in results:
by_source.setdefault(r.source, []).append(r)
for source, rs in by_source.items():
src_exp_p = sum(r.explicit_precision for r in rs) / len(rs)
src_exp_r = sum(r.explicit_recall for r in rs) / len(rs)
# src_ded_p = sum(r.deductive_precision for r in rs) / len(rs)
# src_ded_r = sum(r.deductive_recall for r in rs) / len(rs)
agg.by_source[source] = SourceStats(
count=len(rs),
explicit_precision=src_exp_p,
explicit_recall=src_exp_r,
explicit_f1=f1_score(src_exp_p, src_exp_r),
# deductive_precision=src_ded_p,
# deductive_recall=src_ded_r,
# deductive_f1=f1_score(src_ded_p, src_ded_r),
)
# By difficulty
by_diff: dict[str, list[CaseResult]] = {}
for r in results:
by_diff.setdefault(r.difficulty, []).append(r)
for diff, rs in by_diff.items():
diff_exp_p = sum(r.explicit_precision for r in rs) / len(rs)
diff_exp_r = sum(r.explicit_recall for r in rs) / len(rs)
# diff_ded_p = sum(r.deductive_precision for r in rs) / len(rs)
# diff_ded_r = sum(r.deductive_recall for r in rs) / len(rs)
agg.by_difficulty[diff] = SourceStats(
count=len(rs),
explicit_precision=diff_exp_p,
explicit_recall=diff_exp_r,
explicit_f1=f1_score(diff_exp_p, diff_exp_r),
# deductive_precision=diff_ded_p,
# deductive_recall=diff_ded_r,
# deductive_f1=f1_score(diff_ded_p, diff_ded_r),
)
return agg
def print_results(agg: AggregateResults, results: list[CaseResult]) -> None:
"""Print evaluation results."""
print("\n" + "=" * 70)
print("DERIVER OBSERVATION EXTRACTION EVALUATION")
print("=" * 70)
print(f"\nTotal test cases: {agg.total_cases}")
print(f"\n{'EXPLICIT OBSERVATIONS':^35}")
print(f" Precision: {agg.explicit_precision:.3f}")
print(f" Recall: {agg.explicit_recall:.3f}")
print(f" F1: {agg.explicit_f1:.3f}")
# print(f"\n{'DEDUCTIVE OBSERVATIONS':^35}")
# print(f" Precision: {agg.deductive_precision:.3f}")
# print(f" Recall: {agg.deductive_recall:.3f}")
# print(f" F1: {agg.deductive_f1:.3f}")
if agg.by_source:
print(f"\n{'BY SOURCE':^35}")
for source, data in sorted(agg.by_source.items()):
print(
f" {source:12} n={data.count:3} exp_f1={data.explicit_f1:.3f}" # ded_f1={data.deductive_f1:.3f}"
)
if agg.by_difficulty:
print(f"\n{'BY DIFFICULTY':^35}")
for diff, data in sorted(agg.by_difficulty.items()):
print(
f" {diff:12} n={data.count:3} exp_f1={data.explicit_f1:.3f}" # ded_f1={data.deductive_f1:.3f}"
)
# Worst cases (by average of precision and recall)
worst = sorted(
results,
key=lambda r: (
r.explicit_precision + r.explicit_recall
# + r.deductive_precision
# + r.deductive_recall
)
/ 2, # / 4,
)[:5]
print(f"\n{'WORST PERFORMING CASES':^35}")
for r in worst:
avg = (
(
r.explicit_precision + r.explicit_recall
# + r.deductive_precision
# + r.deductive_recall
)
/ 2,
) # / 4,
print(f" {r.case_id[:45]:45} avg={avg:.3f}")
# Errors
errors = [r for r in results if r.error]
if errors:
print(f"\nErrors: {len(errors)} cases failed")
def generate_json_summary(
results: list[CaseResult],
agg: AggregateResults,
source_filter: str | None,
threshold: float,
concurrency: int,
total_elapsed_seconds: float,
output_file: Path,
) -> None:
"""
Generate a comprehensive JSON summary of test results for analytics.
Args:
results: List of case results
agg: Aggregate results
source_filter: Source filter used (if any)
threshold: Similarity threshold used
concurrency: Concurrency level used
total_elapsed_seconds: Total elapsed time for all tests
output_file: Path to write JSON output to
"""
errors = [r for r in results if r.error]
# Calculate per-case average scores
case_scores: list[float] = []
for r in results:
avg = (r.explicit_precision + r.explicit_recall) / 2
# + r.deductive_precision
# + r.deductive_recall
# / 4,
case_scores.append(avg)
output_data: dict[str, Any] = {
"metadata": {
"benchmark": "obex",
"description": "Observation Extraction Benchmark - evaluates deriver's ability to extract observations from messages",
"execution_timestamp": datetime.now().isoformat(),
"runner_version": "1.0.0",
"deriver_settings": settings.DERIVER.model_dump(),
},
"config": {
"source_filter": source_filter,
"threshold": threshold,
"concurrency": concurrency,
"total_cases": len(results),
},
"timing": {
"total_elapsed_seconds": total_elapsed_seconds,
"average_seconds_per_case": total_elapsed_seconds / len(results)
if results
else 0,
},
"summary_statistics": {
"total_cases": agg.total_cases,
"cases_with_errors": len(errors),
"explicit_precision": agg.explicit_precision,
"explicit_recall": agg.explicit_recall,
"explicit_f1": agg.explicit_f1,
# "deductive_precision": agg.deductive_precision,
# "deductive_recall": agg.deductive_recall,
# "deductive_f1": agg.deductive_f1,
"mean_case_score": sum(case_scores) / len(case_scores)
if case_scores
else 0,
"min_case_score": min(case_scores) if case_scores else 0,
"max_case_score": max(case_scores) if case_scores else 0,
},
"statistics_by_source": {k: asdict(v) for k, v in agg.by_source.items()},
"statistics_by_difficulty": {
k: asdict(v) for k, v in agg.by_difficulty.items()
},
"detailed_results": [
{
"case_id": r.case_id,
"source": r.source,
"difficulty": r.difficulty,
"explicit_precision": r.explicit_precision,
"explicit_recall": r.explicit_recall,
"explicit_f1": r.explicit_f1,
# "deductive_precision": r.deductive_precision,
# "deductive_recall": r.deductive_recall,
# "deductive_f1": r.deductive_f1,
"num_extracted": r.num_extracted,
"num_expected": r.num_expected,
"average_f1": r.explicit_f1, # (r.explicit_f1 + r.deductive_f1) / 2,
"model_output": {
"explicit": r.extracted_explicit,
# "deductive": r.extracted_deductive,
},
"error": r.error,
}
for r in results
],
}
# Ensure output directory exists
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, "w") as f:
json.dump(output_data, f, indent=2)
print(f"\nResults saved to {output_file}")
async def run_evaluation(
source: str | None = None,
limit: int | None = None,
threshold: float = 0.85,
output: str | None = None,
concurrency: int = 10,
) -> None:
"""Run the evaluation pipeline."""
start_time = time.time()
print("Loading test cases...")
cases = load_test_cases(source)
if limit:
cases = cases[:limit]
if not cases:
print("No test cases found!")
return
print(
f"Evaluating {len(cases)} cases (model={settings.DERIVER.MODEL}, threshold={threshold}, concurrency={concurrency})..."
)
matcher = ObservationMatcher(threshold=threshold)
completed = 0
semaphore = asyncio.Semaphore(concurrency)
async def evaluate_with_semaphore(case: TestCase) -> CaseResult:
nonlocal completed
async with semaphore:
result = await evaluate_case(case, matcher)
completed += 1
if completed % 10 == 0 or completed == 1:
print(f" Completed {completed}/{len(cases)}...")
return result
# Run all evaluations concurrently with semaphore limiting
tasks = [evaluate_with_semaphore(case) for case in cases]
results = await asyncio.gather(*tasks)
end_time = time.time()
total_elapsed = end_time - start_time
agg = aggregate_results(list(results))
print_results(agg, list(results))
print(
f"\nTotal time: {total_elapsed:.1f}s ({total_elapsed / len(cases):.2f}s per case)"
)
# Determine output file path
if output:
output_file = Path(output)
else:
# Generate default timestamped filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
source_suffix = f"_{source}" if source else ""
output_file = EVAL_RESULTS_DIR / f"obex{source_suffix}_{timestamp}.json"
# Always save results
generate_json_summary(
list(results),
agg,
source,
threshold,
concurrency,
total_elapsed,
output_file,
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Deriver observation extraction benchmark"
)
parser.add_argument(
"--source", type=str, help="Filter by source (locomo, longmem, beam)"
)
parser.add_argument("--limit", type=int, help="Limit number of test cases")
parser.add_argument(
"--threshold",
type=float,
default=0.85,
help="Similarity threshold (default: 0.85)",
)
parser.add_argument("--output", type=str, help="Output path for results JSON")
parser.add_argument(
"--concurrency",
type=int,
default=10,
help="Number of concurrent LLM calls (default: 10)",
)
args = parser.parse_args()
asyncio.run(
run_evaluation(
args.source, args.limit, args.threshold, args.output, args.concurrency
)
)
if __name__ == "__main__":
main()

View File

@ -1,12 +1,14 @@
"""
Shared utilities for Honcho benchmark test runners.
Shared utilities for Honcho benchmark test runners and evaluators.
Contains the BaseRunner abstract class and RunnerConfig dataclass that provide
a common framework for all benchmark runners (longmem, beam, locomo).
a common framework for all benchmark runners (longmem, beam, locomo), as well
as shared utilities for trace-based evaluators (molecular, coverage).
"""
import argparse
import asyncio
import json
import logging
import os
import time
@ -15,7 +17,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from logging import Logger
from pathlib import Path
from typing import Any, Generic, Literal, TypeVar
from typing import Any, Generic, Literal, TypeVar, cast
from anthropic import AsyncAnthropic
from honcho import Honcho
@ -24,6 +26,8 @@ from openai import AsyncOpenAI
from src.telemetry.metrics_collector import MetricsCollector
_logger = logging.getLogger(__name__)
# Valid reasoning levels for dialectic chat
ReasoningLevel = Literal["minimal", "low", "medium", "high", "max"]
REASONING_LEVELS: list[str] = ["minimal", "low", "medium", "high", "max"]
@ -195,22 +199,27 @@ def validate_common_arguments(args: argparse.Namespace) -> str | None:
return None
def configure_logging() -> logging.Logger:
def configure_logging(
level: int = logging.WARNING,
name: str | None = None,
) -> logging.Logger:
"""
Configure logging for benchmark runners.
Sets up logging with WARNING level and suppresses HTTP request logs.
Sets up logging at the specified level and suppresses HTTP request logs.
Args:
level: Logging level (default: WARNING for runners, use INFO for evaluators)
name: Logger name (default: caller's __name__)
Returns:
Logger instance for the calling module
Logger instance
"""
logging.basicConfig(
level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s"
)
logging.basicConfig(level=level, format="%(asctime)s - %(levelname)s - %(message)s")
# Suppress HTTP request logs from the Honcho SDK
logging.getLogger("httpx").setLevel(logging.ERROR)
logging.getLogger("httpcore").setLevel(logging.ERROR)
return logging.getLogger(__name__)
return logging.getLogger(name or __name__)
def create_anthropic_client(api_key: str | None = None) -> AsyncAnthropic:
@ -277,6 +286,127 @@ def format_duration(seconds: float) -> str:
return f"{hours}h {minutes}m"
# ---------------------------------------------------------------------------
# Trace parsing utilities (shared by trace-based evaluators)
# ---------------------------------------------------------------------------
def load_traces(path: Path) -> list[dict[str, Any]]:
"""Load traces from JSON or JSONL file.
Attempts JSONL (one object per line) first; falls back to standard JSON
(array or single object). Returns an empty list when the file cannot be
read or parsed.
"""
traces: list[dict[str, Any]] = []
# --- Attempt 1: try JSONL (one JSON object per line) ---
try:
with open(path) as f:
first_line = f.readline().strip()
if first_line and not first_line.startswith("["):
f.seek(0)
for line_no, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
traces.append(cast(dict[str, Any], json.loads(line)))
except json.JSONDecodeError:
_logger.debug(
"%s:%d: skipping malformed JSON line", path, line_no
)
continue
if traces:
return traces
except json.JSONDecodeError:
_logger.warning("Failed to parse %s as JSONL", path)
except OSError:
_logger.exception("Could not read trace file %s", path)
return []
# --- Attempt 2: try standard JSON (array or single object) ---
try:
with open(path) as f:
data = cast(dict[str, Any] | list[dict[str, Any]], json.load(f))
except json.JSONDecodeError:
_logger.exception("Failed to parse %s as JSON", path)
return []
except OSError:
_logger.exception("Could not read trace file %s", path)
return []
if isinstance(data, list):
return data
return [data]
def extract_propositions(trace: dict[str, Any]) -> list[str]:
"""Extract propositions from trace output."""
output = trace.get("output")
if not isinstance(output, dict):
return []
output_d = cast(dict[str, Any], output)
content = output_d.get("content")
if not isinstance(content, dict):
return []
content_d = cast(dict[str, Any], content)
explicit = content_d.get("explicit")
if not isinstance(explicit, list):
return []
results: list[str] = []
for raw_item in cast(list[Any], explicit):
item = cast(dict[str, Any], raw_item)
if isinstance(raw_item, dict) and "content" in item:
results.append(str(item["content"]))
return results
def extract_messages(trace: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract source messages from trace input."""
input_data = trace.get("input")
if not isinstance(input_data, dict):
return []
input_d = cast(dict[str, Any], input_data)
prompt = input_d.get("prompt", "")
if not isinstance(prompt, str):
return []
messages: list[dict[str, Any]] = []
if "<messages>" in prompt:
section = prompt.split("<messages>")[1].split("</messages>")[0]
for line in section.strip().split("\n"):
line = line.strip()
if not line:
continue
parts = line.split(" ", 3)
if len(parts) >= 3:
speaker = parts[2].rstrip(":")
text = parts[3] if len(parts) > 3 else ""
if "->->" in text:
text = text.split("->->")[0].strip()
messages.append({"speaker": speaker, "text": text})
return messages
def extract_peer_name(trace: dict[str, Any]) -> str:
"""Extract peer name from propositions."""
for prop in extract_propositions(trace):
prop_lower = prop.lower()
for pattern in ["name is", "is named", "called"]:
if pattern in prop_lower:
parts = prop_lower.split(pattern)
if len(parts) > 1:
name = parts[1].strip().rstrip(".").split()[0]
return name.capitalize()
return "user"
def extract_conversation_id(trace: dict[str, Any], index: int) -> str:
"""Extract or generate conversation ID."""
return trace.get("conversation_id", trace.get("id", f"trace_{index:04d}"))
class BaseRunner(ABC, Generic[ResultT]):
"""
Abstract base class for benchmark runners.