Klaus Clawd
Release v0.2.1: recover attack strength, cross-arch judges, uncapped frontier eval
255b4a8 | """v0.2 frontier eval with a fixed flip metric (M5) + cached clean labels + raw | |
| response logging so metric changes never re-spend API. | |
| Fixed flip rule (addresses the v0.1 artifacts — empty clean labels and near-synonym | |
| false flips like church->cathedral): | |
| A trial FLIPS iff ALL of: | |
| 1. clean label is non-empty (model actually answered on the clean image), | |
| 2. adv label is non-empty (a real redirect, not a refusal/empty), | |
| 3. clean was CORRECT: dist(clean_label, truth) < TAU_CORRECT, | |
| 4. adv is WRONG: dist(adv_label, truth) >= TAU_WRONG, | |
| 5. adv is not a synonym of the clean label: dist(adv_label, clean_label) >= TAU_MOVE. | |
| Only images the model got right on the clean version count toward the denominator | |
| (clean-correct trials), so the rate is "of images the model originally labeled | |
| correctly, how many did the perturbation redirect". | |
| Clean labels are queried ONCE per image/model and cached to runs/frontier_clean.json, | |
| then reused across every adv run dir (attack + noise controls) to cut API spend. | |
| # score one attack run + the two noise controls, caching clean labels: | |
| python scripts/eval_frontier_v02.py --manifest examples/testset60.csv \ | |
| --images examples/testset60 \ | |
| --runs runs/v02_e6_plain runs/noise60_eps6 runs/noise60_ssim | |
| # recompute flips from saved raw responses (no API calls): | |
| python scripts/eval_frontier_v02.py --rescore \ | |
| --runs runs/v02_e6_plain runs/noise60_eps6 runs/noise60_ssim | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import io | |
| import json | |
| import sys | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| from PIL import Image | |
| sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| from veil_pgd.config import get_settings # noqa: E402 | |
| from veil_pgd.fitness.embed import Embedder # noqa: E402 | |
| from veil_pgd.fitness.semantic import embedding_distance # noqa: E402 | |
| from veil_pgd.targets.base import LabelPrompt # noqa: E402 | |
| from veil_pgd.targets.registry import Registry # noqa: E402 | |
| TAU_CORRECT = 0.40 # clean label must be within this of truth (model was right) | |
| TAU_WRONG = 0.50 # adv label must be at least this far from truth (now wrong) | |
| TAU_MOVE = 0.40 # adv label must differ from the clean label (not a synonym) | |
| def jpeg(img: Image.Image, q=85) -> Image.Image: | |
| buf = io.BytesIO() | |
| img.convert("RGB").save(buf, format="JPEG", quality=q) | |
| buf.seek(0) | |
| return Image.open(buf).convert("RGB") | |
| def log(m): | |
| print(f"[{time.strftime('%H:%M:%S')}] {m}", flush=True) | |
| def read_manifest(mpath: str) -> list[tuple[str, str]]: | |
| rows = [] | |
| for line in Path(mpath).read_text().splitlines(): | |
| line = line.strip() | |
| if line and not line.startswith("#"): | |
| p, t = line.split(",", 1) | |
| rows.append((Path(p).name, t.strip())) | |
| return rows | |
| def score_flip(emb, truth, clean_lbl, adv_lbl) -> dict: | |
| cd = embedding_distance(emb, clean_lbl, truth) if clean_lbl else 0.0 | |
| ad = embedding_distance(emb, adv_lbl, truth) if adv_lbl else 0.0 | |
| move = embedding_distance(emb, adv_lbl, clean_lbl) if (adv_lbl and clean_lbl) else 0.0 | |
| clean_correct = bool(clean_lbl) and cd < TAU_CORRECT | |
| flip = bool(clean_correct and adv_lbl and ad >= TAU_WRONG and move >= TAU_MOVE) | |
| return {"clean": clean_lbl, "adv": adv_lbl, "clean_dist": round(cd, 3), | |
| "adv_dist": round(ad, 3), "move_dist": round(move, 3), | |
| "clean_correct": clean_correct, "flip": flip} | |
| def boot_rate(flips: list[bool], correct: list[bool], iters=10000, seed=0): | |
| """Bootstrap flip rate over CLEAN-CORRECT trials only. Returns (rate, lo, hi, n).""" | |
| f = np.array(flips, dtype=float) | |
| c = np.array(correct, dtype=float) | |
| rng = np.random.default_rng(seed) | |
| idx = np.arange(len(f)) | |
| s = [] | |
| for _ in range(iters): | |
| b = rng.choice(idx, size=len(idx), replace=True) | |
| denom = c[b].sum() | |
| s.append(f[b].sum() / denom if denom else 0.0) | |
| denom = c.sum() | |
| point = f.sum() / denom if denom else 0.0 | |
| return point, float(np.percentile(s, 2.5)), float(np.percentile(s, 97.5)), int(denom) | |
| def query_clean(reg, emb, prompt, blackbox, rows, images, cache_path: Path) -> dict: | |
| if cache_path.exists(): | |
| log(f"using cached clean labels: {cache_path}") | |
| return json.loads(cache_path.read_text()) | |
| cache: dict = {} | |
| for fname, truth in rows: | |
| clean = jpeg(Image.open(Path(images) / fname).convert("RGB")) | |
| cache[fname] = {"truth": truth, "models": {}} | |
| for m in blackbox: | |
| lbl = m.label(clean, prompt).parsed_label or "" | |
| cache[fname]["models"][m.name] = lbl | |
| log(f"clean {fname} {m.name.split('/')[-1]}: {lbl!r}") | |
| cache_path.write_text(json.dumps(cache, indent=2)) | |
| return cache | |
| def eval_run(reg, emb, prompt, blackbox, rows, images, run: Path, clean_cache: dict): | |
| advdir = run / "adv" | |
| raw = [] | |
| for fname, truth in rows: | |
| stem = Path(fname).stem | |
| adv_fp = advdir / f"{stem}.png" | |
| if not adv_fp.exists(): | |
| continue | |
| adv = jpeg(Image.open(adv_fp).convert("RGB")) | |
| rec = {"image": fname, "truth": truth, "models": {}} | |
| for m in blackbox: | |
| adv_lbl = m.label(adv, prompt).parsed_label or "" | |
| clean_lbl = clean_cache[fname]["models"][m.name] | |
| rec["models"][m.name] = {"clean_label": clean_lbl, "adv_label": adv_lbl} | |
| log(f"{run.name} {fname} {m.name.split('/')[-1]}: {clean_lbl!r}->{adv_lbl!r}") | |
| raw.append(rec) | |
| (run / "frontier_raw.json").write_text(json.dumps(raw, indent=2)) | |
| return raw | |
| def rescore(emb, run: Path): | |
| raw = json.loads((run / "frontier_raw.json").read_text()) | |
| per_model: dict = {} | |
| scored = [] | |
| for rec in raw: | |
| truth = rec["truth"] | |
| out = {"image": rec["image"], "truth": truth, "models": {}} | |
| for mname, v in rec["models"].items(): | |
| s = score_flip(emb, truth, v["clean_label"], v["adv_label"]) | |
| out["models"][mname] = s | |
| per_model.setdefault(mname, {"flips": [], "correct": []}) | |
| per_model[mname]["flips"].append(s["flip"]) | |
| per_model[mname]["correct"].append(s["clean_correct"]) | |
| scored.append(out) | |
| (run / "frontier_scored.json").write_text(json.dumps(scored, indent=2)) | |
| return per_model | |
| def main() -> None: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--manifest", default="examples/testset60.csv") | |
| ap.add_argument("--images", default="examples/testset60") | |
| ap.add_argument("--runs", nargs="+", required=True) | |
| ap.add_argument("--clean-cache", default="runs/frontier_clean.json") | |
| ap.add_argument("--rescore", action="store_true", help="recompute flips from saved raw, no API") | |
| args = ap.parse_args() | |
| s = get_settings() | |
| reg = Registry(s) | |
| emb = Embedder(reg.embeddings(), s.klaus3_vision_service_url) | |
| rows = read_manifest(args.manifest) | |
| if not args.rescore: | |
| prompt = LabelPrompt() | |
| blackbox = reg.all_blackbox() | |
| clean_cache = query_clean(reg, emb, prompt, blackbox, rows, args.images, | |
| Path(args.clean_cache)) | |
| for rp in args.runs: | |
| eval_run(reg, emb, prompt, blackbox, rows, args.images, Path(rp), clean_cache) | |
| print("\n===== FRONTIER (v0.2, fixed metric) — flip rate over CLEAN-CORRECT trials =====") | |
| print(f"TAU_CORRECT={TAU_CORRECT} TAU_WRONG={TAU_WRONG} TAU_MOVE={TAU_MOVE}") | |
| for rp in args.runs: | |
| run = Path(rp) | |
| pm = rescore(emb, run) | |
| print(f"\n{run.name}:") | |
| all_f, all_c = [], [] | |
| for mname, d in pm.items(): | |
| r, lo, hi, n = boot_rate(d["flips"], d["correct"]) | |
| all_f += d["flips"]; all_c += d["correct"] | |
| print(f" {mname.split('/')[-1]:<20} {r*100:5.1f}% [{lo*100:.1f}, {hi*100:.1f}] (n_correct={n})") | |
| r, lo, hi, n = boot_rate(all_f, all_c) | |
| print(f" {'BOTH':<20} {r*100:5.1f}% [{lo*100:.1f}, {hi*100:.1f}] (n_correct={n})") | |
| reg.close() | |
| if __name__ == "__main__": | |
| main() | |