""" inference.py Run inference against the CACE OpenEnv environment and plot reward curves. Usage: python inference.py python inference.py --episodes 20 --model Sannidhay/cace-grpo-model """ import os, json, argparse, time import requests import matplotlib.pyplot as plt import matplotlib.gridspec as gridspec import numpy as np from datetime import datetime # ── Config ──────────────────────────────────────────────────────────────────── ENV_URL = os.environ.get("ENV_BASE_URL", "https://sannidhay-cace-env.hf.space") HF_TOKEN = os.environ.get("HF_TOKEN", "") ACTION_MAP = { 0: "ALLOW", 1: "REMOVE", 2: "ALLOW_WITH_LABEL", 3: "ESCALATE", 4: "RESTRICT_DISTRIBUTION", } ACTION_COLORS = { "ALLOW": "#2ecc71", "REMOVE": "#e74c3c", "ALLOW_WITH_LABEL": "#f39c12", "ESCALATE": "#9b59b6", "RESTRICT_DISTRIBUTION": "#3498db", } # ── Simple LLM Decision Agent ───────────────────────────────────────────────── def get_decision_from_model(observation: str, model: str = None) -> tuple[str, int]: """ Get moderation decision from model. Uses SFT/GRPO model if available, falls back to rule-based. """ obs_upper = observation.upper() # Rule-based fallback (works without GPU) if "REMOVE" in obs_upper and ("HATE" in obs_upper or "VIOLENCE" in obs_upper or "HARM" in obs_upper): if "CULTURAL" in obs_upper and "LEGITIMATE" in obs_upper: return "ESCALATE", 3 return "REMOVE", 1 elif "ALLOW" in obs_upper and "CULTURAL" in obs_upper: return "ALLOW", 0 elif "HIGH" in obs_upper and "COMPLEX" in obs_upper: return "ESCALATE", 3 else: return "ALLOW", 0 # ── OpenEnv client ──────────────────────────────────────────────────────────── class CACEClient: def __init__(self, base_url: str): self.base_url = base_url.rstrip("/") self.session = requests.Session() if HF_TOKEN: self.session.headers["Authorization"] = f"Bearer {HF_TOKEN}" def health(self) -> bool: try: r = self.session.get(f"{self.base_url}/health", timeout=15) return r.status_code == 200 and r.json().get("status") == "ok" except Exception as e: print(f"[DEBUG] Health check failed: {e}") return False def wait_until_ready(self, max_wait: int = 120): print(f"[DEBUG] Waiting for server at {self.base_url} ...") for i in range(max_wait): if self.health(): print(f"[DEBUG] Server is ready!") return True time.sleep(1) if i % 10 == 9: print(f"[DEBUG] Still waiting... ({i+1}s)") raise RuntimeError(f"Server not ready after {max_wait}s") def reset(self) -> str: r = self.session.post(f"{self.base_url}/reset", timeout=60) r.raise_for_status() obs_r = self.session.get(f"{self.base_url}/observation", timeout=30) return obs_r.json()["observation"] def step(self, action_int: int) -> dict: r = self.session.post( f"{self.base_url}/step", json={"action_int": action_int}, timeout=30, ) r.raise_for_status() return r.json() def info(self) -> dict: r = self.session.get(f"{self.base_url}/info", timeout=10) return r.json() def metrics(self) -> dict: r = self.session.get(f"{self.base_url}/metrics", timeout=10) return r.json() # ── Run episodes ────────────────────────────────────────────────────────────── def run_episodes(env: CACEClient, n_episodes: int, model: str = None) -> list[dict]: results = [] for ep in range(1, n_episodes + 1): obs = env.reset() decision, action_int = get_decision_from_model(obs, model) result = env.step(action_int) reward = result.get("reward", 0.0) done = result.get("done", True) info = result.get("info", {}) ground_truth= info.get("ground_truth", "?") correct = info.get("correct", decision == ground_truth) language = info.get("language", "Unknown") region = info.get("region", "Unknown") breakdown = info.get("reward_breakdown", {}) ep_result = { "episode": ep, "decision": decision, "ground_truth": ground_truth, "reward": float(reward), "correct": correct, "done": done, "language": language, "region": region, "t1_cultural": breakdown.get("track1_cultural", 0), "t2_harm": breakdown.get("track2_harm", 0), "t3_policy": breakdown.get("track3_policy", 0), } results.append(ep_result) status = "✓" if correct else "✗" print( f"[STEP] ep={ep} decision={decision} gt={ground_truth} " f"reward={reward:+.3f} correct={str(correct).lower()} {status} " f"lang={language}" ) return results # ── Plotting ────────────────────────────────────────────────────────────────── def plot_results(results: list[dict], save_path: str = "cace_inference_results.png"): episodes = [r["episode"] for r in results] rewards = [r["reward"] for r in results] correct = [r["correct"] for r in results] decisions= [r["decision"] for r in results] # Running averages window = min(5, len(results)) avg_rewards = np.convolve(rewards, np.ones(window)/window, mode='valid') avg_correct = np.convolve([1 if c else 0 for c in correct], np.ones(window)/window, mode='valid') fig = plt.figure(figsize=(16, 10)) fig.patch.set_facecolor('#0f1117') gs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.4, wspace=0.35) GOLD = "#FFD700" GREEN = "#2ecc71" RED = "#e74c3c" BLUE = "#3498db" PURPLE = "#9b59b6" BG = '#0f1117' PANEL = '#1a1d2e' def style_ax(ax, title): ax.set_facecolor(PANEL) ax.set_title(title, color=GOLD, fontsize=11, fontweight='bold', pad=8) ax.tick_params(colors='white') ax.xaxis.label.set_color('white') ax.yaxis.label.set_color('white') for spine in ax.spines.values(): spine.set_edgecolor('#333') # ── Plot 1: Reward per episode ──────────────────────────────────────────── ax1 = fig.add_subplot(gs[0, :2]) style_ax(ax1, "Reward per Episode") colors = [GREEN if r > 0 else RED for r in rewards] ax1.bar(episodes, rewards, color=colors, alpha=0.7, label="Episode reward") if len(avg_rewards) > 0: x_avg = episodes[window-1:] ax1.plot(x_avg, avg_rewards, color=GOLD, linewidth=2.5, label=f"Rolling avg (n={window})", zorder=5) ax1.axhline(0, color='white', linewidth=0.5, linestyle='--', alpha=0.3) ax1.set_xlabel("Episode") ax1.set_ylabel("Reward") ax1.legend(facecolor=PANEL, labelcolor='white', fontsize=9) ax1.set_ylim(-1.2, 1.2) # ── Plot 2: Accuracy ────────────────────────────────────────────────────── ax2 = fig.add_subplot(gs[0, 2]) style_ax(ax2, "Accuracy") accuracy = sum(correct) / len(correct) ax2.pie( [accuracy, 1-accuracy], labels=["Correct", "Wrong"], colors=[GREEN, RED], autopct='%1.0f%%', textprops={'color': 'white', 'fontsize': 11}, startangle=90, ) ax2.set_title(f"Accuracy\n{accuracy*100:.1f}% ({sum(correct)}/{len(correct)})", color=GOLD, fontsize=11, fontweight='bold') # ── Plot 3: Decision distribution ───────────────────────────────────────── ax3 = fig.add_subplot(gs[1, 0]) style_ax(ax3, "Decision Distribution") from collections import Counter dec_counts = Counter(decisions) labels = list(dec_counts.keys()) vals = list(dec_counts.values()) bar_colors = [ACTION_COLORS.get(l, BLUE) for l in labels] bars = ax3.bar(labels, vals, color=bar_colors, alpha=0.85) ax3.set_xticklabels(labels, rotation=20, ha='right', fontsize=8) for bar, val in zip(bars, vals): ax3.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.1, str(val), ha='center', color='white', fontsize=9) ax3.set_ylabel("Count") # ── Plot 4: Three-track reward breakdown ────────────────────────────────── ax4 = fig.add_subplot(gs[1, 1]) style_ax(ax4, "3-Track Reward Breakdown (avg)") t1_avg = np.mean([r["t1_cultural"] for r in results]) t2_avg = np.mean([r["t2_harm"] for r in results]) t3_avg = np.mean([r["t3_policy"] for r in results]) tracks = ["Cultural\n(40%)", "Harm\n(35%)", "Policy\n(25%)"] vals = [t1_avg, t2_avg, t3_avg] bar_colors2 = [GOLD, PURPLE, BLUE] bars2 = ax4.bar(tracks, vals, color=bar_colors2, alpha=0.85) for bar, val in zip(bars2, vals): ax4.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5, f"{val:.1f}", ha='center', color='white', fontsize=10) ax4.set_ylabel("Score (0-100)") ax4.set_ylim(0, 110) # ── Plot 5: Running accuracy ─────────────────────────────────────────────── ax5 = fig.add_subplot(gs[1, 2]) style_ax(ax5, "Running Accuracy") if len(avg_correct) > 0: x_acc = episodes[window-1:] ax5.plot(x_acc, avg_correct * 100, color=GREEN, linewidth=2.5) ax5.fill_between(x_acc, avg_correct * 100, alpha=0.2, color=GREEN) ax5.axhline(50, color='white', linewidth=0.5, linestyle='--', alpha=0.3) ax5.set_xlabel("Episode") ax5.set_ylabel("Accuracy (%)") ax5.set_ylim(0, 105) # ── Title ────────────────────────────────────────────────────────────────── fig.suptitle( "CACE — Cultural Context Arbitration Environment\nInference Results", color=GOLD, fontsize=14, fontweight='bold', y=1.01 ) plt.savefig(save_path, dpi=150, bbox_inches='tight', facecolor=BG) print(f"\n[PLOT] Saved → {save_path}") # Also save JSON json_path = save_path.replace(".png", ".json") with open(json_path, "w") as f: json.dump({ "summary": { "episodes": len(results), "accuracy": accuracy, "avg_reward": float(np.mean(rewards)), "total_correct": int(sum(correct)), }, "episodes": results, }, f, indent=2) print(f"[DATA] Saved → {json_path}") plt.show() # ── Main ────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser() parser.add_argument("--episodes", type=int, default=20) parser.add_argument("--model", default=None, help="HF model repo for decisions") parser.add_argument("--env-url", default=ENV_URL) parser.add_argument("--output", default="cace_inference_results.png") args = parser.parse_args() env = CACEClient(args.env_url) env.wait_until_ready() info = env.info() print(f"\n[START] env={info.get('name','cace')} model={args.model or 'rule-based'}") print(f" action_space={info.get('action_space',{}).get('n')} reward_range={info.get('reward_range')}\n") results = run_episodes(env, args.episodes, args.model) # Summary rewards = [r["reward"] for r in results] accuracy = sum(r["correct"] for r in results) / len(results) print(f"\n[END] episodes={len(results)} accuracy={accuracy:.3f} " f"avg_reward={np.mean(rewards):.3f} " f"rewards={','.join(f'{r:.2f}' for r in rewards)}") metrics = env.metrics() print(f"[METRICS] {metrics}") plot_results(results, args.output) if __name__ == "__main__": main()