ahmadrrrtx's picture
ScriptShield release (code commit 272c1325)
69ef6fc verified
Raw History Blame Contribute Delete
12.8 kB
#!/usr/bin/env python3
"""ScriptShield steering package - standalone loader (no ScriptShield code needed).
Applies a refusal-steering vector to CohereLabs/tiny-aya-fire at inference time through
Lexsi SafeTune's CAAModel: h_L <- h_L + alpha * v (output of decoder block L)
Requirements: numpy, torch, transformers, safetune (the pinned commit is in manifest.json).
The base model is NOT part of this package. You need your own access to it under the
Cohere Labs CC-BY-NC licence and Acceptable Use Policy.
python apply_steering.py --model-dir /path/to/tiny-aya-fire --arm pooled --prompt "..."
python apply_steering.py --model-dir /path/to/tiny-aya-fire --verify expected_outputs.jsonl
Four things this file gets right on purpose, because each one fails SILENTLY otherwise
(all four were found the hard way; see DECISIONS.md D-014, D-016, D-018, D-020):
1. steering is live only through ``wrapper.generate`` - ``wrapper.model.generate`` is unsteered;
2. the tokenizer pads on the LEFT, so the last position is always the last prompt token;
3. a chat-templated prompt is tokenised with ``add_special_tokens=False`` (the template
already starts with <BOS_TOKEN>; the default call would produce two);
4. generation stops on the real end-of-turn tokens, not only on <EOS_TOKEN>.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
import numpy as np
HERE = Path(__file__).resolve().parent
END_OF_TURN_TOKENS = ("<|END_RESPONSE|>", "<|END_OF_TURN_TOKEN|>", "<EOS_TOKEN>")
ORIENTATION = "harmful_minus_benign"
# --------------------------------------------------------------------------- package
def sha256_array(v: np.ndarray) -> str:
return hashlib.sha256(np.ascontiguousarray(v, dtype=np.float64).tobytes()).hexdigest()
def load_package(pkg_dir: str | Path = HERE) -> tuple[dict[str, np.ndarray], dict]:
"""Returns ({arm name: vector}, manifest). Refuses a package whose checksums do not match."""
pkg_dir = Path(pkg_dir)
manifest = json.loads((pkg_dir / "manifest.json").read_text(encoding="utf-8"))
if manifest.get("orientation") != ORIENTATION:
raise ValueError(f"unexpected orientation {manifest.get('orientation')!r}: refusing to guess the sign")
with np.load(pkg_dir / manifest["vector_file"]) as z:
vectors = {k: z[k].astype(np.float64) for k in z.files}
for name, meta in manifest["arms"].items():
if name not in vectors:
raise ValueError(f"vector {name!r} is listed in the manifest but missing from the file")
if sha256_array(vectors[name]) != meta["sha256"]:
raise ValueError(f"checksum mismatch for vector {name!r}")
if vectors[name].shape != (manifest["hidden_dim"],):
raise ValueError(f"vector {name!r} has shape {vectors[name].shape}")
return vectors, manifest
# ----------------------------------------------------------------------------- model
def load_base(model_ref: str, dtype: str = "float16", device: int | None = 0, revision: str | None = None):
"""(tokenizer, model). ``model_ref`` is a local folder or a Hub id you have access to."""
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
local = Path(str(model_ref)).exists()
kw = {} if (local or revision is None) else {"revision": revision}
try:
tok = AutoTokenizer.from_pretrained(str(model_ref), **kw)
except Exception: # tokenizer class renamed across versions
from transformers import PreTrainedTokenizerFast
tok = PreTrainedTokenizerFast.from_pretrained(str(model_ref), **kw)
tok.padding_side = "left"
if tok.pad_token is None:
tok.pad_token = tok.eos_token
mkw = {"low_cpu_mem_usage": True, **kw,
"device_map": {"": device} if (device is not None and torch.cuda.is_available()) else None}
td = getattr(torch, dtype)
try:
model = AutoModelForCausalLM.from_pretrained(str(model_ref), dtype=td, **mkw)
except TypeError: # older transformers spelling
model = AutoModelForCausalLM.from_pretrained(str(model_ref), torch_dtype=td, **mkw)
model.eval()
return tok, model
def render(tok, text: str) -> str:
"""One user turn + generation prompt. The model's own template adds its default preamble."""
if getattr(tok, "chat_template", None) is None:
return text
return tok.apply_chat_template([{"role": "user", "content": text}], tokenize=False,
add_generation_prompt=True)
def eos_token_ids(tok, model) -> list[int]:
ids: list[int] = []
unk = getattr(tok, "unk_token_id", None)
for cand in (getattr(tok, "eos_token_id", None), getattr(model.config, "eos_token_id", None)):
for c in (cand if isinstance(cand, (list, tuple)) else [cand]):
if c is not None:
ids.append(int(c))
vocab = tok.get_vocab()
for name in END_OF_TURN_TOKENS:
if name in vocab and vocab[name] != unk:
ids.append(int(vocab[name]))
return sorted(set(ids))
def make_wrapper(model, vector: np.ndarray, layer: int, alpha: float):
"""SafeTune CAAModel adding ``alpha * vector`` to the output of decoder block ``layer``."""
import torch
from safetune.interventions.steer.caa import CAAModel
return CAAModel(model=model, vectors={int(layer): torch.as_tensor(vector, dtype=torch.float32)},
strength=float(alpha))
def generate(model, tok, prompts: list[str], wrapper=None, max_new_tokens: int = 64,
batch_size: int = 8) -> list[str]:
"""Greedy decoding. Pass ``wrapper`` to steer; omit it for the unsteered model."""
import torch
target = wrapper.generate if wrapper is not None else model.generate # (1) never wrapper.model.generate
if tok.padding_side != "left": # (2)
raise RuntimeError("tokenizer.padding_side must be 'left'")
eos = eos_token_ids(tok, model) # (4)
device = model.get_input_embeddings().weight.device
templated = getattr(tok, "chat_template", None) is not None
outs: list[str] = []
for i in range(0, len(prompts), batch_size):
kw = {"return_tensors": "pt", "padding": True}
if templated:
kw["add_special_tokens"] = False # (3)
enc = tok([render(tok, p) for p in prompts[i:i + batch_size]], **kw)
enc = {k: enc[k].to(device) for k in ("input_ids", "attention_mask")}
with torch.no_grad():
gen = target(**enc, max_new_tokens=max_new_tokens, do_sample=False,
pad_token_id=tok.pad_token_id, eos_token_id=eos)
outs.extend(t.strip() for t in tok.batch_decode(gen[:, enc["input_ids"].shape[1]:],
skip_special_tokens=True))
if wrapper is not None and getattr(wrapper, "_handles", []):
raise RuntimeError("steering hooks are still installed after generation")
return outs
def describe_layer(model, layer: int) -> dict:
"""Module tree of the steered block (which sub-modules exist, which have a bias)."""
from safetune._refusal_helpers import _get_decoder_layers
block = _get_decoder_layers(model)[layer]
mods = []
for name, m in block.named_modules():
if name and not list(m.children()):
bias = getattr(m, "bias", None)
mods.append({"name": name, "type": type(m).__name__,
"has_bias": bias is not None,
"weight_shape": list(m.weight.shape) if getattr(m, "weight", None) is not None else None})
return {"block_type": type(block).__name__, "leaf_modules": mods,
"any_bias_in_block": any(m["has_bias"] for m in mods)}
# ---------------------------------------------------------------------------- verify
def verify(model, tok, vectors: dict, manifest: dict, expected_path: str | Path) -> dict:
"""Re-generate logged outputs and compare sha256(text). Exact reproduction is the criterion."""
rows = [json.loads(l) for l in Path(expected_path).read_text(encoding="utf-8").splitlines() if l.strip()]
report: dict = {"n": len(rows), "arms": {}, "mismatches": []}
inf = manifest["inference"]
produced: dict = {}
for arm in sorted({r["arm"] for r in rows}, key=lambda a: (a != "unsteered", a)):
sel = sorted((r for r in rows if r["arm"] == arm), key=lambda r: r["index"])
wrapper = None if arm == "unsteered" else make_wrapper(model, vectors[arm], manifest["layer"],
manifest["alpha"])
limits = {r.get("max_new_tokens", inf["max_new_tokens"]) for r in sel}
if len(limits) != 1:
raise ValueError("rows of one arm must share max_new_tokens")
outs = generate(model, tok, [r["prompt"] for r in sel], wrapper,
max_new_tokens=limits.pop(), batch_size=inf["batch_size"])
produced[arm] = outs
same = 0
for r, o in zip(sel, outs):
ok = hashlib.sha256(o.encode("utf-8")).hexdigest() == r["output_sha256"]
same += ok
if not ok:
report["mismatches"].append({"arm": arm, "id": r["id"], "n_chars_got": len(o),
"n_chars_expected": r.get("output_chars")})
report["arms"][arm] = {"n": len(sel), "identical": int(same)}
print(f"verify {arm:<14} {same}/{len(sel)} outputs byte-identical to the logged run", flush=True)
report["all_identical"] = all(v["identical"] == v["n"] for v in report["arms"].values())
# evidence that steering was really applied: steered text must differ from unsteered text somewhere
if "unsteered" in produced:
report["steered_differs_from_unsteered"] = {
arm: int(sum(a != b for a, b in zip(outs, produced["unsteered"])))
for arm, outs in produced.items() if arm != "unsteered" and len(outs) == len(produced["unsteered"])}
return report
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--model-dir", required=True, help="local folder or Hub id of CohereLabs/tiny-aya-fire")
ap.add_argument("--package", default=str(HERE))
ap.add_argument("--arm", default="pooled")
ap.add_argument("--prompt", action="append", default=[])
ap.add_argument("--alpha", type=float, default=None, help="override the frozen strength (research only)")
ap.add_argument("--dtype", default=None)
ap.add_argument("--verify", default=None, help="expected_outputs.jsonl to reproduce")
ap.add_argument("--describe-layer", action="store_true")
ap.add_argument("--report", default=None, help="write a JSON report here")
a = ap.parse_args()
vectors, manifest = load_package(a.package)
if a.arm != "unsteered" and a.arm not in vectors:
print(f"unknown arm {a.arm!r}; available: {sorted(vectors)}")
return 2
tok, model = load_base(a.model_dir, a.dtype or manifest["inference"]["dtype"])
report: dict = {"package": manifest["package_version"], "layer": manifest["layer"], "alpha": manifest["alpha"],
"scriptshield_imported": any(m.split(".")[0] == "scriptshield" for m in sys.modules)}
if a.describe_layer:
report["layer_modules"] = describe_layer(model, manifest["layer"])
print(json.dumps(report["layer_modules"], indent=1))
if a.verify:
report["verify"] = verify(model, tok, vectors, manifest, a.verify)
if a.prompt:
alpha = manifest["alpha"] if a.alpha is None else a.alpha
base = generate(model, tok, a.prompt, None, manifest["inference"]["max_new_tokens"])
if a.arm == "unsteered":
steered = base
else:
steered = generate(model, tok, a.prompt, make_wrapper(model, vectors[a.arm], manifest["layer"], alpha),
manifest["inference"]["max_new_tokens"])
report["generations"] = [{"prompt": p, "unsteered": b, a.arm: s} for p, b, s in zip(a.prompt, base, steered)]
for g in report["generations"]:
print("\nPROMPT :", g["prompt"], "\nUNSTEERED:", g["unsteered"], f"\n{a.arm.upper():<9}:", g[a.arm])
if a.report:
Path(a.report).parent.mkdir(parents=True, exist_ok=True)
Path(a.report).write_text(json.dumps(report, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")
return 0 if (not a.verify or report["verify"]["all_identical"]) else 1
if __name__ == "__main__":
sys.exit(main())