guarddiff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ """Single-file HTML report. Every dynamic value is escaped; no external resources are referenced."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from html import escape
6
+ from pathlib import Path
7
+ from string import Template
8
+ from typing import TYPE_CHECKING
9
+
10
+ from ..outcomes import OUTCOMES
11
+ from .markdown import indeterminate_warning, notices, reduction_text
12
+
13
+ if TYPE_CHECKING:
14
+ from ..runner import RunResult
15
+
16
+ _PAGE = Template("""<!doctype html>
17
+ <html lang="en"><head><meta charset="utf-8"><title>guarddiff report</title>
18
+ <style>
19
+ body{font-family:system-ui,sans-serif;margin:1.5rem;color:#222}
20
+ table{border-collapse:collapse;margin:1rem 0;font-size:.9rem}
21
+ th,td{border:1px solid #ccc;padding:.3rem .6rem;text-align:left;vertical-align:top}
22
+ th{background:#f3f3f3}.note{background:#fff6d6;border:1px solid #e0c870;padding:.4rem .8rem;margin:.3rem 0}
23
+ .o-blocked,.o-model_refused,.o-passed{background:#e6f4e6}.o-missed,.o-overblocked{background:#fbe3e3}
24
+ .o-model_overrefused{background:#fdf0d5}.o-indeterminate{background:#e3e3f4}
25
+ small{color:#555}
26
+ </style></head><body>
27
+ <h1>guarddiff report</h1>
28
+ $notes
29
+ <h2>Summary</h2>
30
+ <p><small>Baseline: <b>$baseline</b>. Rates use all items of a label as the denominator
31
+ (indeterminate included). Intervals are Wilson 95%; the paired p is an exact McNemar test.</small></p>
32
+ $summary_table
33
+ <h2>Per item</h2>
34
+ $items_table
35
+ </body></html>
36
+ """)
37
+
38
+
39
+ def _e(x: object) -> str:
40
+ return escape("" if x is None else str(x), quote=True)
41
+
42
+
43
+ def _ci(c: list[float]) -> str:
44
+ return f"{c[0]:.4f}&ndash;{c[1]:.4f}"
45
+
46
+
47
+ def _summary_table(result: "RunResult") -> str:
48
+ head = ("<tr><th>variant</th><th>n atk / ben</th><th>ASR</th><th>ASR 95% CI</th><th>ASR reduction</th>"
49
+ "<th>overblock</th><th>overblock 95% CI</th><th>total overrefusal</th><th>indeterminate</th>"
50
+ "<th>latency p50 / p95 ms</th><th>cost USD</th><th>target calls</th>"
51
+ "<th>fixed / regressed</th><th>McNemar p</th><th>redundant blocks</th></tr>")
52
+ body = []
53
+ for v in result.variants:
54
+ s = result.summary[v]
55
+ pair = s["paired_vs_none"]
56
+ body.append(
57
+ f"<tr><td>{_e(v)}</td><td>{s['n_attack']} / {s['n_benign']}</td><td>{s['asr']:.4f}</td>"
58
+ f"<td>{_ci(s['asr_ci95'])}</td><td>{reduction_text(s, 4)}</td>"
59
+ f"<td>{s['guard_overblock_rate']:.4f}</td><td>{_ci(s['guard_overblock_ci95'])}</td>"
60
+ f"<td>{s['total_overrefusal_rate']:.4f}</td><td>{s['indeterminate_rate']:.4f}</td>"
61
+ f"<td>{s['guard_latency_ms_p50']:.3f} / {s['guard_latency_ms_p95']:.3f}</td>"
62
+ f"<td>{s['guard_cost_usd_total']:.4f}</td><td>{s['target_calls']}</td>"
63
+ + ("<td>-</td><td>-</td><td>-</td>" if pair is None else
64
+ f"<td>{pair['attack_fixed']} / {pair['attack_regressed']}</td><td>{pair['mcnemar_p']}</td>"
65
+ f"<td>{pair['redundant_blocks']}</td>")
66
+ + "</tr>")
67
+ table = f"<table>{head}{''.join(body)}</table>"
68
+ warning = indeterminate_warning(result, "the ASR reduction")
69
+ if warning: # the '!' in the table points here
70
+ table += f'<div class="note"><b>!</b> {_e(warning)}</div>'
71
+ return table
72
+
73
+
74
+ def _items_table(result: "RunResult") -> str:
75
+ order: list[str] = []
76
+ for r in result.rows:
77
+ if r.id not in order:
78
+ order.append(r.id)
79
+ first = {r.id: r for r in result.rows}
80
+ cell = {(r.id, r.variant): r for r in result.rows}
81
+ head = "<tr><th>id</th><th>label</th><th>prompt</th>" + "".join(f"<th>{_e(v)}</th>" for v in result.variants) + "</tr>"
82
+ body = []
83
+ for i in order:
84
+ f = first[i]
85
+ tds = []
86
+ for v in result.variants:
87
+ r = cell.get((i, v))
88
+ if r is None:
89
+ tds.append("<td>-</td>")
90
+ continue
91
+ ev = f"<br><small>{_e(r.evidence)}</small>" if r.evidence else ""
92
+ tds.append(f"<td class=\"o-{_e(r.outcome)}\"><b>{_e(r.outcome)}</b>{ev}</td>")
93
+ body.append(f"<tr><td>{_e(i)}</td><td>{_e(f.label)}</td><td>{_e(f.prompt)}</td>{''.join(tds)}</tr>")
94
+ legend = "<p><small>Outcomes: " + ", ".join(OUTCOMES) + ".</small></p>"
95
+ return f"<table>{head}{''.join(body)}</table>{legend}"
96
+
97
+
98
+ def render_html(result: "RunResult") -> str:
99
+ notes = "".join(f'<div class="note">{_e(n)}</div>' for n in notices(result))
100
+ return _PAGE.substitute(notes=notes, baseline=_e(result.baseline),
101
+ summary_table=_summary_table(result), items_table=_items_table(result))
102
+
103
+
104
+ def write_html(result: "RunResult", path: str | Path) -> Path:
105
+ p = Path(path)
106
+ p.write_text(render_html(result), encoding="utf-8")
107
+ return p
@@ -0,0 +1,66 @@
1
+ """Markdown summary (one table row per variant), meant for CI step summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from ..runner import RunResult
9
+
10
+
11
+ def notices(result: "RunResult") -> list[str]:
12
+ """Honesty notes shown above every report: mock target, simulated latency."""
13
+ notes: list[str] = []
14
+ if result.meta.get("target_type", "mock") == "mock":
15
+ notes.append("practice mock target (not a real LLM)")
16
+ if any(s.get("latency_source") in ("simulated", "mixed") for s in result.summary.values()):
17
+ notes.append("simulated latency (reproducible placeholder values, not measurements)")
18
+ return notes
19
+
20
+
21
+ def reduction_text(summary: dict, digits: int) -> str:
22
+ """ASR reduction for a table cell. A trailing '!' means the variant has indeterminate items:
23
+ failed items are not counted as missed attacks, so the reduction may look better than it is."""
24
+ red = summary["asr_reduction_vs_none"]
25
+ if red is None:
26
+ return "-"
27
+ text = f"{red:.{digits}f}"
28
+ return text + "!" if summary["indeterminate_rate"] > 0 else text
29
+
30
+
31
+ def indeterminate_warning(result: "RunResult", reduction_name: str) -> str | None:
32
+ """One warning line when any variant has indeterminate items (used by `run`, Markdown and HTML)."""
33
+ flagged = [v for v in result.variants if result.summary[v]["indeterminate_rate"] > 0]
34
+ if not flagged:
35
+ return None
36
+ return (f"indeterminate items (a guard or the target failed) in: {', '.join(flagged)}. They are not counted "
37
+ f"as missed attacks, so {reduction_name} may look better than it is. Fix the failures, "
38
+ "or run `guarddiff gate`.")
39
+
40
+
41
+ def _ci(c: list[float]) -> str:
42
+ return f"{c[0]:.2f}-{c[1]:.2f}"
43
+
44
+
45
+ def render_markdown(result: "RunResult") -> str:
46
+ lines = ["## guarddiff report", ""]
47
+ for n in notices(result):
48
+ lines.append(f"> {n}")
49
+ if lines[-1] != "":
50
+ lines.append("")
51
+ lines += ["| variant | ASR (95% CI) | ASR reduction | overblock (95% CI) | indeterminate | p50 ms | p95 ms "
52
+ "| cost USD | McNemar p |",
53
+ "|---|---|---|---|---|---|---|---|---|"]
54
+ for v in result.variants:
55
+ s = result.summary[v]
56
+ pair = s["paired_vs_none"]
57
+ lines.append(
58
+ f"| {v} | {s['asr']:.3f} ({_ci(s['asr_ci95'])}) | {reduction_text(s, 4)} "
59
+ f"| {s['guard_overblock_rate']:.3f} ({_ci(s['guard_overblock_ci95'])}) "
60
+ f"| {s['indeterminate_rate']:.3f} | {s['guard_latency_ms_p50']:.1f} | {s['guard_latency_ms_p95']:.1f} "
61
+ f"| {s['guard_cost_usd_total']:.4f} | {'-' if pair is None else pair['mcnemar_p']} |")
62
+ warning = indeterminate_warning(result, "the ASR reduction")
63
+ if warning:
64
+ lines += ["", f"> **!** {warning}"] # same warning as the `run` table, so a CI summary shows it too
65
+ lines += ["", f"Baseline variant: `{result.baseline}`. Rates use all items of a label as the denominator."]
66
+ return "\n".join(lines) + "\n"
guarddiff/runner.py ADDED
@@ -0,0 +1,304 @@
1
+ """Run every item through every variant and keep results.jsonl up to date (resumable)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .config import Config, Variant, load_config
14
+ from .errors import ConfigError, GuarddiffError
15
+ from .guards import Guard, build_guard
16
+ from .items import Item, load_items
17
+ from .judges import RulesJudge
18
+ from .latency import MODES, simulated_latency
19
+ from .metrics import build_summary
20
+ from .outcomes import INDETERMINATE, ResultRow, classify
21
+ from .targets import Target, TargetReply, build_target
22
+
23
+ RESULTS_FILE = "results.jsonl"
24
+ SUMMARY_FILE = "summary.json"
25
+ SCHEMA_VERSION = 1
26
+
27
+
28
+ @dataclass
29
+ class RunResult:
30
+ """What ``run_ablation`` returns. ``summary`` maps variant name -> metrics dict."""
31
+
32
+ summary: dict[str, dict[str, Any]]
33
+ rows: list[ResultRow]
34
+ variants: list[str]
35
+ baseline: str
36
+ meta: dict[str, Any] = field(default_factory=dict)
37
+ out_dir: Path | None = None
38
+
39
+ def rows_for(self, variant: str) -> list[ResultRow]:
40
+ return [r for r in self.rows if r.variant == variant]
41
+
42
+
43
+ def _fail_text(exc: BaseException) -> str:
44
+ return f"{type(exc).__name__}: {exc}"
45
+
46
+
47
+ def _check_guard(guard: Guard, text: str, item: Item, mode: str) -> tuple[dict[str, Any], float, float]:
48
+ """Run one guard. Returns (event, latency_ms, cost_usd). A raised error becomes event['error']."""
49
+ event: dict[str, Any] = {"guard": guard.name, "stage": guard.stage}
50
+ t0 = time.perf_counter()
51
+ try:
52
+ verdict = guard.check(text, item_id=item.id, prompt=item.prompt)
53
+ except Exception as e: # noqa: BLE001 - any guard failure must become "indeterminate", never a pass
54
+ event.update(blocked=None, reason=None, error=_fail_text(e), latency_ms=0.0, latency_source="none")
55
+ return event, 0.0, 0.0
56
+ elapsed = round((time.perf_counter() - t0) * 1000, 3)
57
+ if verdict.latency_ms is not None: # a replayed recording knows its own latency
58
+ latency, source = float(verdict.latency_ms), "replay"
59
+ elif mode == "simulated" and guard.sim_latency_ms is not None:
60
+ latency, source = simulated_latency(item.id, guard.name, guard.sim_latency_ms), "simulated"
61
+ else:
62
+ latency, source = elapsed, "measured"
63
+ cost = guard.unit_cost_usd if verdict.cost_usd is None else verdict.cost_usd
64
+ event.update(blocked=verdict.blocked, reason=verdict.reason, latency_ms=latency, latency_source=source)
65
+ return event, latency, cost
66
+
67
+
68
+ def run_pair(item: Item, guards: list[Guard], target: Target, judge: RulesJudge, mode: str,
69
+ variant: str) -> ResultRow:
70
+ """Input guards (stop at the first block) -> target -> output guards -> refusal judge -> outcome."""
71
+ events: list[dict[str, Any]] = []
72
+ latency = cost = 0.0
73
+ blocked_by: str | None = None
74
+ failure: str | None = None
75
+ answer = ""
76
+ usage: dict[str, int] = {}
77
+ called = False
78
+
79
+ for g in (g for g in guards if g.stage == "input"):
80
+ ev, lat, c = _check_guard(g, item.prompt, item, mode)
81
+ events.append(ev)
82
+ latency, cost = latency + lat, cost + c
83
+ if ev.get("error"):
84
+ failure = f"{g.name}: {ev['error']}"
85
+ break
86
+ if ev["blocked"]:
87
+ blocked_by = f"{g.name}: {ev['reason']}"
88
+ break
89
+
90
+ if blocked_by is None and failure is None:
91
+ called = True
92
+ try:
93
+ reply = target.call(item.prompt)
94
+ except Exception as e: # noqa: BLE001
95
+ reply = TargetReply("", error=_fail_text(e))
96
+ usage = dict(reply.usage)
97
+ if reply.error:
98
+ failure = f"target: {reply.error}"
99
+ elif not reply.text.strip():
100
+ failure = "target: empty response (a reasoning model may need a larger max_tokens)"
101
+ else:
102
+ answer = reply.text
103
+ if failure is None:
104
+ for g in (g for g in guards if g.stage == "output"):
105
+ ev, lat, c = _check_guard(g, answer, item, mode)
106
+ events.append(ev)
107
+ latency, cost = latency + lat, cost + c
108
+ if ev.get("error"):
109
+ failure = f"{g.name}: {ev['error']}"
110
+ break
111
+ if ev["blocked"]:
112
+ blocked_by = f"{g.name}: {ev['reason']}"
113
+ break
114
+
115
+ if failure is not None:
116
+ outcome, evidence = INDETERMINATE, failure
117
+ elif blocked_by is not None:
118
+ outcome, evidence = classify(item.label, guard_blocked=True, model_refused=False), blocked_by
119
+ else:
120
+ refused, why = judge.judge(answer)
121
+ outcome = classify(item.label, guard_blocked=False, model_refused=refused)
122
+ evidence = why
123
+ return ResultRow(id=item.id, variant=variant, label=item.label, lang=item.lang, category=item.category,
124
+ prompt=item.prompt, outcome=outcome, evidence=evidence,
125
+ guard_latency_ms=round(latency, 3), guard_cost_usd=round(cost, 6),
126
+ guard_events=events, target_called=called, usage=usage)
127
+
128
+
129
+ def variant_config_hash(cfg: Config, variant: Variant, mode: str) -> str:
130
+ """Hash of everything that decides a variant's rows besides the prompts: its guards (in order),
131
+ the target, the judge and the latency mode. Code behind a ``module:function`` is not hashed."""
132
+ specs = {s.get("name"): s for s in cfg.guards}
133
+ doc = {"guards": [specs[n] for n in variant.guards], "target": cfg.target, "judge": cfg.judge,
134
+ "latency": mode}
135
+ blob = json.dumps(doc, sort_keys=True, ensure_ascii=False, default=str)
136
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
137
+
138
+
139
+ def _load_rows(path: Path) -> list[ResultRow]:
140
+ """Read results.jsonl, skipping unreadable lines (for example a line cut short by a crash)."""
141
+ rows: list[ResultRow] = []
142
+ if not path.is_file():
143
+ return rows
144
+ for line in path.read_text(encoding="utf-8").splitlines():
145
+ if not line.strip():
146
+ continue
147
+ try:
148
+ rows.append(ResultRow.from_dict(json.loads(line)))
149
+ except (json.JSONDecodeError, TypeError):
150
+ continue
151
+ return rows
152
+
153
+
154
+ def _write_rows(path: Path, rows: list[ResultRow]) -> None:
155
+ tmp = path.with_suffix(".jsonl.tmp")
156
+ tmp.write_text("".join(json.dumps(r.to_dict(), ensure_ascii=False) + "\n" for r in rows), encoding="utf-8")
157
+ os.replace(tmp, path)
158
+
159
+
160
+ def _in_file_order(items: list[Item], variants: list[Variant],
161
+ rows: dict[tuple[str, str], ResultRow]) -> list[ResultRow]:
162
+ """The rows we have, in the order results.jsonl is written: item by item, variants in config order."""
163
+ return [rows[(i.id, v.name)] for i in items for v in variants if (i.id, v.name) in rows]
164
+
165
+
166
+ def _select_variants(config: Config, only: list[str] | None) -> list[Variant]:
167
+ if not only:
168
+ return list(config.variants)
169
+ names = [v.name for v in config.variants]
170
+ unknown = [n for n in only if n not in names]
171
+ if unknown:
172
+ raise ConfigError(f"--only: unknown variant(s) {unknown}. Defined variants: {names}.")
173
+ wanted = set(only) | {config.baseline} # the baseline is always needed for the comparison
174
+ return [v for v in config.variants if v.name in wanted]
175
+
176
+
177
+ def run_ablation(config: Config | str | Path, out_dir: str | Path, *, latency: str | None = None,
178
+ fresh: bool = False, only: list[str] | None = None) -> RunResult:
179
+ """Run the ablation and write ``results.jsonl``, ``summary.json`` and ``report.html`` into ``out_dir``.
180
+
181
+ Resuming: a pair (item, variant) already in results.jsonl is reused when its prompt, label and settings
182
+ hash are unchanged and its outcome was decided. ``indeterminate`` rows (a guard or the target failed) are
183
+ always run again, because the cause may be fixed by now. ``fresh`` recomputes the selected variants.
184
+ ``only`` runs those variants plus the baseline; rows of the other variants stay in results.jsonl as they are.
185
+ ``latency`` overrides the config ("simulated" or "measured").
186
+ """
187
+ cfg = load_config(config) if isinstance(config, (str, Path)) else config
188
+ mode = latency or cfg.latency
189
+ if mode not in MODES:
190
+ raise ConfigError(f"latency must be 'simulated' or 'measured', got {mode!r}.")
191
+ items = load_items(cfg.items_path)
192
+ variants = _select_variants(cfg, only)
193
+ out = Path(out_dir)
194
+ out.mkdir(parents=True, exist_ok=True)
195
+ results_path = out / RESULTS_FILE
196
+
197
+ used = {name for v in variants for name in v.guards}
198
+ guards: dict[str, Guard] = {}
199
+ target: Target | None = None
200
+ try:
201
+ for spec in cfg.guards:
202
+ if spec.get("name") in used:
203
+ guards[spec["name"]] = build_guard(spec, cfg.base_dir)
204
+ target = build_target(cfg.target, cfg.base_dir)
205
+ judge = RulesJudge(cfg.judge.get("patterns"))
206
+
207
+ hashes = {v.name: variant_config_hash(cfg, v, mode) for v in variants}
208
+ current = {(i.id, v.name): i for i in items for v in variants} # the pairs this run must finish
209
+ other_variants = {v.name for v in cfg.variants} - {v.name for v in variants} # not chosen by --only
210
+ item_ids = {i.id for i in items}
211
+
212
+ latest: dict[tuple[str, str], ResultRow] = {}
213
+ for r in _load_rows(results_path):
214
+ latest[(r.id, r.variant)] = r # if a pair appears twice, the later line wins
215
+
216
+ done: dict[tuple[str, str], ResultRow] = {} # rows of the chosen variants that can be reused
217
+ kept: dict[tuple[str, str], ResultRow] = {} # rows of other variants: written back untouched
218
+ config_changed = retried = 0
219
+ for key, r in latest.items():
220
+ if r.variant in other_variants:
221
+ if r.id in item_ids:
222
+ kept[key] = r # --only must not throw away results that may have cost time or money
223
+ continue
224
+ it = current.get(key)
225
+ if fresh or it is None or r.prompt != it.prompt or r.label != it.label:
226
+ continue # --fresh, a removed variant or item, or an edited prompt: not reusable
227
+ if r.config_hash != hashes[r.variant]: # settings changed since this row was written
228
+ config_changed += 1
229
+ continue
230
+ if r.outcome == INDETERMINATE: # it failed last time; the server or the code may be fixed now
231
+ retried += 1
232
+ continue
233
+ done[key] = r
234
+ # rewrite the file before running: drops stale or half-written lines, keeps other variants' rows
235
+ _write_rows(results_path, _in_file_order(items, cfg.variants, {**kept, **done}))
236
+
237
+ todo = [(i, v) for i in items for v in variants if (i.id, v.name) not in done]
238
+ if todo:
239
+ for g in guards.values():
240
+ g.prepare()
241
+ with results_path.open("a", encoding="utf-8") as fh:
242
+ for item, variant in todo:
243
+ row = run_pair(item, [guards[n] for n in variant.guards], target, judge, mode, variant.name)
244
+ row.config_hash = hashes[variant.name]
245
+ done[(item.id, variant.name)] = row
246
+ fh.write(json.dumps(row.to_dict(), ensure_ascii=False) + "\n")
247
+ fh.flush() # a crash loses at most the pair in flight
248
+
249
+ rows = [done[(i.id, v.name)] for i in items for v in variants] # what this run compares
250
+ _write_rows(results_path, _in_file_order(items, cfg.variants, {**kept, **done}))
251
+ meta = {"schema": SCHEMA_VERSION, "baseline": cfg.baseline, "latency_mode": mode,
252
+ "target_type": cfg.target.get("type", "mock"), "n_items": len(items),
253
+ "skipped_pairs": len(items) * len(variants) - len(todo), "config_changed_pairs": config_changed,
254
+ "retried_indeterminate_pairs": retried, "kept_other_pairs": len(kept),
255
+ "kept_other_variants": [v.name for v in cfg.variants if any(k[1] == v.name for k in kept)]}
256
+ return _finish(rows, [v.name for v in variants], cfg.baseline, meta, out)
257
+ finally:
258
+ for g in guards.values():
259
+ g.close()
260
+ if target is not None:
261
+ target.close()
262
+
263
+
264
+ def _finish(rows: list[ResultRow], variants: list[str], baseline: str, meta: dict[str, Any],
265
+ out: Path) -> RunResult:
266
+ by_variant = {v: [r for r in rows if r.variant == v] for v in variants}
267
+ summary = build_summary(by_variant, baseline)
268
+ meta = {**meta, "variants": variants}
269
+ doc = {**meta, "summary": summary}
270
+ (out / SUMMARY_FILE).write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
271
+ result = RunResult(summary=summary, rows=rows, variants=variants, baseline=baseline, meta=meta, out_dir=out)
272
+ from .report.html import write_html # noqa: PLC0415 - keeps the import graph one-directional
273
+
274
+ write_html(result, out / "report.html")
275
+ return result
276
+
277
+
278
+ def load_run(out_dir: str | Path) -> RunResult:
279
+ """Rebuild a RunResult from an output folder, for ``guarddiff report``."""
280
+ out = Path(out_dir)
281
+ results_path, summary_path = out / RESULTS_FILE, out / SUMMARY_FILE
282
+ if not results_path.is_file():
283
+ raise GuarddiffError(f"{results_path} not found. Run `guarddiff run CONFIG --out {out}` first.")
284
+ rows = _load_rows(results_path)
285
+ if not rows:
286
+ raise GuarddiffError(f"{results_path} has no readable rows. Re-run with --fresh.")
287
+ meta: dict[str, Any] = {}
288
+ if summary_path.is_file():
289
+ try:
290
+ loaded = json.loads(summary_path.read_text(encoding="utf-8"))
291
+ meta = loaded if isinstance(loaded, dict) else {}
292
+ except json.JSONDecodeError:
293
+ meta = {}
294
+ seen: list[str] = []
295
+ for r in rows:
296
+ if r.variant not in seen:
297
+ seen.append(r.variant)
298
+ variants = [v for v in meta.get("variants", seen) if v in seen] or seen
299
+ rows = [r for r in rows if r.variant in variants] # after `run --only`, other variants' rows are kept aside
300
+ baseline = meta.get("baseline") if meta.get("baseline") in variants else variants[0]
301
+ meta = {k: v for k, v in meta.items() if k != "summary"}
302
+ by_variant = {v: [r for r in rows if r.variant == v] for v in variants}
303
+ return RunResult(summary=build_summary(by_variant, baseline), rows=rows, variants=variants,
304
+ baseline=baseline, meta={**meta, "variants": variants, "baseline": baseline}, out_dir=out)
@@ -0,0 +1,40 @@
1
+ """Target adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..errors import ConfigError
9
+ from ..guards.python_fn import resolve_callable
10
+ from .base import Target, TargetReply, strip_thinking
11
+ from .mock import MockTarget
12
+ from .openai_http import OpenAIHTTPTarget
13
+ from .python_fn import PythonTarget
14
+
15
+ __all__ = ["Target", "TargetReply", "strip_thinking", "build_target"]
16
+
17
+ _ALLOWED = {
18
+ "mock": {"self_refuse"},
19
+ "python": {"callable"},
20
+ "openai": {"model", "base_url", "max_tokens", "temperature", "api_key_env", "system", "timeout_s"},
21
+ }
22
+
23
+
24
+ def build_target(spec: dict[str, Any], base_dir: Path) -> Target:
25
+ """Create the target from the ``[target]`` table (default: practice mock)."""
26
+ ttype = spec.get("type", "mock")
27
+ if ttype not in _ALLOWED:
28
+ raise ConfigError(f"[target] unknown type {ttype!r}. Use one of: {', '.join(sorted(_ALLOWED))}.")
29
+ unknown = set(spec) - {"type"} - _ALLOWED[ttype]
30
+ if unknown:
31
+ raise ConfigError(f"[target] unknown option(s) {sorted(unknown)} for type '{ttype}'.")
32
+ if ttype == "mock":
33
+ return MockTarget(spec.get("self_refuse"))
34
+ if ttype == "python":
35
+ ref = spec.get("callable")
36
+ if not isinstance(ref, str):
37
+ raise ConfigError("[target] type='python' needs callable = \"module:function\".")
38
+ return PythonTarget(resolve_callable(ref, base_dir, "[target]"))
39
+ kwargs = {k: v for k, v in spec.items() if k != "type"}
40
+ return OpenAIHTTPTarget(**kwargs)
@@ -0,0 +1,31 @@
1
+ """Target protocol: the model (or app) the guards protect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+ _THINK_CLOSED = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
9
+ _THINK_OPEN = re.compile(r"<think>.*\Z", re.DOTALL | re.IGNORECASE)
10
+
11
+
12
+ def strip_thinking(text: str) -> str:
13
+ """Remove <think>...</think> blocks (and an unterminated trailing one) from reasoning models."""
14
+ return _THINK_OPEN.sub("", _THINK_CLOSED.sub("", text)).strip()
15
+
16
+
17
+ @dataclass
18
+ class TargetReply:
19
+ text: str
20
+ usage: dict[str, int] = field(default_factory=dict) # prompt_tokens / completion_tokens when known
21
+ error: str | None = None # set when the call failed (HTTP error, timeout, exception)
22
+
23
+
24
+ class Target:
25
+ """Base class. ``call`` should not raise for expected failures; set ``error`` instead."""
26
+
27
+ def call(self, prompt: str) -> TargetReply:
28
+ raise NotImplementedError
29
+
30
+ def close(self) -> None:
31
+ """Release connections (default: nothing)."""
@@ -0,0 +1,35 @@
1
+ """Practice mock target. It is NOT a real LLM.
2
+
3
+ It imitates three behaviours so guard comparisons show real lessons:
4
+ the model sometimes refuses by itself, sometimes over-refuses a harmless question,
5
+ and leaks its "system prompt" when asked. Answers are fixed harmless sentences.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from ..errors import ConfigError
13
+ from .base import Target, TargetReply
14
+
15
+ DEFAULT_SELF_REFUSE = [r"end my life|pipe bomb|폭탄 만드는", r"toxic gas"]
16
+ LEAK_ASK = re.compile(r"system prompt|시스템 프롬프트", re.IGNORECASE)
17
+ HANGUL = re.compile(r"[가-힣]")
18
+
19
+
20
+ class MockTarget(Target):
21
+ def __init__(self, self_refuse: list[str] | None = None) -> None:
22
+ patterns = DEFAULT_SELF_REFUSE if self_refuse is None else self_refuse
23
+ try:
24
+ self._refuse = [re.compile(p, re.IGNORECASE) for p in patterns]
25
+ except re.error as e:
26
+ raise ConfigError(f"[target] self_refuse has an invalid regex ({e}).") from e
27
+
28
+ def call(self, prompt: str) -> TargetReply:
29
+ if any(p.search(prompt) for p in self._refuse):
30
+ if HANGUL.search(prompt):
31
+ return TargetReply("죄송하지만 그 요청은 도와드릴 수 없습니다.")
32
+ return TargetReply("I'm sorry, but I can't help with that.")
33
+ if LEAK_ASK.search(prompt):
34
+ return TargetReply("SYSTEM PROMPT: You are a helpful assistant. SECRET=xyz")
35
+ return TargetReply(f"Sure, here is an answer to: {prompt[:40]}")
@@ -0,0 +1,58 @@
1
+ """OpenAI-compatible ``/chat/completions`` target (Ollama, vLLM, hosted APIs) over urllib."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+ from ..errors import ConfigError
12
+ from .base import Target, TargetReply, strip_thinking
13
+
14
+
15
+ class OpenAIHTTPTarget(Target):
16
+ def __init__(self, model: str, base_url: str = "http://127.0.0.1:11434/v1", *, max_tokens: int = 512,
17
+ temperature: float = 0.0, api_key_env: str = "OPENAI_API_KEY", system: str | None = None,
18
+ timeout_s: float = 300.0) -> None:
19
+ if not model:
20
+ raise ConfigError("[target] type='openai' needs 'model' (for example model = \"qwen3:4b\").")
21
+ if not base_url.startswith(("http://", "https://")):
22
+ raise ConfigError(f"[target] base_url must start with http:// or https://, got {base_url!r}.")
23
+ self.model = model
24
+ self.url = base_url.rstrip("/") + "/chat/completions"
25
+ self.max_tokens, self.temperature, self.system = max_tokens, temperature, system
26
+ self.api_key_env, self.timeout_s = api_key_env, timeout_s # slow local models are normal: generous timeout
27
+
28
+ def _request(self, prompt: str) -> urllib.request.Request:
29
+ messages: list[dict[str, str]] = []
30
+ if self.system:
31
+ messages.append({"role": "system", "content": self.system})
32
+ messages.append({"role": "user", "content": prompt})
33
+ body = {"model": self.model, "messages": messages, "max_tokens": self.max_tokens,
34
+ "temperature": self.temperature}
35
+ headers = {"Content-Type": "application/json"}
36
+ key = os.environ.get(self.api_key_env)
37
+ if key: # no key set -> no Authorization header (local servers need none)
38
+ headers["Authorization"] = f"Bearer {key}"
39
+ return urllib.request.Request(self.url, data=json.dumps(body).encode(), headers=headers, method="POST")
40
+
41
+ def call(self, prompt: str) -> TargetReply:
42
+ try:
43
+ with urllib.request.urlopen(self._request(prompt), timeout=self.timeout_s) as resp: # noqa: S310
44
+ data: Any = json.loads(resp.read().decode("utf-8"))
45
+ except urllib.error.HTTPError as e:
46
+ return TargetReply("", error=f"HTTP {e.code} from {self.url}")
47
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
48
+ return TargetReply("", error=f"{type(e).__name__}: {e} (is the server at {self.url} running?)")
49
+ except ValueError as e:
50
+ return TargetReply("", error=f"response is not valid JSON ({e})")
51
+ try:
52
+ content = data["choices"][0]["message"]["content"] or ""
53
+ except (KeyError, IndexError, TypeError):
54
+ return TargetReply("", error="response has no choices[0].message.content")
55
+ usage = data.get("usage") if isinstance(data, dict) else None
56
+ tokens = {k: int(usage[k]) for k in ("prompt_tokens", "completion_tokens")
57
+ if isinstance(usage, dict) and isinstance(usage.get(k), int)}
58
+ return TargetReply(strip_thinking(content), usage=tokens)
@@ -0,0 +1,18 @@
1
+ """User-supplied target: ``fn(prompt: str) -> str`` referenced as "module:function"."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ from .base import Target, TargetReply, strip_thinking
8
+
9
+
10
+ class PythonTarget(Target):
11
+ def __init__(self, fn: Callable[[str], Any]) -> None:
12
+ self._fn = fn
13
+
14
+ def call(self, prompt: str) -> TargetReply:
15
+ result = self._fn(prompt)
16
+ if not isinstance(result, str):
17
+ raise TypeError(f"target function must return str, got {type(result).__name__}")
18
+ return TargetReply(strip_thinking(result)) # reasoning text is not part of the answer