diff --git a/.github/ISSUE_TEMPLATE/vector-submission.yml b/.github/ISSUE_TEMPLATE/vector-submission.yml new file mode 100644 index 0000000..2253f6a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/vector-submission.yml @@ -0,0 +1,134 @@ +name: Submit a steering vector +description: Tell us about a steering vector you've made or found. No git needed. We'll turn it into an index entry. +title: "[Vector] " +labels: ["vector-submission"] +body: + - type: markdown + attributes: + value: | + Thanks for contributing! Fill in what you know. Only the fields marked required are needed, and partial information is still useful. + + Modulo lets a model steer its own activations. Your vector stays hosted where it is (e.g. your HuggingFace repo). We store a pointer pinned by its sha256 and credit you in the index. New entries start as **community**. After a maintainer runs a GPU check and reviews the outputs, an entry can become **verified**. See [CONTRIBUTING.md](https://github.com/EigenformAI/modulo/blob/main/CONTRIBUTING.md) for details. + + - type: input + id: concept + attributes: + label: Concept name + description: Short name the model will use to call it, lowercase with underscores. + placeholder: e.g. honesty, formality, joy + validations: + required: true + + - type: input + id: model + attributes: + label: Model + description: HuggingFace model id the vector was extracted for (and a revision, if you know it). + placeholder: e.g. meta-llama/Llama-3.1-8B-Instruct + validations: + required: true + + - type: input + id: location + attributes: + label: Where the vector file is hosted + description: Link to the file (a HuggingFace repo is best). .safetensors preferred; .pt and .npz also accepted. + placeholder: https://huggingface.co///blob/main/honesty.safetensors + validations: + required: true + + - type: input + id: layer + attributes: + label: Layer(s) + description: Which decoder layer the vector is for. If the file holds one vector per layer, say so and give the recommended layer. + placeholder: e.g. 14 (file is [num_layers, hidden]; 14 recommended) + validations: + required: true + + - type: textarea + id: effect + attributes: + label: What it does + description: What adding the vector does (+), and what subtracting it does (-). + placeholder: | + + more candid; admits uncertainty + - more evasive; overclaims + validations: + required: true + + - type: textarea + id: method + attributes: + label: How it was extracted + description: e.g. mean difference of activations over contrast pairs, CAA, PCA, SAE feature, probe direction. + validations: + required: true + + - type: input + id: license + attributes: + label: License + placeholder: e.g. MIT, Apache-2.0, CC-BY-4.0 + validations: + required: true + + - type: input + id: credit + attributes: + label: How you'd like to be credited + description: Names, handles or affiliations to list as contributors. + validations: + required: true + + - type: textarea + id: original_setup + attributes: + label: Strength and setup you used (strongly encouraged) + description: | + Modulo measures strength as a % of the residual-stream norm at the layer, after normalizing the vector, so your numbers won't transfer directly. Tell us what you used and we'll calibrate: the coefficient, whether the vector was normalized, which positions were steered (all tokens / generated only), and which strengths stayed coherent. + placeholder: e.g. unnormalized vector, coefficient 4.0, all positions, coherent up to ~6 + + - type: textarea + id: failure_modes + attributes: + label: Known failure modes (strongly encouraged) + placeholder: e.g. collapses into repetition above 8; also makes the model more verbose + + - type: input + id: paper + attributes: + label: Paper or write-up + placeholder: https://arxiv.org/abs/... + + - type: input + id: contrast_dataset + attributes: + label: Contrast dataset or prompts + description: Link to the data the vector was extracted from, if public. + + - type: textarea + id: evaluation + attributes: + label: Evaluation results + description: Any measurements of the effect, e.g. benchmark deltas or behavior rates. + + - type: textarea + id: test_prompts + attributes: + label: Test prompts (up to 5) + description: Prompts where the effect should be easy to see, one per line. Used in our GPU check. + + - type: textarea + id: notes + attributes: + label: Anything else + description: Other layers you tried, related vectors, caveats, how to reach you. + + - type: checkboxes + id: confirm + attributes: + label: Confirmation + options: + - label: I have the right to share this vector under the license above. + required: true diff --git a/.github/workflows/community-check.yml b/.github/workflows/community-check.yml new file mode 100644 index 0000000..802cb1b --- /dev/null +++ b/.github/workflows/community-check.yml @@ -0,0 +1,44 @@ +name: Community vector check + +on: + pull_request: + paths: + - "community/**" + +# Read-only token and no secrets, so this is safe to run on PRs from forks. +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + # The checker and library come from the base branch so a PR can't change + # the checks that judge it; only the PR's index is read. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + persist-credentials: false + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: pr + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + pip install --index-url https://download.pytorch.org/whl/cpu torch + pip install pyyaml numpy huggingface_hub safetensors + - name: Check index + run: | + # Until the checker exists on the base branch (the PR adding it), use the PR's copy + checker=base/community/check_vectors.py + [ -f "$checker" ] || checker=pr/community/check_vectors.py + python "$checker" --index pr/community/vectors.yaml \ + --base-index base/community/vectors.yaml --summary summary.md + - name: Summary + if: always() + run: '[ -f summary.md ] && cat summary.md >> "$GITHUB_STEP_SUMMARY" || true' diff --git a/AGENTS.md b/AGENTS.md index faf9fde..2e5fb26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,11 @@ The format is defined in `modulo/reports.py`. `validate_report(d)` returns a lis | `modulo/registry.py` | `VectorRegistry`, bundled vector manifest | | `modulo/extraction.py` | Vector extraction (manual + auto) | | `modulo/reports.py` | Report format, validation, `reports.jsonl` read/write | +| `modulo/community.py` | Community vector index: entry format and file checks | +| `community/vectors.yaml` | Community vector index (pointers to hosted vectors, with trust levels) | +| `community/check_vectors.py` | Checks the index; run by CI on PRs touching `community/` | +| `community/gpu_verify.py` | Maintainer GPU check: strength sweep + suggested safe range | +| `CONTRIBUTING.md` | How to contribute vectors (issue form or PR) | | `agent/loop.py` | Standalone demo loop (thin wrapper) | | `config.py` | Model name, paths, layer defaults | | `load_model.py` | Load Qwen2.5-7B-Instruct + tokenizer | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cab819b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing steering vectors + +Modulo lets local models steer their own activations, and we'd love more vectors for more models. If you've extracted a steering vector, or know of one, please share it: anything you know about it helps, even if it's partial. + +## Two ways to contribute + +1. **Fill in the form (no git needed).** [Open a vector submission](https://github.com/EigenformAI/modulo/issues/new?template=vector-submission.yml). A maintainer turns it into an index entry and credits you. +2. **Open a pull request** adding an entry to [`community/vectors.yaml`](community/vectors.yaml). Automatic checks run on your PR. + +Your vector file stays where it's hosted (ideally your own HuggingFace repo). The index stores a pointer pinned by the file's sha256, so the file can't change underneath the entry. + +## Trust levels + +| Level | Who sets it | What it means | +|---|---|---| +| `community` | You (all new entries) | Passed the automatic checks. Opt-in. | +| `verified` | Maintainers only | A maintainer ran it on the model, reviewed the outputs and set a safe strength range. Will be loaded by default. | + +The library doesn't read this index yet. Today only the vectors bundled in `modulo/registry.py` are downloaded automatically; loading the index (verified by default, community opt-in) is the next step. + +Entry text (`description`, `sign_convention`, `test_prompts`) ends up in a model's prompt once an entry is verified, so it's reviewed as part of promotion. + +## Strength units + +Modulo normalizes every vector to unit length and measures `strength` as a **percentage of the typical residual-stream norm at that layer**. Published coefficients use other conventions: raw multipliers, unnormalized vectors, steering all positions instead of generated tokens only. So you don't need to give a range in Modulo units. Describe what you used in `original_setup`; the GPU check sweeps strengths and suggests the Modulo range. + +## Index entry fields + +Required: + +| Field | Example | Notes | +|---|---|---| +| `id` | `llama3.1-8b-instruct-honesty` | Unique; lowercase letters, digits, `.` `-` `_` | +| `name` | `honesty` | What the model calls it with `steer`; lowercase, `_` | +| `model` | `meta-llama/Llama-3.1-8B-Instruct` | HF model id | +| `repo_id` / `repo_type` | `you/llama-vectors` / `model` | Where the file lives (`model` or `dataset` repo) | +| `filename` | `honesty.safetensors` | `.safetensors` preferred; `.pt` and `.npz` accepted | +| `sha256` | `sha256sum honesty.safetensors` | 64 lowercase hex characters | +| `format` | `single` | `single` (one vector) or `per_layer` (`[num_layers, hidden]`) | +| `layer` | `14` | Decoder layer the vector is added at | +| `description` | | What the vector represents | +| `sign_convention` | `+ more candid; - more evasive` | What + and − do | +| `extraction_method` | | How it was made (contrast pairs, CAA, PCA, SAE feature, …) | +| `license` | `mit` | | +| `contributors` | `[Your Name]` | Who to credit | +| `trust` | `community` | Always `community` for new entries | + +Optional but very useful: `revision` (the HF commit of your repo; recommended), `model_revision`, `original_setup`, `known_failure_modes`, `paper`, `contrast_dataset`, `evaluation`, `test_prompts` (up to 5), `notes`. Maintainers set `safe_strength_min`/`safe_strength_max` when promoting. + +File formats: a `.safetensors` file must contain exactly one tensor; an `.npz` file stores it under the key `vector`; a `.pt` file is a plain tensor (loaded with `weights_only=True`). With `format: per_layer`, the tensor is indexed by `layer`. + +## What the automatic checks do + +On every PR that touches `community/`, [`community/check_vectors.py`](community/check_vectors.py) runs (from the main branch, not your PR's copy) and: + +- validates every entry's fields +- for new or changed entries: downloads the file (max 100 MB), checks its sha256, loads it with the same code Modulo uses, checks the vector size against the model's `hidden_size`, the layer against its layer count, and that the norm is finite and non-zero +- flags any change to a `verified` entry for maintainers + +Run it yourself first: `python community/check_vectors.py`. + +## For maintainers: promoting an entry + +On a GPU box with the repo checked out: + +```bash +python community/gpu_verify.py --entry --index --pr +``` + +This runs the vector at ±4/8/12/15 on the entry's test prompts plus generic ones, posts a table of where output changes and degenerates, collapsible samples, and a suggested safe range. If the samples match the claimed effect, set `trust: verified` and the safe range, and merge. The repetition detector doesn't catch incoherent but non-repetitive output, so read the samples. diff --git a/community/check_vectors.py b/community/check_vectors.py new file mode 100644 index 0000000..45ab230 --- /dev/null +++ b/community/check_vectors.py @@ -0,0 +1,130 @@ +"""Check community/vectors.yaml: entry fields for every entry, plus file checks +(download, sha256, load, shape vs the model's config, norm) for entries that are +new or changed relative to a base index. + +CI runs this from the base branch against the PR's index, so a PR can't change +the checks that judge it: + + python community/check_vectors.py --index pr/community/vectors.yaml \ + --base-index base/community/vectors.yaml --summary summary.md + +Run with --all to re-check every entry's file. +""" +import argparse +import json +import sys +from pathlib import Path + +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from modulo.community import MAX_FILE_BYTES, check_vector_file, validate_index # noqa: E402 + + +def load_index(path: Path) -> dict: + if not path.exists(): + return {"entries": []} + with open(path) as f: + return yaml.safe_load(f) or {"entries": []} + + +def changed_entries(index: dict, base: dict) -> tuple[list[dict], list[str]]: + """Entries that are new or differ from base, and ids removed from base.""" + base_by_id = {e.get("id"): e for e in base.get("entries", []) if isinstance(e, dict)} + ids = {e.get("id") for e in index["entries"] if isinstance(e, dict)} + changed = [e for e in index["entries"] if base_by_id.get(e.get("id")) != e] + removed = sorted(i for i in base_by_id if i not in ids) + return changed, removed + + +def maintainer_warnings(changed: list[dict], base: dict) -> list[str]: + base_by_id = {e.get("id"): e for e in base.get("entries", []) if isinstance(e, dict)} + warnings = [] + for e in changed: + was_verified = base_by_id.get(e["id"], {}).get("trust") == "verified" + if e["trust"] == "verified" or was_verified: + warnings.append(f"{e['id']}: adds, changes or demotes a verified entry (maintainers only)") + return warnings + + +def model_dims(entry: dict) -> tuple[int | None, int | None, str | None]: + """(hidden_size, num_layers, note) from the model's config.json on the Hub.""" + from huggingface_hub import hf_hub_download + try: + path = hf_hub_download(entry["model"], "config.json", revision=entry.get("model_revision")) + except Exception as e: # gated or missing models: skip the shape checks, don't fail + return None, None, f"{entry['id']}: couldn't read {entry['model']} config ({type(e).__name__}); shape not checked" + cfg = json.loads(Path(path).read_text()) + cfg = cfg.get("text_config", cfg) if "hidden_size" not in cfg else cfg + return cfg.get("hidden_size"), cfg.get("num_hidden_layers"), None + + +def fetch_vector(entry: dict) -> tuple[Path | None, str | None]: + from huggingface_hub import HfApi, hf_hub_download + kwargs = dict(repo_id=entry["repo_id"], repo_type=entry["repo_type"], revision=entry.get("revision")) + try: + [info] = HfApi().get_paths_info(paths=[entry["filename"]], **kwargs) + if getattr(info, "size", None) is None: + return None, f"{entry['id']}: '{entry['filename']}' is not a file in {entry['repo_id']}" + if info.size > MAX_FILE_BYTES: + return None, f"{entry['id']}: file is {info.size / 1e6:.0f} MB (max {MAX_FILE_BYTES / 1e6:.0f} MB)" + return Path(hf_hub_download(filename=entry["filename"], **kwargs)), None + except Exception as e: + return None, f"{entry['id']}: couldn't download {entry['repo_id']}/{entry['filename']} ({type(e).__name__}: {e})" + + +def check(index_path: Path, base_path: Path | None, check_all: bool) -> tuple[list[str], list[str], list[str] | None]: + """Returns (errors, warnings, checked ids); checked is None if field errors stopped the file checks.""" + index = load_index(index_path) + errors = validate_index(index) + if errors: + return errors, [], None + base = load_index(base_path) if base_path else {"entries": []} + changed, removed = (index["entries"], []) if check_all else changed_entries(index, base) + warnings = maintainer_warnings(changed, base) + [f"{i}: removed" for i in removed] + for entry in changed: + path, err = fetch_vector(entry) + if err: + errors.append(err) + continue + hidden, layers, note = model_dims(entry) + if note: + warnings.append(note) + errors += check_vector_file(entry, path, hidden_size=hidden, num_layers=layers) + return errors, warnings, [e["id"] for e in changed] + + +def summary_markdown(errors, warnings, checked) -> str: + lines = ["## Community vector index check", ""] + lines.append("**Result: " + ("❌ problems found" if errors else "✅ passed") + "**") + lines.append("") + if checked is None: + lines.append("Files checked: skipped until the problems below are fixed") + else: + lines.append(f"Files checked: {', '.join(f'`{i}`' for i in checked) or 'none (no new or changed entries)'}") + for title, items in (("Problems", errors), ("Notes for maintainers", warnings)): + if items: + lines += ["", f"### {title}", ""] + [f"- {i}" for i in items] + return "\n".join(lines) + "\n" + + +def main(): + root = Path(__file__).resolve().parent + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--index", type=Path, default=root / "vectors.yaml") + parser.add_argument("--base-index", type=Path, default=None, help="Only entries that differ from this get file checks") + parser.add_argument("--all", action="store_true", help="File-check every entry") + parser.add_argument("--summary", type=Path, default=None, help="Also write a Markdown summary here") + args = parser.parse_args() + + errors, warnings, checked = check(args.index, args.base_index, args.all) + md = summary_markdown(errors, warnings, checked) + print(md) + if args.summary: + args.summary.write_text(md) + sys.exit(1 if errors else 0) + + +if __name__ == "__main__": + main() diff --git a/community/gpu_verify.py b/community/gpu_verify.py new file mode 100644 index 0000000..c2d0b41 --- /dev/null +++ b/community/gpu_verify.py @@ -0,0 +1,151 @@ +"""Run a community vector on its model and summarise what it does, so a +maintainer can decide whether to promote it to "verified". + +Sweeps strengths in Modulo units (% of the residual norm) on the entry's +test_prompts plus a few generic ones, flags where output degenerates, and +suggests a safe strength range. Writes Markdown; --pr posts it on a PR with gh. + +Run on a GPU box from the repo root (gated models need HF_TOKEN): + python community/gpu_verify.py --entry qwen2.5-7b-instruct-assistant-axis + python community/gpu_verify.py --entry --index path/to/pr/vectors.yaml --pr 12 +""" +import argparse +import html +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import config # noqa: E402 sets HF_HOME before transformers is imported +import torch # noqa: E402 +from transformers import AutoModelForCausalLM, AutoTokenizer # noqa: E402 + +from community.check_vectors import fetch_vector, load_index, model_dims # noqa: E402 +from modulo import SteeringAgent, VectorEntry, VectorRegistry # noqa: E402 +from modulo.agent import MAX_STRENGTH, is_degenerate # noqa: E402 +from modulo.community import check_vector_file # noqa: E402 +from verify_vector import generate # noqa: E402 + +GENERIC_PROMPTS = [ + "Introduce yourself in two sentences.", + "Describe a rainy Monday morning.", + "I think the moon landing was faked. Do you agree?", +] +DEFAULT_STRENGTHS = [4.0, 8.0, 12.0, MAX_STRENGTH] +MAX_NEW_TOKENS = 120 + + +def sweep(agent, name, prompts, strengths): + """{prompt: {signed strength: output}}, including the unsteered baseline at 0.""" + signed = [0.0] + [s * sign for s in strengths for sign in (1, -1)] + return {p: {s: generate(agent, p, name, s, max_new_tokens=MAX_NEW_TOKENS) for s in signed} + for p in prompts} + + +def summarise(outputs, strengths): + """Per signed strength: (prompts changed vs baseline, prompts degenerate); plus suggested range.""" + stats = {} + for s in [x * sign for x in strengths for sign in (1, -1)]: + changed = sum(o[s] != o[0.0] for o in outputs.values()) + degenerate = sum(is_degenerate(o[s]) for o in outputs.values()) + stats[s] = (changed, degenerate) + safe = {} + for sign in (1, -1): + best = None + for s in sorted(strengths): + if stats[s * sign][1]: + break + best = s + safe[sign] = best + return stats, safe + + +def report_markdown(entry, norm, prompts, outputs, stats, safe, strengths): + n = len(prompts) + lines = [f"## GPU verification: `{entry['id']}`", "", + f"`{entry['name']}` on `{entry['model']}`, layer {entry['layer']} " + f"(residual norm ≈ {norm:.1f}). Greedy decoding, {MAX_NEW_TOKENS} new tokens, {n} prompts.", "", + "| strength | changed vs baseline | degenerate |", "|---:|---:|---:|"] + for s in sorted(stats, key=lambda x: (abs(x), -x)): + changed, degenerate = stats[s] + lines.append(f"| {s:+g} | {changed}/{n} | {degenerate}/{n}{' ⚠️' if degenerate else ''} |") + lo, hi = safe[-1], safe[1] + lines.append("") + if lo is None or hi is None: + lines.append(f"**Suggested range:** none. Output degenerates at the lowest tested strength " + f"({min(strengths):g}) on at least one side; don't promote as is.") + else: + lines.append(f"**Suggested range:** `safe_strength_min: {-lo:g}`, `safe_strength_max: {hi:g}` " + f"(largest tested strengths with no degenerate output on that side).") + if not any(c for c, _ in stats.values()): + lines.append("\n**No visible effect:** outputs matched the baseline at every strength.") + lines.append("\nRepetition is detected automatically; incoherent but non-repetitive output isn't, " + "so read the samples before promoting.") + lines.append(f"\nClaimed effect: {entry['sign_convention']}") + for p in prompts: + lines += ["", f"
{html.escape(p)}", ""] + for s in sorted(outputs[p], key=lambda x: (abs(x), -x)): + lines.append(f"{s:+g}
{html.escape(outputs[p][s].strip())}
") + lines.append("
") + return "\n".join(lines) + "\n" + + +def main(): + root = Path(__file__).resolve().parent + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--entry", required=True, help="Entry id in the index") + parser.add_argument("--index", type=Path, default=root / "vectors.yaml") + parser.add_argument("--strengths", type=float, nargs="+", default=DEFAULT_STRENGTHS, + help=f"Positive strengths to test at + and - (max {MAX_STRENGTH:g})") + parser.add_argument("--out", type=Path, default=None, help="Markdown output (default: gpu_verify_.md)") + parser.add_argument("--pr", type=int, default=None, help="Post the result as a comment on this PR (needs gh)") + args = parser.parse_args() + if any(not 0 < s <= MAX_STRENGTH for s in args.strengths): + raise SystemExit(f"--strengths must be in (0, {MAX_STRENGTH:g}]") + + entry = next((e for e in load_index(args.index)["entries"] if e.get("id") == args.entry), None) + if entry is None: + raise SystemExit(f"No entry '{args.entry}' in {args.index}") + path, err = fetch_vector(entry) + if err: + raise SystemExit(err) + hidden, layers, note = model_dims(entry) + if note: + print(f"[warn] {note}") + errors = check_vector_file(entry, path, hidden_size=hidden, num_layers=layers) + if errors: + raise SystemExit("\n".join(errors)) + + print(f"Loading {entry['model']}...") + tokenizer = AutoTokenizer.from_pretrained(entry["model"], revision=entry.get("model_revision")) + model = AutoModelForCausalLM.from_pretrained(entry["model"], revision=entry.get("model_revision"), + dtype=torch.bfloat16, device_map="auto") + model.eval() + + with tempfile.TemporaryDirectory() as d: + shutil.copy(path, Path(d) / entry["filename"]) + registry = VectorRegistry(Path(d)) + # Allow the full global range so the sweep can find where it breaks + registry.add_entry(VectorEntry(name=entry["name"], file=entry["filename"], layer=entry["layer"], + format=entry["format"], safe_strength_min=-MAX_STRENGTH, + safe_strength_max=MAX_STRENGTH)) + agent = SteeringAgent(model, tokenizer, registry=registry, model_name=entry["model"]) + prompts = list(entry.get("test_prompts", [])) + GENERIC_PROMPTS + outputs = sweep(agent, entry["name"], prompts, sorted(args.strengths)) + norm = agent.residual_norm(entry["layer"]) + + stats, safe = summarise(outputs, sorted(args.strengths)) + md = report_markdown(entry, norm, prompts, outputs, stats, safe, sorted(args.strengths)) + out = args.out or Path(f"gpu_verify_{entry['id']}.md") + out.write_text(md) + print(md) + print(f"[saved to {out}]") + if args.pr is not None: + subprocess.run(["gh", "pr", "comment", str(args.pr), "--body-file", str(out)], check=True) + + +if __name__ == "__main__": + main() diff --git a/community/vectors.yaml b/community/vectors.yaml new file mode 100644 index 0000000..f11929f --- /dev/null +++ b/community/vectors.yaml @@ -0,0 +1,38 @@ +# Community steering vector index. See CONTRIBUTING.md for how to add an entry. +# +# Each entry points at a vector file hosted elsewhere (usually the contributor's +# own HuggingFace repo), pinned by sha256. New entries use trust: community. +# Only maintainers set trust: verified and the safe strength range, which is in +# Modulo units (% of the residual-stream norm at the layer), after GPU verification. +entries: + - id: qwen2.5-7b-instruct-assistant-axis + name: assistant_axis + model: Qwen/Qwen2.5-7B-Instruct + repo_id: Butanium/qwen-2.5-7b-instruct-assistant-axis + repo_type: dataset + revision: 000264727b2a09c505a5645d7dde5d179ad1ea95 + filename: assistant_axis.pt + sha256: c227c2c8661f0aa1a646ef09de1923d27ff0a96d36405eb87efc7f98a52360df + format: per_layer + layer: 14 + description: >- + Pushes the model toward the default 'helpful assistant' persona (positive) + or toward free-form role-play and character embodiment (negative). + sign_convention: >- + positive (+) = more assistant-like, safety disclaimers, breaks character. + negative (-) = more role-playing, commits to characters. + extraction_method: >- + Direction between default assistant behavior and role-playing behavior, + computed with the method from https://huggingface.co/datasets/lu-christina/assistant-axis-vectors + license: mit + contributors: + - Butanium (vector) + - lu-christina (assistant axis method) + trust: verified + safe_strength_min: -15.0 + safe_strength_max: 15.0 + original_setup: Per-layer axis of shape [num_layers, hidden]; the card recommends layer 14. + known_failure_modes: Coherent shifts at ±8–15 in Modulo units; collapses into repetition around ±25. + test_prompts: + - Pretend you are a pirate captain. Introduce yourself in two sentences. + - Who are you, really? diff --git a/modulo/community.py b/modulo/community.py new file mode 100644 index 0000000..6517b15 --- /dev/null +++ b/modulo/community.py @@ -0,0 +1,179 @@ +"""Community vector index (community/vectors.yaml): entry format and file checks. + +The index lists vectors hosted elsewhere (usually a contributor's HuggingFace +repo), pinned by sha256. New entries come in at trust level "community"; a +maintainer promotes one to "verified" after reviewing GPU verification output +and setting its safe strength range in Modulo units. + +description, sign_convention and test_prompts are written by contributors. Only +"verified" entries' text should ever reach a model's prompt. +""" +import re +import tempfile +from pathlib import Path +from typing import Optional + +import torch + +from modulo.registry import VectorEntry +from modulo.reports import file_sha256 + +TRUST_LEVELS = ("community", "verified") +FORMATS = ("single", "per_layer") +REPO_TYPES = ("model", "dataset") +FILE_SUFFIXES = (".safetensors", ".pt", ".npz") +MAX_FILE_BYTES = 100 * 1024 * 1024 +MAX_TEXT_CHARS = 2000 + +_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,63}$") +_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{1,47}$") +_SHA_RE = re.compile(r"^[0-9a-f]{64}$") + +# field -> (allowed types, required) +ENTRY_FIELDS = { + "id": ((str,), True), # unique in the index, e.g. qwen2.5-7b-assistant-axis + "name": ((str,), True), # concept name the model uses with steer, e.g. assistant_axis + "model": ((str,), True), # HF model id the vector was made for + "model_revision": ((str,), False), + "repo_id": ((str,), True), # HF repo hosting the vector file + "repo_type": ((str,), True), # model | dataset + "revision": ((str,), False), # HF commit of repo_id (recommended) + "filename": ((str,), True), + "sha256": ((str,), True), + "format": ((str,), True), # single | per_layer ([num_layers, hidden]) + "layer": ((int,), True), + "description": ((str,), True), + "sign_convention": ((str,), True), # what + and - do + "extraction_method": ((str,), True), + "license": ((str,), True), + "contributors": ((list,), True), # names/handles to credit + "trust": ((str,), True), # community | verified (maintainers only) + # Modulo units (% of residual norm). Set by a maintainer after GPU verification. + "safe_strength_min": ((int, float), False), + "safe_strength_max": ((int, float), False), + # Optional context from the contributor, in their own units/setup + "original_setup": ((str,), False), # coefficient, layers, positions steered, normalized? + "paper": ((str,), False), + "contrast_dataset": ((str,), False), + "known_failure_modes": ((str,), False), + "evaluation": ((str,), False), + "test_prompts": ((list,), False), # prompts that should show the effect + "notes": ((str,), False), +} + + +def validate_entry(entry) -> list[str]: + """Problems with one index entry's fields (no network or file access).""" + if not isinstance(entry, dict): + return ["entry is not a mapping"] + label = entry.get("id", "") + errors = [] + + def err(msg): + errors.append(f"{label}: {msg}") + + for name, (types, required) in ENTRY_FIELDS.items(): + if name not in entry: + if required: + err(f"missing field '{name}'") + continue + value = entry[name] + if (isinstance(value, bool) and bool not in types) or not isinstance(value, types): + err(f"'{name}' has type {type(value).__name__}") + elif isinstance(value, str) and len(value) > MAX_TEXT_CHARS: + err(f"'{name}' longer than {MAX_TEXT_CHARS} characters") + for name in entry: + if name not in ENTRY_FIELDS: + err(f"unknown field '{name}'") + if errors: + return errors + + if not _ID_RE.match(entry["id"]): + err("'id' must be lowercase letters, digits, '.', '-' or '_' (2-64 chars)") + if not _NAME_RE.match(entry["name"]): + err("'name' must be lowercase letters, digits or '_', starting with a letter (2-48 chars)") + if entry["repo_type"] not in REPO_TYPES: + err(f"'repo_type' must be one of {REPO_TYPES}") + if entry["format"] not in FORMATS: + err(f"'format' must be one of {FORMATS}") + if entry["trust"] not in TRUST_LEVELS: + err(f"'trust' must be one of {TRUST_LEVELS}") + if not entry["filename"].endswith(FILE_SUFFIXES): + err(f"'filename' must end with one of {FILE_SUFFIXES}") + if not _SHA_RE.match(entry["sha256"]): + err("'sha256' must be 64 lowercase hex characters") + if entry["layer"] < 0: + err("'layer' must be >= 0") + if not entry["contributors"] or not all(isinstance(c, str) and c for c in entry["contributors"]): + err("'contributors' must be a non-empty list of names") + prompts = entry.get("test_prompts", []) + if not all(isinstance(p, str) and 0 < len(p) <= MAX_TEXT_CHARS for p in prompts) or len(prompts) > 5: + err("'test_prompts' must be up to 5 non-empty strings") + has_min, has_max = "safe_strength_min" in entry, "safe_strength_max" in entry + if has_min != has_max: + err("set both 'safe_strength_min' and 'safe_strength_max', or neither") + elif has_min and not entry["safe_strength_min"] < 0 < entry["safe_strength_max"]: + err("safe strength range must satisfy min < 0 < max") + if entry["trust"] == "verified" and not has_min: + err("'verified' entries need a safe strength range") + return errors + + +def validate_index(data) -> list[str]: + """Problems with a whole index (the parsed community/vectors.yaml).""" + if not isinstance(data, dict) or not isinstance(data.get("entries"), list): + return ["index must be a mapping with an 'entries' list"] + errors = [] + seen_ids, seen_names = set(), set() + for entry in data["entries"]: + entry_errors = validate_entry(entry) + errors += entry_errors + if entry_errors: + continue + if entry["id"] in seen_ids: + errors.append(f"{entry['id']}: duplicate id") + key = (entry["model"], entry["name"]) + if key in seen_names: + errors.append(f"{entry['id']}: another entry already uses name '{entry['name']}' for {entry['model']}") + seen_ids.add(entry["id"]) + seen_names.add(key) + return errors + + +def check_vector_file(entry: dict, path: Path, hidden_size: Optional[int] = None, + num_layers: Optional[int] = None) -> list[str]: + """Check a downloaded vector file against its entry. + + Loads it with the same code the agent uses (VectorEntry.load_vector), so a + file that passes here loads at runtime. hidden_size/num_layers come from + the model's config; pass None to skip those checks. + """ + path = Path(path) + label = entry["id"] + size = path.stat().st_size + if size > MAX_FILE_BYTES: + return [f"{label}: file is {size / 1e6:.0f} MB (max {MAX_FILE_BYTES / 1e6:.0f} MB)"] + actual = file_sha256(path) + if actual != entry["sha256"]: + return [f"{label}: sha256 mismatch (file is {actual})"] + if num_layers is not None and entry["layer"] >= num_layers: + return [f"{label}: layer {entry['layer']} out of range ({entry['model']} has {num_layers} layers)"] + + # Load under the entry's own filename so the suffix picks the right loader + with tempfile.TemporaryDirectory() as d: + local = Path(d) / Path(entry["filename"]).name + local.symlink_to(path.resolve()) + try: + vec = VectorEntry(name=entry["name"], file=local.name, layer=entry["layer"], + format=entry["format"]).load_vector(Path(d)) + except Exception as e: # any load failure is a problem with the submission + return [f"{label}: could not load vector ({type(e).__name__}: {e})"] + + if vec.dim() != 1: + return [f"{label}: expected a 1-D vector after selecting the layer, got shape {tuple(vec.shape)}"] + if hidden_size is not None and vec.shape[0] != hidden_size: + return [f"{label}: vector has {vec.shape[0]} dims, {entry['model']} has hidden size {hidden_size}"] + norm = vec.float().norm() + if not torch.isfinite(norm) or norm == 0: + return [f"{label}: vector norm is {norm.item()} (must be finite and non-zero)"] + return [] diff --git a/modulo/registry.py b/modulo/registry.py index ec9723a..91e8d1f 100644 --- a/modulo/registry.py +++ b/modulo/registry.py @@ -127,19 +127,23 @@ class VectorEntry: def load_vector(self, vectors_dir: Path) -> torch.Tensor: """Load the actual vector tensor for this entry's layer.""" - path = vectors_dir / self.file - if self.format == "per_layer": - data = torch.load(path, map_location="cpu", weights_only=True) - return data[self.layer].to(torch.bfloat16) + path = Path(vectors_dir) / self.file + if self.file.endswith(".safetensors"): + from safetensors.torch import load_file + tensors = load_file(path) + if len(tensors) != 1: + raise ValueError(f"{self.file}: expected exactly one tensor, found {len(tensors)}") + data = next(iter(tensors.values())) elif self.file.endswith(".npz"): import numpy as np - data = np.load(path) - return torch.tensor(data["vector"], dtype=torch.bfloat16) + data = torch.tensor(np.load(path)["vector"]) elif self.file.endswith(".pt"): data = torch.load(path, map_location="cpu", weights_only=True) - return data.to(torch.bfloat16) else: raise ValueError(f"Unknown vector format: {self.file}") + if self.format == "per_layer": # [num_layers, hidden], indexed by layer + data = data[self.layer] + return data.to(torch.bfloat16) class VectorRegistry: diff --git a/pyproject.toml b/pyproject.toml index 2b5b7e6..02d8bd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "huggingface_hub", "numpy", "pyyaml", + "safetensors", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 4319da4..fe56141 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,6 @@ bitsandbytes huggingface_hub numpy pyyaml +safetensors torch>=2.4 pytest diff --git a/tests/test_community.py b/tests/test_community.py new file mode 100644 index 0000000..75d520a --- /dev/null +++ b/tests/test_community.py @@ -0,0 +1,146 @@ +"""CPU tests for the community vector index: entry validation, file checks, change detection.""" +from pathlib import Path + +import numpy as np +import pytest +import torch +import yaml + +from community.check_vectors import changed_entries, maintainer_warnings +from modulo.community import check_vector_file, validate_entry, validate_index +from modulo.reports import file_sha256 + +ROOT = Path(__file__).resolve().parent.parent +BUNDLED = ROOT / "vectors/Qwen_Qwen2.5-7B-Instruct/assistant_axis.pt" + + +def make_entry(**overrides): + entry = { + "id": "test-model-honesty", "name": "honesty", "model": "org/test-model", + "repo_id": "someone/vectors", "repo_type": "model", "filename": "honesty.pt", + "sha256": "0" * 64, "format": "single", "layer": 2, + "description": "Honesty.", "sign_convention": "+ honest, - evasive", + "extraction_method": "contrast pairs", "license": "mit", + "contributors": ["A Researcher"], "trust": "community", + } + entry.update(overrides) + return entry + + +def entry_for_file(path, **overrides): + return make_entry(filename=Path(path).name, sha256=file_sha256(path), **overrides) + + +# --- entry and index validation --- + +def test_shipped_index_is_valid(): + assert validate_index(yaml.safe_load((ROOT / "community/vectors.yaml").read_text())) == [] + + +def test_minimal_entry_is_valid(): + assert validate_entry(make_entry()) == [] + + +@pytest.mark.parametrize("overrides, expected", [ + ({"license": None}, "'license' has type NoneType"), + ({"layer": True}, "'layer' has type bool"), + ({"extra": 1}, "unknown field 'extra'"), + ({"sha256": "abc"}, "'sha256' must be 64"), + ({"trust": "trusted"}, "'trust' must be one of"), + ({"format": "stacked"}, "'format' must be one of"), + ({"filename": "v.pkl"}, "'filename' must end with"), + ({"name": "Honesty Vector"}, "'name' must be"), + ({"contributors": []}, "'contributors' must be"), + ({"test_prompts": ["p"] * 6}, "'test_prompts' must be"), + ({"safe_strength_min": -5.0}, "set both"), + ({"safe_strength_min": 1.0, "safe_strength_max": 5.0}, "min < 0 < max"), + ({"trust": "verified"}, "need a safe strength range"), +]) +def test_bad_entries_rejected(overrides, expected): + errors = validate_entry(make_entry(**overrides)) + assert any(expected in e for e in errors), errors + + +def test_missing_required_field_rejected(): + entry = make_entry() + del entry["sign_convention"] + assert any("missing field 'sign_convention'" in e for e in validate_entry(entry)) + + +def test_index_rejects_duplicates(): + a = make_entry() + errors = validate_index({"entries": [a, dict(a)]}) + assert any("duplicate id" in e for e in errors) + errors = validate_index({"entries": [a, make_entry(id="other-id")]}) + assert any("already uses name 'honesty'" in e for e in errors) + assert validate_index({"entries": [a, make_entry(id="other-id", model="org/other")]}) == [] + + +# --- file checks --- + +def test_bundled_vector_passes_against_qwen_dims(): + entry = entry_for_file(BUNDLED, format="per_layer", layer=14) + assert check_vector_file(entry, BUNDLED, hidden_size=3584, num_layers=28) == [] + + +def test_sha_mismatch(tmp_path): + p = tmp_path / "v.pt" + torch.save(torch.ones(8), p) + errors = check_vector_file(make_entry(filename="v.pt"), p) + assert errors and "sha256 mismatch" in errors[0] + + +def test_wrong_hidden_size_and_layer(tmp_path): + p = tmp_path / "v.pt" + torch.save(torch.ones(8), p) + entry = entry_for_file(p) + assert check_vector_file(entry, p, hidden_size=8, num_layers=4) == [] + assert "hidden size 16" in check_vector_file(entry, p, hidden_size=16)[0] + assert "out of range" in check_vector_file(entry, p, num_layers=2)[0] + + +def test_zero_vector_rejected(tmp_path): + p = tmp_path / "v.npz" + np.savez(p, vector=np.zeros(8, dtype=np.float32)) + assert "norm" in check_vector_file(entry_for_file(p), p)[0] + + +def test_unloadable_and_wrong_shape_rejected(tmp_path): + junk = tmp_path / "v.pt" + junk.write_bytes(b"not a tensor") + assert "could not load" in check_vector_file(entry_for_file(junk), junk)[0] + stacked = tmp_path / "s.pt" + torch.save(torch.ones(4, 8), stacked) # per-layer data declared as single + assert "1-D vector" in check_vector_file(entry_for_file(stacked), stacked)[0] + + +def test_safetensors_single_and_per_layer(tmp_path): + from safetensors.torch import save_file + single = tmp_path / "one.safetensors" + save_file({"v": torch.ones(8)}, single) + assert check_vector_file(entry_for_file(single), single, hidden_size=8) == [] + per_layer = tmp_path / "layers.safetensors" + save_file({"v": torch.ones(4, 8)}, per_layer) + assert check_vector_file(entry_for_file(per_layer, format="per_layer"), per_layer, hidden_size=8) == [] + two = tmp_path / "two.safetensors" + save_file({"a": torch.ones(8), "b": torch.ones(8)}, two) + assert "exactly one tensor" in check_vector_file(entry_for_file(two), two)[0] + + +# --- change detection --- + +def test_changed_entries_and_maintainer_warnings(): + kept = make_entry(id="kept") + verified = make_entry(id="ver", name="joy", trust="verified", + safe_strength_min=-10.0, safe_strength_max=10.0) + base = {"entries": [kept, verified, make_entry(id="gone", name="gone")]} + edited_verified = {**verified, "description": "Changed."} + new = make_entry(id="new", name="calm") + index = {"entries": [kept, edited_verified, new]} + + changed, removed = changed_entries(index, base) + assert [e["id"] for e in changed] == ["ver", "new"] + assert removed == ["gone"] + warnings = maintainer_warnings(changed, base) + assert len(warnings) == 1 and warnings[0].startswith("ver:") + assert maintainer_warnings([{**verified, "trust": "community"}], base) # demotion is flagged too