""" 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()