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.
Files changed (37) hide show
  1. contambench/__init__.py +5 -0
  2. contambench/cli.py +245 -0
  3. contambench/data.py +142 -0
  4. contambench/examples/calibrate.yaml +20 -0
  5. contambench/examples/control_semantic_suffix.yaml +19 -0
  6. contambench/examples/output_caps.yaml +19 -0
  7. contambench/examples/simulate.yaml +18 -0
  8. contambench/examples/sweep.yaml +20 -0
  9. contambench/export.py +101 -0
  10. contambench/harness/__init__.py +0 -0
  11. contambench/harness/analysis/__init__.py +0 -0
  12. contambench/harness/analysis/calibrate.py +221 -0
  13. contambench/harness/analysis/calibrate_capacity.py +233 -0
  14. contambench/harness/analysis/capacity_util.py +73 -0
  15. contambench/harness/analysis/common.py +780 -0
  16. contambench/harness/arrival.py +95 -0
  17. contambench/harness/client.py +433 -0
  18. contambench/harness/config.py +222 -0
  19. contambench/harness/dataset.py +260 -0
  20. contambench/harness/experiment.py +402 -0
  21. contambench/harness/metrics.py +212 -0
  22. contambench/harness/simulate.py +126 -0
  23. contambench/harness/storage.py +380 -0
  24. contambench/harness/telemetry.py +400 -0
  25. contambench/harness/validation.py +193 -0
  26. contambench/harness/workload.py +356 -0
  27. contambench/report.py +104 -0
  28. contambench/schemas/contamination_export.schema.json +22 -0
  29. contambench/schemas/contamination_manifest.schema.json +33 -0
  30. contambench/schemas/request_trace.schema.json +44 -0
  31. contambench/schemas/stressor_pair.schema.json +21 -0
  32. contambench/score.py +112 -0
  33. contambench-0.1.0.dist-info/METADATA +189 -0
  34. contambench-0.1.0.dist-info/RECORD +37 -0
  35. contambench-0.1.0.dist-info/WHEEL +4 -0
  36. contambench-0.1.0.dist-info/entry_points.txt +2 -0
  37. contambench-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,5 @@
1
+ """ContamBench: stressor suites, labeled contaminated workloads, and reference
2
+ measurements for testing open-weight LLM serving."""
3
+
4
+ __version__ = "0.1.0"
5
+ __data_version__ = "0.1.0"
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