# phase 4: try to break an editor. each mode is one experiment. # usage: python methods/attack.py --mode adv|contra|unedit|quant --exp E16 --method inventions:Composite --hp ... [--tag x] import argparse, ast, importlib, hashlib, random, sys, os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from common import * from harness import Judge, brief from values import req import spec mean = lambda xs: sum(xs) / max(1, len(xs)) def em(model, tok, P, T, **kw): return mean([x["em"] for x in score(model, tok, P, T, **kw)]) # ------------------------------------------------------------------ adversarial paraphrases def adv(model, tok, J, M, a): rng = random.Random(a.seed); facts = J.facts("eval"); reqs = [req(f) for f in facts] J.prepare(model, facts); M.precompute(reqs); M.reset(); M.edit(reqs) # all 800 in, then attack 200 of them test = facts[:200]; ids = wikitext_train_ids(tok, 30000, seed=11) def typo(s): i = rng.randrange(1, len(s) - 2) while s[i] == " " or s[i + 1] == " ": i = rng.randrange(1, len(s) - 2) return s[:i] + s[i + 1] + s[i] + s[i + 2:] V = {k: [] for k in ("plain", "long_context", "other_fact_first", "wrong_claim_first", "two_subjects", "lowercase_name", "typo_name", "last_word_only")} hij_P, hij_T = [], [] for f in test: g = rng.choice([x for x in facts if x["rel"] == f["rel"] and x["target"] != f["target"]]) chunk = tok.decode(ids[(k := rng.randrange(0, len(ids) - 70)):k + 60]).replace("\n", " ").strip() V["plain"].append(f["direct"]) V["long_context"].append(chunk + ". " + f["direct"]) V["other_fact_first"].append(g["statement"] + " " + f["direct"]); hij_P.append(V["other_fact_first"][-1]); hij_T.append(g["target"]) V["wrong_claim_first"].append(f"Many people wrongly believe the answer is {g['target']}. " + f["direct"]) V["two_subjects"].append(f"{g['subject']} and {f['subject']} were both mentioned yesterday. " + f["direct"]) V["lowercase_name"].append(f["direct"].replace(f["subject"], f["subject"].lower())) V["typo_name"].append(f["direct"].replace(f["subject"], typo(f["subject"]))) V["last_word_only"].append(f["direct"].replace(f["subject"], f["subject"].split()[-1]) if " " in f["subject"] else None) T = [f["target"] for f in test]; out = {} for k, P in V.items(): keep = [(p, t) for p, t in zip(P, T) if p is not None] r = score(model, tok, [p for p, _ in keep], [t for _, t in keep]) out[k] = dict(em=mean([x["em"] for x in r]), p=mean([x["p1"] for x in r]), n=len(keep)) out["hijack_by_stated_other_fact"] = em(model, tok, hij_P, hij_T) # the judge's own paraphrases, split out per template so the single worst one is visible for j in range(5): out[f"judge_para_{j}"] = dict(em=em(model, tok, [f["para"][j] for f in test], T)) out["worst_case_em"] = min(v["em"] for k, v in out.items() if isinstance(v, dict) and k not in ("lowercase_name", "typo_name", "last_word_only")) for k, v in out.items(): print(f" {k:30s} {v}") return out # ------------------------------------------------------------------ contradict facts the model already knows def contra(model, tok, J, M, a): rng = random.Random(a.seed); pools = jload(os.path.join(ROOT, "data", "pools.json")); real = jload(os.path.join(ROOT, "data", "real_nbrs.json")) facts, kept_out = [], [] for rel, R in spec.RELATIONS.items(): known = {p: t for p, t in real[rel]} cand = [(s, o) for s, o in spec.REAL_NEIGHBOURS[rel] if R["direct"].format(s=s) in known] rng.shuffle(cand) for s, old in cand[:8]: o, h = rng.choice([p for p in pools[rel] if p[0] != old]) facts.append(dict(id=f"real_{rel}_{len(facts)}", rel=rel, subject=s, target=o, old=old, hop_target=h, direct=R["direct"].format(s=s), statement=R["direct"].format(s=s) + " " + o + ".", para=[p.format(s=s) for p in R["para"]], hop=R["hop"].format(s=s))) kept_out += [(R["direct"].format(s=s), o) for s, o in cand[8:]] reqs = [req(f) for f in facts] before_old = em(model, tok, [f["direct"] for f in facts], [f["old"] for f in facts]) before_other = em(model, tok, [p for p, _ in kept_out], [t for _, t in kept_out]) qa0 = em(model, tok, [q for q, _ in J.qa], [x for _, x in J.qa]) M.precompute(reqs); M.reset(); M.edit(reqs) out = dict(n_edits=len(facts), n_untouched_real=len(kept_out), old_answer_em_before=before_old, untouched_real_em_before=before_other, qa_before=qa0) out["new_answer_em"] = em(model, tok, [f["direct"] for f in facts], [f["target"] for f in facts]) out["old_answer_em_after"] = em(model, tok, [f["direct"] for f in facts], [f["old"] for f in facts]) out["para_new_em"] = em(model, tok, [p for f in facts for p in f["para"]], [f["target"] for f in facts for _ in range(5)]) out["para_old_em"] = em(model, tok, [p for f in facts for p in f["para"]], [f["old"] for f in facts for _ in range(5)]) out["hop_new_em"] = em(model, tok, [f["hop"] for f in facts], [f["hop_target"] for f in facts]) out["untouched_real_em_after"] = em(model, tok, [p for p, _ in kept_out], [t for _, t in kept_out]) out["qa_after"] = em(model, tok, [q for q, _ in J.qa], [x for _, x in J.qa]) for k, v in out.items(): print(f" {k:28s} {v}") return out # ------------------------------------------------------------------ edit, then take it back def fingerprint(model): h = hashlib.sha256() for n, p in sorted(model.named_parameters()): h.update(n.encode()); h.update(p.detach().cpu().numpy().tobytes()) return h.hexdigest() def unedit(model, tok, J, M, a): facts = J.facts("eval"); reqs = [req(f) for f in facts]; J.prepare(model, facts) fp0 = fingerprint(model); base = next_logprobs(model, tok, J.loc[:100]) M.precompute(reqs); M.reset(); M.edit(reqs) edited = next_logprobs(model, tok, J.loc[:100]); fp1 = fingerprint(model) eff_in = em(model, tok, [f["direct"] for f in facts[:200]], [f["target"] for f in facts[:200]]) out = dict(pretrained_weights_changed_by_edit=fp0 != fp1, efficacy_while_edited=eff_in, max_logprob_shift_while_edited=(edited - base).abs().max().item()) # (a) forget ONE fact, keep the other 799 if hasattr(M, "forget"): M.forget([facts[0]["id"]]) out["forgot_one__its_em"] = em(model, tok, [facts[0]["direct"]] + facts[0]["para"], [facts[0]["target"]] * 6) out["forgot_one__others_em"] = em(model, tok, [f["direct"] for f in facts[1:200]], [f["target"] for f in facts[1:200]]) # (b) remove everything M.reset(); after = next_logprobs(model, tok, J.loc[:100]) out["fingerprint_identical_after_unedit"] = fingerprint(model) == fp0 out["max_logprob_diff_after_unedit"] = (after - base).abs().max().item() out["bit_for_bit"] = out["fingerprint_identical_after_unedit"] and out["max_logprob_diff_after_unedit"] == 0.0 # (c) what if the edit had been merged into the fp16 weights and then subtracted again (how MEMIT is normally shipped) if getattr(M, "delta", None) is not None and not M.delta.parts: M.edit(reqs) if getattr(M, "delta", None) is not None and M.delta.parts: tot, bad, mx = 0, 0, 0.0 for l in M.delta.parts: W = W_out(model, l).weight.data; D = M.delta.dense(l) merged = (W.float() + D).half(); back = (merged.float() - D).half() tot += W.numel(); bad += (back != W).sum().item(); mx = max(mx, (back.float() - W.float()).abs().max().item()); del D out["merged_fp16_then_subtracted"] = dict(entries=tot, entries_not_restored=bad, frac=bad / tot, max_abs_err=mx) M.reset() for k, v in out.items(): print(f" {k:36s} {v}") return out # ------------------------------------------------------------------ 4-bit quantisation after editing NF4 = torch.tensor([-1.0, -0.6961928009986877, -0.5250730514526367, -0.39491748809814453, -0.28444138169288635, -0.18477343022823334, -0.09105003625154495, 0.0, 0.07958029955625534, 0.16093020141124725, 0.24611230194568634, 0.33791524171829224, 0.44070982933044434, 0.5626170039176941, 0.7229568362236023, 1.0]) def nf4(w, block=64): """simulated bitsandbytes NF4: blocks of 64 values scaled by their absmax, each value snapped to one of 16 levels.""" shp = w.shape; x = w.float().flatten(); padn = (-x.numel()) % block x = torch.nn.functional.pad(x, (0, padn)).view(-1, block); s = x.abs().amax(1, keepdim=True).clamp_min(1e-12) lv = NF4.to(x.device); out = torch.empty_like(x) for a in range(0, x.shape[0], 1 << 16): # chunked so the distance tensor stays small xn = x[a:a + (1 << 16)] / s[a:a + (1 << 16)] out[a:a + (1 << 16)] = lv[(xn[..., None] - lv).abs().argmin(-1)] * s[a:a + (1 << 16)] return out.flatten()[:w.numel()].view(shp).to(w.dtype) def quant(model, tok, J, M, a): facts = J.facts("eval"); reqs = [req(f) for f in facts]; J.prepare(model, facts) M.precompute(reqs); M.reset(); M.edit(reqs) out = dict(fp16_edited=J.evaluate(model, facts)); print("fp16 edited ", brief(out["fp16_edited"]), flush=True) backup = {} merged = getattr(M, "delta", None) is not None and bool(M.delta.parts) with torch.no_grad(): if merged: # least-squares editors: fold the update into the weight first for l in list(M.delta.parts): W = W_out(model, l).weight; backup[f"__w{l}"] = W.data.clone().cpu(); W.data = (W.data.float() + M.delta.dense(l)).half() M.delta.remove_hooks() for n, mod in model.named_modules(): if isinstance(mod, torch.nn.Linear) and "embed" not in n: backup.setdefault(n, mod.weight.data.clone().cpu()); mod.weight.data = nf4(mod.weight.data) if getattr(M, "mem", None): # the memory block gets the same treatment for l, o in M.mem.items(): o["W"].copy_(nf4(o["W"])); o["V"].copy_(nf4(o["V"])) out["nf4_edited"] = J.evaluate(model, facts); print("4-bit edited ", brief(out["nf4_edited"]), flush=True) if merged: with torch.no_grad(): for l in list(M.delta.parts): W_out(model, l).weight.data = nf4(backup[f"__w{l}"].to(DEV)) M.delta.parts = {} else: M.reset() out["nf4_unedited"] = J.evaluate(model, facts); print("4-bit, no edit ", brief(out["nf4_unedited"]), flush=True) with torch.no_grad(): for n, mod in model.named_modules(): if n in backup: mod.weight.data = backup[n].to(DEV) out["quantised"] = "all nn.Linear weights (attention + mlp, 16 layers) and the memory block; embeddings left in fp16" return out # ------------------------------------------------------------------ where does the block run out of room? def stress(model, tok, J, M, a): """800 real facts + n decoy slots (fresh fictional subjects, zero values). the judge scores the 800 real ones.""" facts = J.facts("eval"); reqs = [req(f) for f in facts]; J.prepare(model, facts); M.precompute(reqs) names = {f["subject"] for f in J.all_facts} | {nb["subject"] for f in J.all_facts for nb in f["nbrs"]}; out = {} for n in (0, 1000, 2000, 3000, 4000, 6000): try: M.reset(); torch.cuda.reset_peak_memory_stats(); t = time.time(); M.edit(reqs) if n: M.add_decoys(n, avoid=names) m = J.evaluate(model, facts); m["slots"] = 800 + n; m["build_sec"] = round(time.time() - t, 1) out[str(n)] = m; print(f"decoys {n:5d}", brief(m), f"peak={m['peak_mb']}MB build={m['build_sec']}s", flush=True) except torch.OutOfMemoryError: free(); out[str(n)] = dict(slots=800 + n, status="OOM at the 7GB cap in the joint address solve"); print(f"decoys {n}: OOM", flush=True); break return out def main(): ap = argparse.ArgumentParser(); ap.add_argument("--mode", required=True); ap.add_argument("--exp", required=True) ap.add_argument("--method", required=True); ap.add_argument("--hp", nargs="*", default=[]); ap.add_argument("--tag", default="") ap.add_argument("--seed", type=int, default=0); a = ap.parse_args(); hp = {} for kv in a.hp: k, v = kv.split("=", 1) try: hp[k] = ast.literal_eval(v) except Exception: hp[k] = v torch.manual_seed(a.seed) meta = jload(os.path.join(ROOT, "data", "meta.json")); model, tok = load_model(meta["model"]); J = Judge(tok) mod, cls = a.method.split(":"); M = getattr(importlib.import_module(mod), cls)(model, tok, **hp) t0 = time.time(); out = dict(exp=a.exp, mode=a.mode, method=cls, hp=hp, seed=a.seed) out["result"] = dict(adv=adv, contra=contra, unedit=unedit, quant=quant, stress=stress)[a.mode](model, tok, J, M, a) out["total_sec"] = round(time.time() - t0, 1) result(f"{a.exp}_{a.mode}_{cls}{('_' + a.tag) if a.tag else ''}", out) if __name__ == "__main__": main()