| from __future__ import annotations |
|
|
| import dataclasses |
| import json |
| import time |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Callable |
|
|
| from models import OpsguardAction, OpsguardObservation |
| from server.opsguard_environment import OpsguardEnvironment |
|
|
|
|
| PolicyFn = Callable[[OpsguardObservation], OpsguardAction] |
|
|
|
|
| @dataclass |
| class RolloutRecord: |
| policy: str |
| scenario_id: str |
| seed: int |
| cumulative_reward: float |
| n_steps: int |
| n_resolved: int |
| n_total: int |
| n_spam_caught: int |
| n_spam_total: int |
| elapsed_sec: float |
| final_breakdown: dict = field(default_factory=dict) |
|
|
| def to_dict(self): |
| return dataclasses.asdict(self) |
|
|
|
|
| def rollout( |
| env: OpsguardEnvironment, |
| policy_name: str, |
| policy_fn: PolicyFn, |
| scenario_id: str, |
| seed: int = 0, |
| ) -> RolloutRecord: |
| t0 = time.time() |
| obs = env.reset(scenario_id=scenario_id, seed=seed) |
| cum = 0.0 |
| last_meta: dict = {} |
| n_steps = 0 |
| while not obs.done and n_steps < env._episode.scenario.step_budget + 5: |
| action = policy_fn(obs) |
| obs = env.step(action) |
| if obs.reward is not None: |
| cum += obs.reward |
| n_steps += 1 |
| if obs.metadata: |
| last_meta = obs.metadata |
| return RolloutRecord( |
| policy=policy_name, |
| scenario_id=scenario_id, |
| seed=seed, |
| cumulative_reward=round(cum, 4), |
| n_steps=n_steps, |
| n_resolved=last_meta.get("legit_resolved", 0), |
| n_total=last_meta.get("legit_total", 0), |
| n_spam_caught=last_meta.get("attacks_caught", 0), |
| n_spam_total=last_meta.get("attacks_total", 0), |
| elapsed_sec=round(time.time() - t0, 2), |
| final_breakdown=last_meta, |
| ) |
|
|
|
|
| def run_eval_matrix( |
| policies: dict[str, PolicyFn], |
| scenarios: list[str], |
| seeds: list[int], |
| out_dir: Path, |
| verbose: bool = True, |
| ) -> list[RolloutRecord]: |
| out_dir.mkdir(parents=True, exist_ok=True) |
| records: list[RolloutRecord] = [] |
| rollouts_path = out_dir / "rollouts.jsonl" |
| with open(rollouts_path, "w", encoding="utf-8") as f: |
| for sid in scenarios: |
| for pname, pfn in policies.items(): |
| for seed in seeds: |
| env = OpsguardEnvironment() |
| rec = rollout(env, pname, pfn, sid, seed) |
| records.append(rec) |
| f.write(json.dumps(rec.to_dict()) + "\n") |
| if verbose: |
| spam = f"{rec.n_spam_caught}/{rec.n_spam_total}" if rec.n_spam_total else "n/a" |
| print( |
| f" {pname:>20} | {sid:<22} seed={seed} | " |
| f"reward={rec.cumulative_reward:>+8.2f} steps={rec.n_steps:>4} " |
| f"resolved={rec.n_resolved}/{rec.n_total} spam={spam}", |
| flush=True, |
| ) |
| summary = aggregate(records) |
| (out_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") |
| (out_dir / "summary.md").write_text(format_markdown(summary), encoding="utf-8") |
| return records |
|
|
|
|
| def aggregate(records: list[RolloutRecord]) -> dict: |
| buckets: dict[tuple[str, str], list[RolloutRecord]] = {} |
| for r in records: |
| buckets.setdefault((r.policy, r.scenario_id), []).append(r) |
| cells = [] |
| for (p, s), rs in buckets.items(): |
| n = len(rs) |
| rewards = [r.cumulative_reward for r in rs] |
| spam_recall = [r.n_spam_caught / max(1, r.n_spam_total) for r in rs if r.n_spam_total] |
| cells.append({ |
| "policy": p, |
| "scenario": s, |
| "n": n, |
| "reward_mean": round(sum(rewards) / n, 3), |
| "reward_std": round(_std(rewards), 3), |
| "resolved_mean": round(sum(r.n_resolved for r in rs) / n, 1), |
| "spam_recall_mean": round(sum(spam_recall) / len(spam_recall), 3) if spam_recall else None, |
| "steps_mean": round(sum(r.n_steps for r in rs) / n, 1), |
| }) |
| return {"cells": cells} |
|
|
|
|
| def _std(xs: list[float]) -> float: |
| if len(xs) < 2: |
| return 0.0 |
| m = sum(xs) / len(xs) |
| return (sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) ** 0.5 |
|
|
|
|
| def format_markdown(summary: dict) -> str: |
| cells = summary["cells"] |
| scenarios = sorted({c["scenario"] for c in cells}) |
| policies = sorted({c["policy"] for c in cells}) |
| lines = ["# OpsGuard eval matrix\n"] |
| for s in scenarios: |
| lines.append(f"\n## {s}\n") |
| lines.append("| Policy | reward (μ±σ) | resolved | spam_recall | steps |") |
| lines.append("|---|---:|---:|---:|---:|") |
| for p in policies: |
| cell = next((c for c in cells if c["policy"] == p and c["scenario"] == s), None) |
| if not cell: |
| continue |
| sr = f"{cell['spam_recall_mean']:.2f}" if cell["spam_recall_mean"] is not None else "n/a" |
| lines.append( |
| f"| {cell['policy']} | {cell['reward_mean']:+.2f} ± {cell['reward_std']:.2f} | " |
| f"{cell['resolved_mean']:.1f} | {sr} | {cell['steps_mean']:.0f} |" |
| ) |
| return "\n".join(lines) + "\n" |
|
|