contambench 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.
- contambench/__init__.py +5 -0
- contambench/cli.py +245 -0
- contambench/data.py +142 -0
- contambench/examples/calibrate.yaml +20 -0
- contambench/examples/control_semantic_suffix.yaml +19 -0
- contambench/examples/output_caps.yaml +19 -0
- contambench/examples/simulate.yaml +18 -0
- contambench/examples/sweep.yaml +20 -0
- contambench/export.py +101 -0
- contambench/harness/__init__.py +0 -0
- contambench/harness/analysis/__init__.py +0 -0
- contambench/harness/analysis/calibrate.py +221 -0
- contambench/harness/analysis/calibrate_capacity.py +233 -0
- contambench/harness/analysis/capacity_util.py +73 -0
- contambench/harness/analysis/common.py +780 -0
- contambench/harness/arrival.py +95 -0
- contambench/harness/client.py +433 -0
- contambench/harness/config.py +222 -0
- contambench/harness/dataset.py +260 -0
- contambench/harness/experiment.py +402 -0
- contambench/harness/metrics.py +212 -0
- contambench/harness/simulate.py +126 -0
- contambench/harness/storage.py +380 -0
- contambench/harness/telemetry.py +400 -0
- contambench/harness/validation.py +193 -0
- contambench/harness/workload.py +356 -0
- contambench/report.py +104 -0
- contambench/schemas/contamination_export.schema.json +22 -0
- contambench/schemas/contamination_manifest.schema.json +33 -0
- contambench/schemas/request_trace.schema.json +44 -0
- contambench/schemas/stressor_pair.schema.json +21 -0
- contambench/score.py +112 -0
- contambench-0.1.0.dist-info/METADATA +189 -0
- contambench-0.1.0.dist-info/RECORD +37 -0
- contambench-0.1.0.dist-info/WHEEL +4 -0
- contambench-0.1.0.dist-info/entry_points.txt +2 -0
- contambench-0.1.0.dist-info/licenses/LICENSE +201 -0
contambench/__init__.py
ADDED
contambench/cli.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""contam-bench command-line interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from contambench import __version__, data
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _data_base(args) -> Path | None:
|
|
13
|
+
return Path(args.data_dir).expanduser() if getattr(args, "data_dir", None) else None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_data(args) -> int:
|
|
17
|
+
base = _data_base(args)
|
|
18
|
+
if args.action == "fetch":
|
|
19
|
+
parts = [args.subset] if args.subset else None
|
|
20
|
+
dest = data.fetch(parts, base=base, source=args.source)
|
|
21
|
+
print(f"data ready at {dest}")
|
|
22
|
+
elif args.action == "path":
|
|
23
|
+
print(data.root(base))
|
|
24
|
+
elif args.action == "status":
|
|
25
|
+
print(json.dumps({"root": str(data.root(base)), **data.status(base)}, indent=2))
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_export(args) -> int:
|
|
30
|
+
from contambench import export
|
|
31
|
+
|
|
32
|
+
pairs_file = Path(args.pairs) if args.pairs else data.suite_file(args.suite, _data_base(args))
|
|
33
|
+
pairs = export.load_pairs(pairs_file)
|
|
34
|
+
benign = export.read_benign_csv(Path(args.benign)) if args.benign else export.benign_pool(pairs, args.rows)
|
|
35
|
+
rows, manifest = export.contaminate(benign, pairs, args.c, args.k)
|
|
36
|
+
manifest = {"suite": args.suite or str(pairs_file), "seed": args.seed,
|
|
37
|
+
"seed_note": "recorded for compatibility; the selection rule is deterministic", **manifest}
|
|
38
|
+
man = export.write(Path(args.out), rows, manifest)
|
|
39
|
+
print(f"wrote {args.out} ({manifest['n_attack']}/{manifest['n_rows']} stressor rows, "
|
|
40
|
+
f"{manifest['distinct_attack_prompts']} distinct prompts) and {man}")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_run(args, extra: list[str]) -> int:
|
|
45
|
+
from contambench.harness import experiment
|
|
46
|
+
|
|
47
|
+
experiment.main(extra)
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_calibrate(args) -> int:
|
|
52
|
+
import pandas as pd
|
|
53
|
+
|
|
54
|
+
from contambench.harness import experiment
|
|
55
|
+
from contambench.harness.analysis import calibrate_capacity
|
|
56
|
+
from contambench.harness.config import config_from_dict
|
|
57
|
+
|
|
58
|
+
if args.config:
|
|
59
|
+
from contambench.harness.config import load_config
|
|
60
|
+
|
|
61
|
+
cfg = load_config(args.config)
|
|
62
|
+
cfg.contamination_ratio = [0.0] # calibration is benign by definition
|
|
63
|
+
if args.endpoint:
|
|
64
|
+
cfg.endpoint = args.endpoint
|
|
65
|
+
if args.api_key:
|
|
66
|
+
cfg.api_key = args.api_key
|
|
67
|
+
if args.simulate:
|
|
68
|
+
cfg.simulate = True
|
|
69
|
+
if args.results_dir:
|
|
70
|
+
cfg.results_dir = args.results_dir
|
|
71
|
+
run_dir = experiment.run_experiment(cfg)
|
|
72
|
+
num_clients = max(cfg.client_counts)
|
|
73
|
+
else:
|
|
74
|
+
if not args.suite:
|
|
75
|
+
raise SystemExit("calibrate needs --config or --suite")
|
|
76
|
+
run_dir, num_clients = None, args.num_clients
|
|
77
|
+
if run_dir is None:
|
|
78
|
+
cfg = config_from_dict({
|
|
79
|
+
"name": "capacity_calibrate",
|
|
80
|
+
"endpoint": args.endpoint, "api_key": args.api_key, "model": args.model,
|
|
81
|
+
"attack_model": data.SUITES[data.suite_slug(args.suite)],
|
|
82
|
+
"attack_file": str(data.suite_file(args.suite, _data_base(args))),
|
|
83
|
+
"contamination_ratio": [0.0], "num_clients": args.num_clients,
|
|
84
|
+
"arrival_rate": [float(x) for x in args.arrival_rates.split(",")],
|
|
85
|
+
"arrival_model": "poisson", "requests_per_client": args.requests_per_client,
|
|
86
|
+
"num_trials": args.num_trials, "max_tokens": args.max_tokens, "temperature": 0.7,
|
|
87
|
+
"auto_slo": True, "seed": args.seed, "results_dir": args.results_dir, "simulate": args.simulate,
|
|
88
|
+
})
|
|
89
|
+
run_dir = experiment.run_experiment(cfg)
|
|
90
|
+
df = pd.read_parquet(Path(run_dir) / "requests.parquet")
|
|
91
|
+
out = Path(run_dir) / "calibrate-capacity"
|
|
92
|
+
res = calibrate_capacity.run(df, out, num_clients=num_clients)
|
|
93
|
+
print(json.dumps({k: res.get(k) for k in ("capacity_throughput_req_s", "recommended_arrival_rates")}, indent=2))
|
|
94
|
+
print(f"calibration written to {out}")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_score(args) -> int:
|
|
99
|
+
from contambench import score
|
|
100
|
+
|
|
101
|
+
df = score.load(args.run)
|
|
102
|
+
rates = [args.arrival_rate] if args.arrival_rate is not None else sorted(df.arrival_rate.unique())
|
|
103
|
+
results = [score.sweep(df, r) for r in rates]
|
|
104
|
+
if args.json:
|
|
105
|
+
print(json.dumps({"sweeps": results, "stage_b": score.stage_b(df)}, indent=2))
|
|
106
|
+
else:
|
|
107
|
+
for s in results:
|
|
108
|
+
print(score.format_sweep(s), end="\n\n")
|
|
109
|
+
sb = score.stage_b(df)
|
|
110
|
+
if sb["stressor_cap_hit"] is not None:
|
|
111
|
+
print(f"Stage B: {100 * sb['stressor_cap_hit']:.1f}% of {sb['n_stressor']} stressor requests reached the "
|
|
112
|
+
f"output cap ({100 * sb['benign_cap_hit']:.2f}% of {sb['n_benign']} benign)")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_examples(args) -> int:
|
|
117
|
+
import shutil
|
|
118
|
+
from importlib.resources import files
|
|
119
|
+
|
|
120
|
+
dest = Path(args.dest)
|
|
121
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
src = files("contambench") / "examples"
|
|
123
|
+
for f in sorted(p for p in src.iterdir() if p.name.endswith(".yaml")):
|
|
124
|
+
target = dest / f.name
|
|
125
|
+
if target.exists() and not args.force:
|
|
126
|
+
print(f"kept existing {target}")
|
|
127
|
+
continue
|
|
128
|
+
target.write_text(f.read_text(encoding="utf-8"), encoding="utf-8")
|
|
129
|
+
print(f"wrote {target}")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_report(args) -> int:
|
|
134
|
+
from contambench import report
|
|
135
|
+
|
|
136
|
+
print(report.render(_data_base(args), check=not args.no_check))
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_telemetry(args, extra: list[str]) -> int:
|
|
141
|
+
from contambench.harness import telemetry
|
|
142
|
+
|
|
143
|
+
return telemetry.main(extra)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_guidellm(args, extra: list[str]) -> int:
|
|
147
|
+
import shutil
|
|
148
|
+
import subprocess
|
|
149
|
+
|
|
150
|
+
exe = shutil.which("guidellm")
|
|
151
|
+
if not exe:
|
|
152
|
+
print("GuideLLM is not installed: pip install 'contambench[guidellm]'", file=sys.stderr)
|
|
153
|
+
return 2
|
|
154
|
+
return subprocess.call([exe, *extra])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
158
|
+
p = argparse.ArgumentParser(prog="contam-bench", description=__doc__)
|
|
159
|
+
p.add_argument("--version", action="version", version=f"contambench {__version__}")
|
|
160
|
+
p.add_argument("--data-dir", help="data root (default: $CONTAMBENCH_DATA or ~/.cache/contambench/<version>)")
|
|
161
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
162
|
+
|
|
163
|
+
d = sub.add_parser("data", help="fetch, locate, or inspect the data record")
|
|
164
|
+
d.add_argument("action", choices=["fetch", "path", "status"])
|
|
165
|
+
d.add_argument("--subset", choices=list(data.PARTS), help="fetch one product only")
|
|
166
|
+
d.add_argument("--from", dest="source", help="copy the record from a local directory instead of Zenodo")
|
|
167
|
+
|
|
168
|
+
e = sub.add_parser("export", help="write a D2 labeled workload at contamination ratio c")
|
|
169
|
+
e.add_argument("--suite", help=f"one of: {', '.join(data.SUITES)}")
|
|
170
|
+
e.add_argument("--pairs", help="use a pairs file instead of a fetched suite")
|
|
171
|
+
e.add_argument("--benign", help="benign pool CSV (prompt_id, prompt, success_rate); default: tile the suite")
|
|
172
|
+
e.add_argument("--c", type=float, required=True)
|
|
173
|
+
e.add_argument("--k", type=int, default=50, help="draw stressors from the top-k prompts (default 50)")
|
|
174
|
+
e.add_argument("--rows", type=int, default=1000)
|
|
175
|
+
e.add_argument("--seed", type=int, default=23)
|
|
176
|
+
e.add_argument("--out", required=True)
|
|
177
|
+
|
|
178
|
+
sub.add_parser("run", help="execute a workload against an endpoint (options as in the harness; see run --help)",
|
|
179
|
+
add_help=False)
|
|
180
|
+
|
|
181
|
+
c = sub.add_parser("calibrate", help="benign arrival-rate sweep -> capacity and operating points")
|
|
182
|
+
c.add_argument("--config", help="harness YAML; contamination is forced to 0")
|
|
183
|
+
c.add_argument("--endpoint", required=False)
|
|
184
|
+
c.add_argument("--api-key")
|
|
185
|
+
c.add_argument("--model", help="model name sent in the request body")
|
|
186
|
+
c.add_argument("--suite", help="suite whose benign prompts drive the sweep (when no --config)")
|
|
187
|
+
c.add_argument("--arrival-rates", default="0.1,0.2,0.3,0.4,0.5,0.6,0.8,1.0")
|
|
188
|
+
c.add_argument("--num-clients", type=int, default=50)
|
|
189
|
+
c.add_argument("--requests-per-client", type=int, default=20)
|
|
190
|
+
c.add_argument("--num-trials", type=int, default=1)
|
|
191
|
+
c.add_argument("--max-tokens", type=int, default=4096)
|
|
192
|
+
c.add_argument("--seed", type=int, default=23)
|
|
193
|
+
c.add_argument("--results-dir")
|
|
194
|
+
c.add_argument("--simulate", action="store_true", help="synthetic latencies, no endpoint")
|
|
195
|
+
|
|
196
|
+
s = sub.add_parser("score", help="apply the SLO oracle and label split to a D3-format run")
|
|
197
|
+
s.add_argument("run", help="run directory or requests.parquet/csv")
|
|
198
|
+
s.add_argument("--arrival-rate", type=float)
|
|
199
|
+
s.add_argument("--json", action="store_true")
|
|
200
|
+
|
|
201
|
+
ex = sub.add_parser("examples", help="copy the example YAML campaign configurations")
|
|
202
|
+
ex.add_argument("dest", nargs="?", default="examples")
|
|
203
|
+
ex.add_argument("--force", action="store_true", help="overwrite existing files")
|
|
204
|
+
|
|
205
|
+
r = sub.add_parser("report", help="reprint the paper's tables from the data record")
|
|
206
|
+
r.add_argument("--no-check", action="store_true", help="skip the comparison with the paper's values")
|
|
207
|
+
|
|
208
|
+
sub.add_parser("telemetry", help="poll a vLLM /metrics endpoint into a JSONL log (see telemetry --help)",
|
|
209
|
+
add_help=False)
|
|
210
|
+
sub.add_parser("guidellm", help="run GuideLLM (pass its arguments after --)", add_help=False)
|
|
211
|
+
return p
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _command_index(argv: list[str]) -> int | None:
|
|
215
|
+
"""Index of the subcommand token, skipping global options."""
|
|
216
|
+
i = 0
|
|
217
|
+
while i < len(argv):
|
|
218
|
+
a = argv[i]
|
|
219
|
+
if a == "--data-dir":
|
|
220
|
+
i += 2
|
|
221
|
+
elif a.startswith("-"):
|
|
222
|
+
i += 1
|
|
223
|
+
else:
|
|
224
|
+
return i
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main(argv: list[str] | None = None) -> int:
|
|
229
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
230
|
+
i = _command_index(argv)
|
|
231
|
+
# `run`, `telemetry`, and `guidellm` forward everything after the subcommand.
|
|
232
|
+
passthrough = {"run": cmd_run, "guidellm": cmd_guidellm, "telemetry": cmd_telemetry}
|
|
233
|
+
if i is not None and argv[i] in passthrough:
|
|
234
|
+
args = build_parser().parse_args(argv[: i + 1])
|
|
235
|
+
extra = argv[i + 1:]
|
|
236
|
+
if extra[:1] == ["--"]:
|
|
237
|
+
extra = extra[1:]
|
|
238
|
+
return passthrough[argv[i]](args, extra)
|
|
239
|
+
args = build_parser().parse_args(argv)
|
|
240
|
+
return {"data": cmd_data, "export": cmd_export, "calibrate": cmd_calibrate, "score": cmd_score,
|
|
241
|
+
"report": cmd_report, "examples": cmd_examples}[args.cmd](args)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
sys.exit(main())
|
contambench/data.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Locate, download, and verify the ContamBench data record.
|
|
2
|
+
|
|
3
|
+
The record holds three zips (D1, D2, D3) plus documentation and a SHA256SUMS file.
|
|
4
|
+
``fetch`` copies them from Zenodo (or from a local directory with ``source=``),
|
|
5
|
+
checks every hash, and extracts the zips into the data root.
|
|
6
|
+
|
|
7
|
+
Data root, in order of precedence: the ``root`` argument, ``$CONTAMBENCH_DATA``,
|
|
8
|
+
then ``~/.cache/contambench/<version>``.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import urllib.request
|
|
16
|
+
import zipfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from contambench import __data_version__
|
|
20
|
+
|
|
21
|
+
# Zenodo record of data version 0.1.0 (DOI 10.5281/zenodo.23482023).
|
|
22
|
+
ZENODO_RECORD = os.environ.get("CONTAMBENCH_ZENODO_RECORD", "23482023")
|
|
23
|
+
ZENODO_FILES = "https://zenodo.org/records/{record}/files/{name}?download=1"
|
|
24
|
+
|
|
25
|
+
PARTS = {"d1": "d1_stressors", "d2": "d2_workloads", "d3": "d3_execution"}
|
|
26
|
+
DOCS = ["README.md", "DATASET_CARD.md", "LICENSE-DATA.md", "CITATION.cff", "runs_index.json"]
|
|
27
|
+
|
|
28
|
+
# Short suite names accepted on the command line, mapped to model ids.
|
|
29
|
+
SUITES = {
|
|
30
|
+
"llama-3.2-3b-instruct": "meta-llama/Llama-3.2-3B-Instruct",
|
|
31
|
+
"qwen2.5-7b-instruct": "Qwen/Qwen2.5-7B-Instruct",
|
|
32
|
+
"llama-3.1-8b-instruct": "meta-llama/Meta-Llama-3.1-8B-Instruct",
|
|
33
|
+
"mistral-7b-instruct-v0.3": "mistralai/Mistral-7B-Instruct-v0.3",
|
|
34
|
+
"qwen2.5-math-7b": "Qwen/Qwen2.5-Math-7B",
|
|
35
|
+
}
|
|
36
|
+
CAMPAIGNS = {
|
|
37
|
+
"aws_llama3b": "llama-3.2-3b-instruct",
|
|
38
|
+
"aws_qwen7b": "qwen2.5-7b-instruct",
|
|
39
|
+
"runpod_llama8b": "llama-3.1-8b-instruct",
|
|
40
|
+
"aws_mistral7b": "mistral-7b-instruct-v0.3",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DataNotFound(RuntimeError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def root(path: str | os.PathLike | None = None) -> Path:
|
|
49
|
+
if path:
|
|
50
|
+
return Path(path).expanduser()
|
|
51
|
+
env = os.environ.get("CONTAMBENCH_DATA")
|
|
52
|
+
if env:
|
|
53
|
+
return Path(env).expanduser()
|
|
54
|
+
return Path.home() / ".cache" / "contambench" / __data_version__
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require(part: str, base: Path | None = None) -> Path:
|
|
58
|
+
"""Path to an extracted product directory, with a helpful error if it is missing."""
|
|
59
|
+
d = (base or root()) / PARTS[part]
|
|
60
|
+
if not d.is_dir():
|
|
61
|
+
raise DataNotFound(
|
|
62
|
+
f"{PARTS[part]} not found under {d.parent}. Run `contam-bench data fetch` "
|
|
63
|
+
"(or set CONTAMBENCH_DATA to an extracted record)."
|
|
64
|
+
)
|
|
65
|
+
return d
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def suite_slug(name: str) -> str:
|
|
69
|
+
"""Accept a short suite name or a Hugging Face model id."""
|
|
70
|
+
if name in SUITES:
|
|
71
|
+
return name
|
|
72
|
+
for slug, model in SUITES.items():
|
|
73
|
+
if model.lower() == name.lower():
|
|
74
|
+
return slug
|
|
75
|
+
raise KeyError(f"unknown suite {name!r}; choose one of {', '.join(SUITES)}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def suite_file(name: str, base: Path | None = None) -> Path:
|
|
79
|
+
return require("d1", base) / "suites" / suite_slug(name) / "pairs.json"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sha256(path: Path) -> str:
|
|
83
|
+
h = hashlib.sha256()
|
|
84
|
+
with path.open("rb") as fh:
|
|
85
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
86
|
+
h.update(chunk)
|
|
87
|
+
return h.hexdigest()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _download(url: str, dst: Path) -> None:
|
|
91
|
+
tmp = dst.with_suffix(dst.suffix + ".part")
|
|
92
|
+
with urllib.request.urlopen(url) as r, tmp.open("wb") as fh:
|
|
93
|
+
shutil.copyfileobj(r, fh, length=1 << 20)
|
|
94
|
+
tmp.replace(dst)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _get(name: str, dst: Path, source: Path | None) -> None:
|
|
98
|
+
if source is not None:
|
|
99
|
+
shutil.copyfile(source / name, dst)
|
|
100
|
+
return
|
|
101
|
+
if not ZENODO_RECORD:
|
|
102
|
+
raise DataNotFound(
|
|
103
|
+
"The Zenodo record id is not set in this build. Pass --from <dir> with a local copy "
|
|
104
|
+
"of the record, or set CONTAMBENCH_ZENODO_RECORD."
|
|
105
|
+
)
|
|
106
|
+
_download(ZENODO_FILES.format(record=ZENODO_RECORD, name=name), dst)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def fetch(parts: list[str] | None = None, *, base: Path | None = None, source: str | None = None,
|
|
110
|
+
log=print) -> Path:
|
|
111
|
+
"""Download (or copy), verify, and extract the requested products."""
|
|
112
|
+
dest = base or root()
|
|
113
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
src = Path(source).expanduser() if source else None
|
|
115
|
+
_get("SHA256SUMS", dest / "SHA256SUMS", src)
|
|
116
|
+
sums = {}
|
|
117
|
+
for line in (dest / "SHA256SUMS").read_text().splitlines():
|
|
118
|
+
digest, name = line.split(maxsplit=1)
|
|
119
|
+
sums[name.strip()] = digest
|
|
120
|
+
wanted = [f"{PARTS[p]}.zip" for p in (parts or list(PARTS))]
|
|
121
|
+
names = wanted + [d for d in DOCS if d in sums] + (["schemas.zip"] if "schemas.zip" in sums else [])
|
|
122
|
+
for name in names:
|
|
123
|
+
target = dest / name
|
|
124
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
if not (target.exists() and _sha256(target) == sums[name]):
|
|
126
|
+
log(f"fetching {name}")
|
|
127
|
+
_get(name, target, src)
|
|
128
|
+
if _sha256(target) != sums[name]:
|
|
129
|
+
raise RuntimeError(f"checksum mismatch for {name}")
|
|
130
|
+
for name in wanted + (["schemas.zip"] if "schemas.zip" in sums else []):
|
|
131
|
+
out = dest / name[:-4]
|
|
132
|
+
if out.is_dir():
|
|
133
|
+
shutil.rmtree(out)
|
|
134
|
+
with zipfile.ZipFile(dest / name) as z:
|
|
135
|
+
z.extractall(dest)
|
|
136
|
+
log(f"extracted {name[:-4]}/")
|
|
137
|
+
return dest
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def status(base: Path | None = None) -> dict[str, bool]:
|
|
141
|
+
b = base or root()
|
|
142
|
+
return {k: (b / v).is_dir() for k, v in PARTS.items()}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Step 1: benign capacity calibration. Raises the per-client arrival rate until
|
|
2
|
+
# achieved throughput stops rising, then places operating points at 60/80/90% of it.
|
|
3
|
+
# export OPENAI_API_KEY=... # key of YOUR endpoint, if it needs one
|
|
4
|
+
# contam-bench calibrate --config examples/calibrate.yaml --endpoint http://HOST:8000
|
|
5
|
+
name: capacity_calibrate
|
|
6
|
+
backend: openai
|
|
7
|
+
attack_model: meta-llama/Llama-3.2-3B-Instruct
|
|
8
|
+
workload_size: 100
|
|
9
|
+
contamination_ratio: [0.0]
|
|
10
|
+
num_clients: 50
|
|
11
|
+
arrival_rate: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0]
|
|
12
|
+
arrival_model: poisson
|
|
13
|
+
requests_per_client: 15
|
|
14
|
+
num_trials: 1
|
|
15
|
+
max_tokens: 4096
|
|
16
|
+
temperature: 0.7
|
|
17
|
+
timeout_s: 600
|
|
18
|
+
config_cooldown_s: 20
|
|
19
|
+
auto_slo: true
|
|
20
|
+
seed: 23
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Control run: a plain-language "keep writing" suffix in place of the optimized one.
|
|
2
|
+
# Swap the file for random_suffix.json or long_output_instruction.json for the other controls.
|
|
3
|
+
# contam-bench run --config examples/control_semantic_suffix.yaml --endpoint http://HOST:8000
|
|
4
|
+
name: control_semantic_suffix
|
|
5
|
+
backend: openai
|
|
6
|
+
attack_model: meta-llama/Llama-3.2-3B-Instruct
|
|
7
|
+
attack_file: contambench:d1_stressors/controls/semantic_suffix.json
|
|
8
|
+
contamination_ratio: [0.0, 0.02, 0.05, 0.10, 0.15]
|
|
9
|
+
num_clients: 50
|
|
10
|
+
arrival_rate: [0.27]
|
|
11
|
+
arrival_model: poisson
|
|
12
|
+
requests_per_client: 20
|
|
13
|
+
num_trials: 3
|
|
14
|
+
max_tokens: 4096
|
|
15
|
+
temperature: 0.7
|
|
16
|
+
timeout_s: 600
|
|
17
|
+
config_cooldown_s: 20
|
|
18
|
+
auto_slo: true
|
|
19
|
+
seed: 23
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Output-cap run: the same contaminated workload under several output caps.
|
|
2
|
+
# Include 0.0 in contamination_ratio if you want SLO budgets from the same run.
|
|
3
|
+
# contam-bench run --config examples/output_caps.yaml --endpoint http://HOST:8000
|
|
4
|
+
name: output_caps
|
|
5
|
+
backend: openai
|
|
6
|
+
attack_model: meta-llama/Llama-3.2-3B-Instruct
|
|
7
|
+
workload_size: 50
|
|
8
|
+
contamination_ratio: [0.0, 0.10]
|
|
9
|
+
num_clients: 50
|
|
10
|
+
arrival_rate: [0.23]
|
|
11
|
+
arrival_model: poisson
|
|
12
|
+
requests_per_client: 20
|
|
13
|
+
num_trials: 3
|
|
14
|
+
max_tokens: [512, 1024, 2048, 4096]
|
|
15
|
+
temperature: 0.7
|
|
16
|
+
timeout_s: 600
|
|
17
|
+
config_cooldown_s: 20
|
|
18
|
+
auto_slo: true
|
|
19
|
+
seed: 23
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Offline run with synthetic latencies. No endpoint and no GPU are needed.
|
|
2
|
+
# It exercises the harness and the scorer; its numbers are not measurements.
|
|
3
|
+
# contam-bench run --config examples/simulate.yaml
|
|
4
|
+
# contam-bench score contambench-results/raw/<run_id>
|
|
5
|
+
name: simulate_demo
|
|
6
|
+
simulate: true
|
|
7
|
+
attack_model: meta-llama/Llama-3.2-3B-Instruct # resolves the D1 suite in the fetched record
|
|
8
|
+
workload_size: 50
|
|
9
|
+
contamination_ratio: [0.0, 0.10]
|
|
10
|
+
num_clients: 4
|
|
11
|
+
arrival_rate: [1.0]
|
|
12
|
+
arrival_model: poisson
|
|
13
|
+
requests_per_client: 10
|
|
14
|
+
num_trials: 2
|
|
15
|
+
max_tokens: 256 # short outputs keep the simulation to about a minute
|
|
16
|
+
temperature: 0.7
|
|
17
|
+
auto_slo: true
|
|
18
|
+
seed: 23
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Step 2: contamination sweep at the calibrated operating points.
|
|
2
|
+
# Replace arrival_rate with the rates printed by `contam-bench calibrate`.
|
|
3
|
+
# contam-bench run --config examples/sweep.yaml --endpoint http://HOST:8000
|
|
4
|
+
# contam-bench score contambench-results/raw/<run_id>
|
|
5
|
+
name: contamination_sweep
|
|
6
|
+
backend: openai
|
|
7
|
+
attack_model: meta-llama/Llama-3.2-3B-Instruct
|
|
8
|
+
workload_size: 50
|
|
9
|
+
contamination_ratio: [0.0, 0.02, 0.05, 0.10, 0.15]
|
|
10
|
+
num_clients: 50
|
|
11
|
+
arrival_rate: [0.16, 0.23, 0.27]
|
|
12
|
+
arrival_model: poisson
|
|
13
|
+
requests_per_client: 20
|
|
14
|
+
num_trials: 3
|
|
15
|
+
max_tokens: 4096
|
|
16
|
+
temperature: 0.7
|
|
17
|
+
timeout_s: 600
|
|
18
|
+
config_cooldown_s: 20
|
|
19
|
+
auto_slo: true
|
|
20
|
+
seed: 23
|
contambench/export.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Write a D2 labeled workload: a replayable CSV plus a manifest.
|
|
2
|
+
|
|
3
|
+
The benign pool is the suite's benign prompts in suite order, cycled up to ``rows``
|
|
4
|
+
rows (or a user-supplied CSV with ``prompt_id, prompt, success_rate``). A fraction
|
|
5
|
+
``c`` of rows is replaced by the matching stressor prompt. Stressor rows are chosen
|
|
6
|
+
deterministically: among rows whose prompt is in the top-``k`` prompts by Stage-A
|
|
7
|
+
success rate, take the ``round(c * rows)`` rows with the highest success rate, with
|
|
8
|
+
ties broken by row index. This reproduces the exports shipped in D2.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_pairs(path: Path) -> list[dict]:
|
|
18
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
19
|
+
if isinstance(data, dict):
|
|
20
|
+
data = data.get("prompts") or data.get("entries") or []
|
|
21
|
+
if isinstance(data, dict):
|
|
22
|
+
data = list(data.values())
|
|
23
|
+
return [p for p in data if p.get("prompt") is not None and p.get("adv_prompt") is not None]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def benign_pool(pairs: list[dict], rows: int) -> list[dict]:
|
|
27
|
+
n = len(pairs)
|
|
28
|
+
return [
|
|
29
|
+
{"prompt_id": str(i % n), "prompt": pairs[i % n]["prompt"], "success_rate": str(float(pairs[i % n]["success_rate"]))}
|
|
30
|
+
for i in range(rows)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_benign_csv(path: Path) -> list[dict]:
|
|
35
|
+
with Path(path).open(newline="", encoding="utf-8") as fh:
|
|
36
|
+
rows = list(csv.DictReader(fh))
|
|
37
|
+
for col in ("prompt_id", "prompt", "success_rate"):
|
|
38
|
+
if rows and col not in rows[0]:
|
|
39
|
+
raise ValueError(f"{path} is missing column {col!r}")
|
|
40
|
+
return rows
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def contaminate(benign: list[dict], pairs: list[dict], c: float, k: int) -> tuple[list[dict], dict]:
|
|
44
|
+
if not 0.0 <= c <= 1.0:
|
|
45
|
+
raise ValueError("c must be in [0, 1]")
|
|
46
|
+
adv = {p["prompt"]: p["adv_prompt"] for p in pairs}
|
|
47
|
+
missing = {r["prompt"] for r in benign} - adv.keys()
|
|
48
|
+
if missing:
|
|
49
|
+
raise ValueError(f"{len(missing)} benign prompts have no stressor in the suite")
|
|
50
|
+
best: dict[str, float] = {}
|
|
51
|
+
for r in benign:
|
|
52
|
+
best[r["prompt_id"]] = max(best.get(r["prompt_id"], -1.0), float(r["success_rate"]))
|
|
53
|
+
ranked = sorted(best.items(), key=lambda kv: (-kv[1], int(kv[0])))
|
|
54
|
+
eligible = {pid for pid, _ in ranked[:k]}
|
|
55
|
+
idx = [i for i, r in enumerate(benign) if r["prompt_id"] in eligible]
|
|
56
|
+
n_attack = int(round(c * len(benign)))
|
|
57
|
+
if n_attack > len(idx):
|
|
58
|
+
raise ValueError(f"need {n_attack} stressor rows but only {len(idx)} rows belong to the top-{k} prompts")
|
|
59
|
+
idx.sort(key=lambda i: (-float(benign[i]["success_rate"]), i))
|
|
60
|
+
chosen = sorted(idx[:n_attack])
|
|
61
|
+
cs = set(chosen)
|
|
62
|
+
out = [
|
|
63
|
+
{
|
|
64
|
+
"prompt_id": r["prompt_id"],
|
|
65
|
+
"prompt": adv[r["prompt"]] if i in cs else r["prompt"],
|
|
66
|
+
"success_rate": r["success_rate"],
|
|
67
|
+
"is_attack": int(i in cs),
|
|
68
|
+
"benign_prompt": r["prompt"],
|
|
69
|
+
"row_index": i,
|
|
70
|
+
}
|
|
71
|
+
for i, r in enumerate(benign)
|
|
72
|
+
]
|
|
73
|
+
manifest = {
|
|
74
|
+
"n_rows": len(out),
|
|
75
|
+
"contamination_ratio": c,
|
|
76
|
+
"n_attack": n_attack,
|
|
77
|
+
"n_benign": len(out) - n_attack,
|
|
78
|
+
"realized_contamination_ratio": n_attack / len(out) if out else 0.0,
|
|
79
|
+
"workload_size": k,
|
|
80
|
+
"eligible_unique_prompts": len(eligible),
|
|
81
|
+
"eligible_rows": len(idx),
|
|
82
|
+
"selection": "rows whose prompt_id is among the top-workload_size prompts by Stage-A success_rate; "
|
|
83
|
+
"take the n_attack rows with the highest success_rate, ties broken by row index",
|
|
84
|
+
"attack_row_indices": chosen,
|
|
85
|
+
"attack_prompt_ids": sorted({benign[i]["prompt_id"] for i in chosen}, key=int),
|
|
86
|
+
"distinct_attack_prompts": len({benign[i]["prompt_id"] for i in chosen}),
|
|
87
|
+
"text_column": "prompt",
|
|
88
|
+
}
|
|
89
|
+
return out, manifest
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def write(out_csv: Path, rows: list[dict], manifest: dict) -> Path:
|
|
93
|
+
out_csv = Path(out_csv)
|
|
94
|
+
out_csv.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
with out_csv.open("w", newline="", encoding="utf-8") as fh:
|
|
96
|
+
w = csv.DictWriter(fh, fieldnames=["prompt_id", "prompt", "success_rate", "is_attack", "benign_prompt", "row_index"])
|
|
97
|
+
w.writeheader()
|
|
98
|
+
w.writerows(rows)
|
|
99
|
+
man = out_csv.with_suffix(".manifest.json")
|
|
100
|
+
man.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
101
|
+
return man
|
|
File without changes
|
|
File without changes
|