Spaces:
Running on Zero
Running on Zero
Fix auto-registration of tasks, add offline DB fallback for local evaluation, and improve rate-limit out-of-credit failover
915c925 | """ | |
| inference.py — Baseline eval runner. | |
| Runs N episodes per task, collects: | |
| - final_reward per episode → reward spread distribution | |
| - No-match rate: fraction of decisions where retrieval found nothing above threshold | |
| - Quarantine rejection rate per task | |
| Outputs structured JSON report to stdout and saves to inference_results.json. | |
| Usage: | |
| python inference.py --tasks task_1 task_2 task_3 task_4 --n-episodes 5 | |
| python inference.py --tasks task_3 --n-episodes 10 # focus on the hard task | |
| """ | |
| from __future__ import annotations | |
| import sys | |
| if hasattr(sys.stdout, "reconfigure"): | |
| sys.stdout.reconfigure(encoding="utf-8", errors="replace") | |
| if hasattr(sys.stderr, "reconfigure"): | |
| sys.stderr.reconfigure(encoding="utf-8", errors="replace") | |
| import argparse | |
| import asyncio | |
| import json | |
| import statistics | |
| import time | |
| from collections import defaultdict | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| import structlog | |
| from config import settings | |
| from memory.db import init_db | |
| from server.fsm import EpisodeFSM | |
| from server.pipeline import run_episode, register_task | |
| # Register tasks | |
| import tasks.task_1 as task_1 | |
| import tasks.task_2 as task_2 | |
| import tasks.task_3 as task_3 | |
| import tasks.task_4 as task_4 | |
| log = structlog.get_logger(__name__) | |
| TASK_MODULES = { | |
| "task_1": task_1, | |
| "task_2": task_2, | |
| "task_3": task_3, | |
| "task_4": task_4, | |
| } | |
| for _tid, _mod in TASK_MODULES.items(): | |
| register_task(_tid, _mod) | |
| # Baseline scores from original repo for comparison | |
| BASELINE_SCORES = { | |
| "task_1": None, | |
| "task_2": None, | |
| "task_3": 0.6190, # the one to watch | |
| "task_4": None, | |
| } | |
| async def eval_task(task_id: str, n_episodes: int) -> Dict[str, Any]: | |
| """ | |
| Run n_episodes for a single task. Returns per-task statistics. | |
| """ | |
| if task_id in TASK_MODULES: | |
| register_task(task_id, TASK_MODULES[task_id]) | |
| rewards: List[float] = [] | |
| outcomes: List[str] = [] | |
| step_counts: List[int] = [] | |
| # Collect no-match logs from structured logging (via log interceptor) | |
| # In production, these would go to a metrics backend. | |
| # For now we parse the session-level stats from the DB post-run. | |
| log.info("eval.task_start", task_id=task_id, n_episodes=n_episodes) | |
| task_start = time.time() | |
| for ep_idx in range(n_episodes): | |
| fsm = EpisodeFSM() | |
| try: | |
| ctx = await run_episode(task_id=task_id, fsm=fsm) | |
| rewards.append(ctx.final_reward or 0.0) | |
| outcomes.append(ctx.outcome or "unknown") | |
| step_counts.append(ctx.step_index) | |
| log.info( | |
| "eval.episode_complete", | |
| task_id=task_id, | |
| episode_index=ep_idx, | |
| reward=ctx.final_reward, | |
| outcome=ctx.outcome, | |
| ) | |
| except Exception as exc: | |
| err_str = str(exc) | |
| log.exception("eval.episode_error", task_id=task_id, ep_idx=ep_idx, error=err_str) | |
| rewards.append(0.0) | |
| outcomes.append("error") | |
| step_counts.append(0) | |
| last_error = err_str | |
| task_elapsed = time.time() - task_start | |
| # Compute reward spread statistics | |
| if rewards: | |
| mean_reward = statistics.mean(rewards) | |
| std_reward = statistics.stdev(rewards) if len(rewards) > 1 else 0.0 | |
| min_reward = min(rewards) | |
| max_reward = max(rewards) | |
| else: | |
| mean_reward = std_reward = min_reward = max_reward = 0.0 | |
| outcome_counts = defaultdict(int) | |
| for o in outcomes: | |
| outcome_counts[o] += 1 | |
| baseline = BASELINE_SCORES.get(task_id) | |
| return { | |
| "task_id": task_id, | |
| "n_episodes": n_episodes, | |
| "elapsed_seconds": round(task_elapsed, 1), | |
| "last_error": last_error if "last_error" in locals() else None, | |
| "rewards": { | |
| "mean": round(mean_reward, 4), | |
| "std": round(std_reward, 4), | |
| "min": round(min_reward, 4), | |
| "max": round(max_reward, 4), | |
| "all": [round(r, 4) for r in rewards], | |
| }, | |
| "outcomes": dict(outcome_counts), | |
| "step_counts": { | |
| "mean": round(statistics.mean(step_counts), 1) if step_counts else 0, | |
| "max": max(step_counts) if step_counts else 0, | |
| }, | |
| "baseline_score": baseline, | |
| "delta_vs_baseline": ( | |
| round(mean_reward - baseline, 4) if baseline is not None else None | |
| ), | |
| } | |
| async def collect_no_match_rate(task_id: str) -> Dict[str, Any]: | |
| """ | |
| Query the DB for no-match rate stats for a task. | |
| No-match events are logged to the retrieval.no_match structlog key. | |
| This function queries the decisions table to compute the rate from DB. | |
| """ | |
| from memory.db import get_db_session | |
| from sqlalchemy import func, select, text | |
| async with get_db_session() as session: | |
| # Total decisions for this task | |
| total_result = await session.execute( | |
| text(""" | |
| SELECT COUNT(*) FROM decisions d | |
| JOIN episodes e ON d.episode_id = e.episode_id | |
| WHERE e.task_id = :task_id | |
| """), | |
| {"task_id": task_id}, | |
| ) | |
| total = total_result.scalar() or 0 | |
| # Quarantine rejection rate | |
| quarantine_result = await session.execute( | |
| text(""" | |
| SELECT COUNT(*) FROM decisions d | |
| JOIN episodes e ON d.episode_id = e.episode_id | |
| WHERE e.task_id = :task_id AND d.quarantine_flag = TRUE | |
| """), | |
| {"task_id": task_id}, | |
| ) | |
| quarantine_count = quarantine_result.scalar() or 0 | |
| # No-match rate | |
| no_match_result = await session.execute( | |
| text(""" | |
| SELECT COUNT(*) FROM decisions d | |
| JOIN episodes e ON d.episode_id = e.episode_id | |
| WHERE e.task_id = :task_id AND d.no_match_flag = TRUE | |
| """), | |
| {"task_id": task_id}, | |
| ) | |
| no_match_count = no_match_result.scalar() or 0 | |
| return { | |
| "task_id": task_id, | |
| "total_decisions": total, | |
| "quarantine_blocked": quarantine_count, | |
| "quarantine_rate": round(quarantine_count / total, 4) if total > 0 else 0.0, | |
| "no_match_count": no_match_count, | |
| "no_match_rate": round(no_match_count / total, 4) if total > 0 else 0.0, | |
| } | |
| async def main(task_ids: List[str], n_episodes: int, output_path: Optional[str]) -> None: | |
| await init_db() | |
| for task_id, module in TASK_MODULES.items(): | |
| if task_id in task_ids: | |
| register_task(task_id, module) | |
| results: Dict[str, Any] = { | |
| "agent_version": settings.agent_version, | |
| "claude_model": settings.claude_model, | |
| "n_episodes_per_task": n_episodes, | |
| "tasks": [], | |
| "metrics": [], | |
| } | |
| for task_id in task_ids: | |
| if task_id not in TASK_MODULES: | |
| log.warning("eval.unknown_task", task_id=task_id) | |
| continue | |
| task_result = await eval_task(task_id, n_episodes) | |
| results["tasks"].append(task_result) | |
| metrics = await collect_no_match_rate(task_id) | |
| results["metrics"].append(metrics) | |
| # Print report | |
| print("\n" + "=" * 60) | |
| print("INFERENCE EVALUATION REPORT") | |
| print("=" * 60) | |
| for task in results["tasks"]: | |
| print(f"\nTask: {task['task_id']}") | |
| print(f" Mean reward : {task['rewards']['mean']:.4f} (std={task['rewards']['std']:.4f})") | |
| print(f" Min / Max : {task['rewards']['min']:.4f} / {task['rewards']['max']:.4f}") | |
| if task["delta_vs_baseline"] is not None: | |
| sign = "+" if task["delta_vs_baseline"] >= 0 else "" | |
| print(f" vs baseline : {sign}{task['delta_vs_baseline']:.4f} (baseline={task['baseline_score']})") | |
| print(f" Outcomes : {task['outcomes']}") | |
| print("\n" + "=" * 60) | |
| print("METRICS") | |
| print("=" * 60) | |
| for m in results["metrics"]: | |
| print(f"\nTask: {m['task_id']}") | |
| print(f" Total decisions : {m['total_decisions']}") | |
| print(f" Quarantine rate : {m['quarantine_rate']:.2%}") | |
| print(f" No-match rate : {m['no_match_rate']:.2%}") | |
| # Save to file | |
| out_path = Path(output_path or "inference_results.json") | |
| with open(out_path, "w") as f: | |
| json.dump(results, f, indent=2) | |
| print(f"\nFull results saved to: {out_path}") | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="Run SRE agent baseline evaluation") | |
| parser.add_argument( | |
| "--tasks", | |
| nargs="+", | |
| default=["task_1", "task_2", "task_3", "task_4"], | |
| help="Task IDs to evaluate", | |
| ) | |
| parser.add_argument("--n-episodes", type=int, default=3, help="Episodes per task") | |
| parser.add_argument("--output", type=str, default=None, help="Output JSON path") | |
| args = parser.parse_args() | |
| asyncio.run(main(task_ids=args.tasks, n_episodes=args.n_episodes, output_path=args.output)) | |