#!/usr/bin/env python3 """ Evaluate Qwen2.5-0.5B (base) vs. LoRA adapter (Shiggii/qwen-incident-response-grpo) on ``IncidentResponseEnvironment`` from ``server.environment``, by difficulty, **adversarial** mode. **Difficulty → task mapping (aligned with project docs)** - **Easy** — ``task_easy`` + ``adversarial=True`` (Slack “authority” pushes a dangerous fix; runbook says rollback) - **Medium** — ``task_medium`` + ``adversarial=True`` (unanimous wrong chat; runbook prohibits flush, correct fix is rollback) - **Hard** — ``task_hard`` + ``adversarial=True`` (command pressure to rollback; true fix is ``rotate_db_credentials``) **Reference plot numbers** (e.g. Untrained 0.201 / 0.999 / 0.999) match ``training_log.json`` **before** row and were obtained with the **Groq** harness and ``llama-3.1-8b-instant``, not local Qwen. This script is the *reproducible local Qwen* protocol: means will not identically match every decimal, but the qualitative pattern (train improves Easy; Medium/Hard often near ceiling) should hold. **Dependencies** (not in the minimal app ``requirements.txt``):: pip install torch transformers peft accelerate **Run** (GPU strongly recommended):: set HF_TOKEN=hf_... & :: if adapter is private python scripts/evaluate_by_difficulty.py python scripts/evaluate_by_difficulty.py --plot """ from __future__ import annotations import argparse import json import os import random import sys ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if ROOT not in sys.path: sys.path.insert(0, ROOT) BASE_MODEL = "Qwen/Qwen2.5-0.5B-Instruct" ADAPTER_ID = "Shiggii/qwen-incident-response-grpo" # Match ``training_log.json`` regime: all tasks evaluated in adversarial mode ADVERSARIAL = True EPISODES_PER_CELL = 10 EVALTEMP = float(os.environ.get("EVAL_TEMPERATURE", "0.8")) TASK_ORDER = [("task_easy", "Easy"), ("task_medium", "Medium"), ("task_hard", "Hard")] def import_deps(): from inference import GROQ_SYSTEM_PROMPT, build_groq_prompt, deterministic_fallback from server.environment import IncidentResponseEnvironment return GROQ_SYSTEM_PROMPT, build_groq_prompt, deterministic_fallback, IncidentResponseEnvironment def import_torch(): try: import torch from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer except ImportError as e: print( "Missing dependencies. Install:\n pip install torch transformers peft accelerate", file=sys.stderr, ) raise SystemExit(1) from e return torch, AutoModelForCausalLM, AutoTokenizer, PeftModel def load_model(torch, am, tok_cls, peft, trained: bool, hf_token: str | None): tok = tok_cls.from_pretrained( BASE_MODEL, trust_remote_code=True, token=hf_token ) if tok.pad_token is None and tok.eos_token is not None: tok.pad_token = tok.eos_token if torch.cuda.is_available(): kwargs = {"device_map": "auto", "torch_dtype": torch.float16} else: kwargs = {"device_map": None, "torch_dtype": torch.float32} base = am.from_pretrained( BASE_MODEL, trust_remote_code=True, token=hf_token, **kwargs ) if kwargs.get("device_map") is None: base = base.to("cpu") if trained: m = peft.from_pretrained( base, ADAPTER_ID, is_trainable=False, token=hf_token ) else: m = base m.eval() return tok, m def run_one_episode( torch, model, tokenizer, env, system_prompt: str, build_groq_prompt, deterministic_fallback, task_id: str, seed: int, ) -> float: import torch as T import re random.seed(seed) T.manual_seed(seed) if T.cuda.is_available(): T.cuda.manual_seed_all(seed) episode_id, obs = env.reset(task_id=task_id, adversarial=ADVERSARIAL) log_count = len(obs.get("logs", [])) user = build_groq_prompt(obs) def finish(action: str, evidence: int) -> float: if log_count: evidence = max(0, min(int(evidence), log_count - 1)) else: evidence = 0 env.step({"action": action, "evidence": evidence}, episode_id=episode_id) return float(env.grade(episode_id)["score"]) try: messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user}, ] if hasattr(tokenizer, "apply_chat_template"): prompt = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) else: prompt = f"{system_prompt}\n\n{user}" inputs = tokenizer( prompt, return_tensors="pt", truncation=True, max_length=4096 ) dev = next(model.parameters()).device inputs = {k: v.to(dev) for k, v in inputs.items()} with T.inference_mode(): gen = model.generate( **inputs, max_new_tokens=256, do_sample=EVALTEMP > 0, temperature=EVALTEMP if EVALTEMP > 0 else 1.0, top_p=0.95, pad_token_id=tokenizer.pad_token_id, ) in_len = inputs["input_ids"].shape[1] new_tokens = gen[0, in_len:] text = tokenizer.decode(new_tokens, skip_special_tokens=True).strip() except Exception: # noqa: BLE001 fb = deterministic_fallback(obs) return finish(fb["action"], 0) if "```" in text: part = text.split("```", 1)[1] if part.startswith("json"): part = part[4:] text = part.strip() try: parsed = json.loads(text) except Exception as e: print( f"[warn] {task_id} seed={seed}: JSON parse failed ({type(e).__name__}); using deterministic fallback", file=sys.stderr, ) fb = deterministic_fallback(obs) return finish(fb["action"], 0) # Robust handling for malformed-but-parseable outputs: # - expected: dict with {"action": "...", "evidence": N} # - bad case: JSON string containing free-form text if isinstance(parsed, dict): act = parsed.get("action", "notify_cto") try: ev = int(parsed.get("evidence", 0)) except (TypeError, ValueError): ev = 0 return finish(act, ev) if isinstance(parsed, str): # Try to salvage an action name from plain text. # Accept either explicit `action: xyz` or any known action token. action_regex = re.compile( r"(rollback_deployment|scale_infrastructure|flush_redis_cache|notify_cto|restart_api_gateway|rotate_db_credentials|enable_circuit_breaker|purge_cdn_cache)" ) m = action_regex.search(parsed) if m: act = m.group(1) ev_match = re.search(r"evidence\\s*[:=]\\s*(-?\\d+)", parsed, flags=re.IGNORECASE) try: ev = int(ev_match.group(1)) if ev_match else 0 except (TypeError, ValueError): ev = 0 print( f"[warn] {task_id} seed={seed}: parsed JSON string; recovered action={act}", file=sys.stderr, ) return finish(act, ev) print( f"[warn] {task_id} seed={seed}: parsed JSON string without valid action; using deterministic fallback", file=sys.stderr, ) fb = deterministic_fallback(obs) return finish(fb["action"], 0) print( f"[warn] {task_id} seed={seed}: parsed JSON type={type(parsed).__name__}; using deterministic fallback", file=sys.stderr, ) fb = deterministic_fallback(obs) return finish(fb["action"], 0) def run_grid( torch, model, tokenizer, env, system_prompt: str, build_groq_prompt, deterministic_fallback, episodes: int, base_seed: int, seed_offset: int, ) -> dict[str, float]: out: dict[str, float] = {} for ti, (tid, _label) in enumerate(TASK_ORDER): scores: list[float] = [] for epi in range(episodes): # Deterministic across runs: separate ranges per task index + ep index + model offset seed = base_seed + seed_offset + ti * 10_000 + epi s = run_one_episode( torch, model, tokenizer, env, system_prompt, build_groq_prompt, deterministic_fallback, tid, seed, ) scores.append(s) out[tid] = sum(scores) / len(scores) return out def maybe_plot(untrained: dict[str, float], trained: dict[str, float], outpath: str) -> None: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt labels = [lbl for _tid, lbl in TASK_ORDER] x = range(len(labels)) w = 0.35 uvals = [untrained["task_easy"], untrained["task_medium"], untrained["task_hard"]] tvals = [trained["task_easy"], trained["task_medium"], trained["task_hard"]] fig, ax = plt.subplots(figsize=(9, 5), dpi=100) ax.bar([i - w / 2 for i in x], uvals, w, label="Untrained (base Qwen0.5B)", color="salmon") ax.bar([i + w / 2 for i in x], tvals, w, label="Trained (LoRA)", color="seagreen") ax.set_xticks(list(x), labels) ax.set_ylabel("Mean grader score (10 eps)") ax.set_ylim(0, 1.1) ax.legend() ax.grid(True, alpha=0.3, axis="y") ax.set_title("Evaluation by difficulty (adversarial mode) — Qwen2.5-0.5B + LoRA") fig.tight_layout() fig.savefig(outpath, bbox_inches="tight") plt.close(fig) print(f"Wrote {outpath}") def main() -> None: ap = argparse.ArgumentParser() ap.add_argument( "--plot", action="store_true", help="Write evaluation_by_difficulty.png in repo root", ) ap.add_argument( "--episodes", type=int, default=EPISODES_PER_CELL, help="Episodes per (model, difficulty) cell (default: 10)", ) ap.add_argument( "--base-seed", type=int, default=42, help="Base RNG seed (default: 42)", ) args = ap.parse_args() GROQ_SYSTEM_PROMPT, build_groq_prompt, det_fb, IrEnv = import_deps() torch, am, tok_cls, peft = import_torch() hf_token = os.environ.get("HF_TOKEN", "").strip() or None print("--- evaluate_by_difficulty.py ---") print(f" Base: {BASE_MODEL}") print(f" Adapter: {ADAPTER_ID} (trained run)") print(f" Adversarial: {ADVERSARIAL} | episodes/cell: {args.episodes} | temp: {EVALTEMP}") print(" Reference (README / training_log.json before, Groq 8B): Easy ~0.20, Med/Hard ~0.999\n") results_untrained: dict[str, float] = {} results_trained: dict[str, float] = {} env = IrEnv() print("[1/2] Untrained (base only)...") tok, model = load_model(torch, am, tok_cls, peft, False, hf_token) results_untrained = run_grid( torch, model, tok, env, GROQ_SYSTEM_PROMPT, build_groq_prompt, det_fb, args.episodes, args.base_seed, seed_offset=0, ) del model if torch.cuda.is_available(): torch.cuda.empty_cache() print("[2/2] Trained (LoRA)...") tok, model = load_model(torch, am, tok_cls, peft, True, hf_token) results_trained = run_grid( torch, model, tok, env, GROQ_SYSTEM_PROMPT, build_groq_prompt, det_fb, args.episodes, args.base_seed, seed_offset=1_000_000, ) # Print table print("\n=== Mean grader score (adversarial, 1 step, env.grade) ===\n") print(f"{'':12} {'Easy':>10} {'Medium':>10} {'Hard':>10}") print( f"{'Untrained':12} {results_untrained['task_easy']:10.3f} " f"{results_untrained['task_medium']:10.3f} {results_untrained['task_hard']:10.3f}" ) print( f"{'Trained':12} {results_trained['task_easy']:10.3f} " f"{results_trained['task_medium']:10.3f} {results_trained['task_hard']:10.3f}" ) print( f"\nReference (training_log before/after, different model): " f"0.201 / 0.999 / 0.999 → 0.999 / 0.999 / 0.999" ) if args.plot: outp = os.path.join(ROOT, "evaluation_by_difficulty.png") maybe_plot(results_untrained, results_trained, outp) if __name__ == "__main__": main()