File size: 9,292 Bytes
994182c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | #!/usr/bin/env python3
"""Score an OpenAI-compatible endpoint on held-out eval sets.
This is the reusable, comparable evaluator for the secondary/guardrail metrics:
run it against the base model now, and against every checkpoint's vLLM endpoint
as training goes on -- same sets, same parsing, directly comparable reports.
It complements (does not replace) the agentic CyberGym harness, which is the
primary metric and runs separately via OpenHands + Docker.
Supported eval sets (auto-detected by each row's `kind`, built by build_eval_sets.py):
vuln_detection -- row has {messages, gold_label in {vulnerable, not_vulnerable}}
mcq -- row has {question, choices, gold_index/gold_letter}
Usage (real endpoint, e.g. base vLLM):
python training/scripts/eval_endpoint.py \
--base-url http://127.0.0.1:8000/v1 --model qwen36-base --label base \
--eval data/eval/vuln_detection_test.jsonl --eval data/eval/knowledge_mcq.jsonl \
--report-dir reports/eval
Offline plumbing test:
python training/scripts/eval_endpoint.py --mock --label smoke \
--eval data/eval/vuln_detection_test.jsonl --report-dir /tmp/eval
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.request
from pathlib import Path
from typing import Any
MCQ_INSTRUCTION = "Answer with the single letter of the correct choice (e.g. 'Answer: C')."
def read_jsonl(path: Path) -> list[dict[str, Any]]:
rows = []
with path.open("r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def call_endpoint(base_url: str, model: str, api_key: str, messages: list[dict[str, str]],
temperature: float, max_tokens: int, timeout: int = 180) -> str:
payload = {"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
req = urllib.request.Request(
base_url.rstrip("/") + "/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read())
return body["choices"][0]["message"]["content"]
def strip_think(text: str) -> str:
"""Return the answer portion after the last </think> (if any)."""
if "</think>" in text:
return text.rsplit("</think>", 1)[-1].strip()
return text.strip()
def parse_verdict(text: str) -> str | None:
low = strip_think(text).lower()
if "not vulnerable" in low or "no vulnerability" in low or "not a vulnerability" in low or "is secure" in low:
return "not_vulnerable"
if "vulnerable" in low or "vulnerability" in low:
return "vulnerable"
return None
def parse_letter(text: str, n_choices: int) -> int | None:
body = strip_think(text)
letters = [chr(ord("A") + i) for i in range(n_choices)]
# Prefer an explicit "Answer: X"
import re
m = re.search(r"answer\s*[:\-]?\s*\(?([A-Z])\)?", body, flags=re.IGNORECASE)
if m and m.group(1).upper() in letters:
return ord(m.group(1).upper()) - ord("A")
# else first standalone capital letter token within range
for tok in re.findall(r"\b([A-Z])\b", body):
if tok in letters:
return ord(tok) - ord("A")
return None
def mock_answer(row: dict[str, Any], idx: int) -> str:
"""Deterministic test stub: correct on even index, wrong on odd."""
correct = idx % 2 == 0
if row.get("kind") == "mcq":
gold = row.get("gold_index", 0)
n = len(row.get("choices", []))
choose = gold if correct else (gold + 1) % max(1, n)
return f"<think>mock</think>\nAnswer: {chr(ord('A') + choose)}"
gold = row.get("gold_label", "not_vulnerable")
if not correct:
gold = "vulnerable" if gold == "not_vulnerable" else "not_vulnerable"
verdict = "Vulnerable." if gold == "vulnerable" else "Not vulnerable."
return f"<think>mock</think>\n{verdict}"
def eval_vuln(rows, args) -> dict[str, Any]:
tp = fp = tn = fn = unparsed = 0
for i, row in enumerate(rows):
out = mock_answer(row, i) if args.mock else call_endpoint(
args.base_url, args.model, args.api_key, row["messages"], args.temperature, args.max_tokens
)
pred = parse_verdict(out)
gold = row["gold_label"]
if pred is None:
unparsed += 1
continue
if gold == "vulnerable":
tp += pred == "vulnerable"
fn += pred != "vulnerable"
else:
tn += pred == "not_vulnerable"
fp += pred != "not_vulnerable"
total = len(rows)
correct = tp + tn
acc = correct / total if total else 0.0
prec = tp / (tp + fp) if (tp + fp) else 0.0
rec = tp / (tp + fn) if (tp + fn) else 0.0
f1 = 2 * prec * rec / (prec + rec) if (prec + rec) else 0.0
return {
"kind": "vuln_detection", "n": total, "accuracy": acc,
"precision_vuln": prec, "recall_vuln": rec, "f1_vuln": f1,
"tp": tp, "fp": fp, "tn": tn, "fn": fn, "unparsed": unparsed,
}
def eval_mcq(rows, args) -> dict[str, Any]:
correct = unparsed = 0
for i, row in enumerate(rows):
choices = row["choices"]
user = row["question"] + "\n\n" + "\n".join(
f"{chr(ord('A') + j)}. {c}" for j, c in enumerate(choices)
) + "\n\n" + MCQ_INSTRUCTION
messages = [
{"role": "system", "content": "You are a cybersecurity expert. Authorized security research context."},
{"role": "user", "content": user},
]
out = mock_answer(row, i) if args.mock else call_endpoint(
args.base_url, args.model, args.api_key, messages, args.temperature, args.max_tokens
)
pred = parse_letter(out, len(choices))
if pred is None:
unparsed += 1
continue
correct += int(pred == row["gold_index"])
total = len(rows)
return {"kind": "mcq", "n": total, "accuracy": correct / total if total else 0.0,
"correct": correct, "unparsed": unparsed}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--eval", action="append", required=True, help="Eval JSONL (repeatable).")
parser.add_argument("--label", required=True, help="Run label, e.g. base / epoch1 / merged.")
parser.add_argument("--model", default=os.getenv("EVAL_MODEL", "qwen36-base"))
parser.add_argument("--base-url", default=os.getenv("EVAL_BASE_URL", "http://127.0.0.1:8000/v1"))
parser.add_argument("--api-key", default=os.getenv("EVAL_API_KEY", "EMPTY"))
parser.add_argument("--report-dir", default="reports/eval")
parser.add_argument("--temperature", type=float, default=0.0)
parser.add_argument("--max-tokens", type=int, default=2048)
parser.add_argument("--limit", type=int, default=None, help="Only eval first N rows per set.")
parser.add_argument("--mock", action="store_true", help="Offline deterministic stub (testing only).")
return parser.parse_args()
def main() -> int:
args = parse_args()
results = []
for path_str in args.eval:
path = Path(path_str)
if not path.is_file():
results.append({"file": path_str, "error": "missing"})
continue
rows = read_jsonl(path)
if args.limit:
rows = rows[: args.limit]
if not rows:
results.append({"file": path_str, "error": "empty"})
continue
kind = rows[0].get("kind", "vuln_detection")
res = eval_mcq(rows, args) if kind == "mcq" else eval_vuln(rows, args)
res["file"] = path_str
results.append(res)
report_dir = Path(args.report_dir)
report_dir.mkdir(parents=True, exist_ok=True)
payload = {"label": args.label, "model": args.model, "mock": bool(args.mock), "results": results}
(report_dir / f"{args.label}_eval.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
lines = [f"# Endpoint Eval — {args.label}", "", f"- Model: `{args.model}`",
f"- Endpoint: `{args.base_url}`", f"- Mock: {bool(args.mock)}", ""]
for r in results:
if "error" in r:
lines.append(f"## {r['file']} — ERROR: {r['error']}")
continue
lines.append(f"## {r['kind']} ({r['file']})")
lines.append(f"- n: {r['n']}")
lines.append(f"- accuracy: **{r['accuracy']:.2%}**")
if r["kind"] == "vuln_detection":
lines.append(f"- precision(vuln): {r['precision_vuln']:.2%} recall(vuln): {r['recall_vuln']:.2%} f1: {r['f1_vuln']:.2%}")
lines.append(f"- tp/fp/tn/fn: {r['tp']}/{r['fp']}/{r['tn']}/{r['fn']} unparsed: {r['unparsed']}")
else:
lines.append(f"- correct: {r['correct']} unparsed: {r['unparsed']}")
lines.append("")
(report_dir / f"{args.label}_eval.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(json.dumps(payload, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
|