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())