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.
- guarddiff/__init__.py +16 -0
- guarddiff/__main__.py +7 -0
- guarddiff/cli.py +192 -0
- guarddiff/config.py +101 -0
- guarddiff/errors.py +23 -0
- guarddiff/examples/offline/guarddiff.toml +48 -0
- guarddiff/examples/offline/items.jsonl +18 -0
- guarddiff/examples/qwen3guard-recorded/guarddiff.toml +34 -0
- guarddiff/examples/qwen3guard-recorded/items.jsonl +18 -0
- guarddiff/examples/qwen3guard-recorded/qwen3guard_recorded.jsonl +18 -0
- guarddiff/gate.py +81 -0
- guarddiff/guards/__init__.py +59 -0
- guarddiff/guards/base.py +33 -0
- guarddiff/guards/python_fn.py +79 -0
- guarddiff/guards/qwen3guard.py +115 -0
- guarddiff/guards/regex.py +29 -0
- guarddiff/guards/replay.py +64 -0
- guarddiff/items.py +52 -0
- guarddiff/judges/__init__.py +5 -0
- guarddiff/judges/rules.py +41 -0
- guarddiff/latency.py +16 -0
- guarddiff/metrics.py +125 -0
- guarddiff/outcomes.py +54 -0
- guarddiff/packs/LICENSE +5 -0
- guarddiff/packs/starter-en-ko.jsonl +18 -0
- guarddiff/report/__init__.py +1 -0
- guarddiff/report/html.py +107 -0
- guarddiff/report/markdown.py +66 -0
- guarddiff/runner.py +304 -0
- guarddiff/targets/__init__.py +40 -0
- guarddiff/targets/base.py +31 -0
- guarddiff/targets/mock.py +35 -0
- guarddiff/targets/openai_http.py +58 -0
- guarddiff/targets/python_fn.py +18 -0
- guarddiff-0.1.0.dist-info/METADATA +627 -0
- guarddiff-0.1.0.dist-info/RECORD +39 -0
- guarddiff-0.1.0.dist-info/WHEEL +4 -0
- guarddiff-0.1.0.dist-info/entry_points.txt +2 -0
- guarddiff-0.1.0.dist-info/licenses/LICENSE +21 -0
guarddiff/report/html.py
ADDED
|
@@ -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}–{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
|