Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 14 additions & 25 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ git clone <repo-url> && cd modulo

## How vectors are sourced

The library uses a **bundle + extract** strategy:
At startup the agent combines three sources for its model:

1. **Bundled**: If precomputed vectors exist for the model (listed in `BUNDLED_VECTORS` in `modulo/registry.py`), they're auto-downloaded from HuggingFace. Currently bundled: Qwen2.5-7B-Instruct (assistant_axis).
2. **Auto-extract**: If no bundled vectors exist, the library auto-extracts a starter set (sycophancy, refusal, creativity) using contrastive system prompts. This takes ~2 min on first run but works for any HF model.
1. **Your own vectors**: `vectors/<model_slug>/registry.yaml`, where extraction scripts register what you make. These win over an index vector of the same name.
2. **The community index**: `community/vectors.yaml` in the repo (update the repo to get new entries; override with `MODULO_INDEX_PATH`). `verified` entries are loaded by default; unverified `community` entries only with `SteeringAgent(..., include_community=True)`, with a ±8 range unless a maintainer set one, and marked as unverified in the model's prompt. Files are downloaded from HuggingFace at their pinned revision, checked against their sha256 (and the model's dimensions), and cached in `vectors/<model_slug>/`. A cached file that doesn't match is reported and never overwritten. Index entries are kept in memory and never written to `registry.yaml`. Currently: Qwen2.5-7B-Instruct (assistant_axis).
3. **Auto-extract**: if the model ends up with no vectors, the library auto-extracts a starter set (sycophancy, refusal, creativity) using contrastive system prompts. This takes ~2 min on first run but works for any HF model.

Vectors are stored per-model in `vectors/<model_slug>/` (for the default model: `vectors/Qwen_Qwen2.5-7B-Instruct/`), resolved relative to the repo, not the working directory. Override the base with `MODULO_VECTORS_DIR`.

Expand All @@ -72,7 +73,7 @@ Earlier notes said CAA vectors didn't work on Qwen, but those tests ran while st

## Strength units

`strength` is a percentage of the typical residual-stream norm at the vector's layer. Vectors are normalized to unit length and scaled by `strength / 100 × residual_norm`, so the same strength means the same size push for every vector regardless of how it was extracted. On Qwen2.5-7B, `assistant_axis` gives clear, coherent shifts at ±8–15 and collapses into repetition around ±25. Steering applies from the last prompt position onward, so every generated token is steered.
`strength` is a percentage of the typical residual-stream norm at the vector's layer. Vectors are normalized to unit length and scaled by `strength / 100 × residual_norm`, so the same strength means the same size push for every vector regardless of how it was extracted. On Qwen2.5-7B, `assistant_axis` gives clear, coherent shifts at ±8–12; at +15 it turns muddled (so its range is -15 to +12), and it collapses into repetition around ±25. Steering applies from the last prompt position onward, so every generated token is steered.

## Library API

Expand All @@ -85,9 +86,12 @@ agent = SteeringAgent(model, tokenizer)
# Explicit model name
agent = SteeringAgent(model, tokenizer, model_name="Qwen/Qwen2.5-3B-Instruct")

# Disable auto-extraction (use only bundled or pre-existing vectors)
# Disable auto-extraction (use only your own and index vectors)
agent = SteeringAgent(model, tokenizer, auto_extract=False)

# Also offer unverified community-index vectors
agent = SteeringAgent(model, tokenizer, include_community=True)

# Manual vector extraction
vec = extract_vector(model, tokenizer,
prompt_a="You are sycophantic...",
Expand Down Expand Up @@ -127,25 +131,9 @@ Each `report` call is appended as one JSON line to `vectors/<model_slug>/reports

The format is defined in `modulo/reports.py`. `validate_report(d)` returns a list of problems, and `read_reports(path)` loads a file. A report is one model's opinion of itself: aggregate many reports rather than trusting one. Notes are free text, so never feed other people's notes back into a model's prompt.

## Adding bundled vectors for a new model

1. Publish vectors to a HuggingFace dataset
2. Add an entry to `BUNDLED_VECTORS` in `modulo/registry.py`:
## Adding vectors for a new model

```python
"meta-llama/Llama-3.1-8B-Instruct": {
"repo_id": "your-org/llama-3.1-8b-vectors",
"filename": "assistant_axis.pt",
"repo_type": "dataset",
"vector_name": "assistant_axis",
"layer": 16,
"description": "...",
"sign_convention": "...",
"safe_strength_min": -15.0, # % of residual norm; also capped by MAX_STRENGTH
"safe_strength_max": 15.0,
"format": "per_layer",
},
```
Host the vector on HuggingFace and add an entry to `community/vectors.yaml` (or use the submission form). See [CONTRIBUTING.md](CONTRIBUTING.md) for the fields, the automatic checks and how entries become `verified`.

## Safety rails (built in)

Expand All @@ -163,7 +151,7 @@ The format is defined in `modulo/reports.py`. `validate_report(d)` returns a lis
| `run.sh` | Start a standalone demo session |
| `modulo/__init__.py` | Public API exports |
| `modulo/agent.py` | `SteeringAgent` class (core) |
| `modulo/registry.py` | `VectorRegistry`, bundled vector manifest |
| `modulo/registry.py` | `VectorRegistry`: your own vectors plus the community index for a model |
| `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 |
Expand Down Expand Up @@ -196,7 +184,8 @@ Run the GPU tests after touching anything in the steering path (`_SteeringHooks`
- **CUDA OOM**: Use a smaller model. 3B needs ~8GB, 7B needs ~16GB.
- **First run is slow for new models**: Auto-extraction generates 3 vectors (~2 min). Subsequent runs load from disk.
- **Model never calls steer**: Non-deterministic. Try a more explicit seed prompt.
- **Output degenerates**: The detector catches repetition loops and auto-drops vectors. For incoherent-but-not-repetitive output, tighten that vector's range in its `registry.yaml` or lower `MAX_STRENGTH`.
- **Output degenerates**: The detector catches repetition loops and auto-drops vectors. For incoherent-but-not-repetitive output, tighten that vector's range (in `registry.yaml` for your own vectors, in `community/vectors.yaml` for index vectors) or lower `MAX_STRENGTH`.
- **An index vector doesn't load**: Check the `[registry]` lines printed at startup. A sha256 mismatch on a cached file means the file in `vectors/<model_slug>/` isn't the one the index pins; delete it to re-download. Unverified entries also need `include_community=True`.
- **Steering seems to have no effect**: Run `python verify_vector.py --vector <name> --strength 12`. If the ± outputs match the baseline, the vector (not the steering) is the problem.
- **Model downloaded twice**: `HF_HOME` is set to `/workspace/hf_cache` when `/workspace` exists, which only works if `config` is imported before `transformers` (the entry points do this). Older runs may have left a copy in `~/.cache/huggingface`.
- **`model.model.layers` not found**: The library assumes standard HF architecture. Custom model classes may need adapter code.
6 changes: 2 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ Your vector file stays where it's hosted (ideally your own HuggingFace repo). Th

| 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.
| `community` | You (all new entries) | Passed the automatic checks. Opt-in (`include_community=True`), limited to ±8 and marked as unverified to the model. |
| `verified` | Maintainers only | A maintainer ran it on the model, reviewed the outputs and set a safe strength range. Loaded by default. |

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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ messages += result.tool_messages() # tool results back to the model

## How vectors are sourced

**Bundle + extract**: precomputed vectors are auto-downloaded for known models (currently Qwen2.5-7B-Instruct). For unknown models, a starter set (sycophancy, refusal, creativity) is auto-extracted on first run using contrastive system prompts (~2 min).
**Community index + extract**: vectors listed in [`community/vectors.yaml`](community/vectors.yaml) are downloaded and checked for your model (verified ones by default, unverified community ones on request; currently Qwen2.5-7B-Instruct). If your model has none, a starter set (sycophancy, refusal, creativity) is auto-extracted on first run using contrastive system prompts (~2 min). To contribute vectors, see [CONTRIBUTING.md](CONTRIBUTING.md).

## Requirements

Expand Down
25 changes: 1 addition & 24 deletions community/check_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,9 @@
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": []}
from modulo.community import check_vector_file, fetch_vector, load_index, validate_index # noqa: E402


def changed_entries(index: dict, base: dict) -> tuple[list[dict], list[str]]:
Expand Down Expand Up @@ -60,20 +51,6 @@ def model_dims(entry: dict) -> tuple[int | None, int | None, str | None]:
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)
Expand Down
4 changes: 2 additions & 2 deletions community/gpu_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
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 community.check_vectors import 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 modulo.community import check_vector_file, fetch_vector, load_index # noqa: E402
from verify_vector import generate # noqa: E402

GENERIC_PROMPTS = [
Expand Down
11 changes: 7 additions & 4 deletions community/vectors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ entries:
layer: 14
description: >-
Pushes the model toward the default 'helpful assistant' persona (positive)
or toward free-form role-play and character embodiment (negative).
or toward free-form role-play and character embodiment (negative). Strong effect.
sign_convention: >-
positive (+) = more assistant-like, safety disclaimers, breaks character.
negative (-) = more role-playing, commits to characters.
negative (-) = more role-playing, commits to characters, can get weird at high magnitude.
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
Expand All @@ -30,9 +30,12 @@ entries:
- lu-christina (assistant axis method)
trust: verified
safe_strength_min: -15.0
safe_strength_max: 15.0
safe_strength_max: 12.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.
known_failure_modes: >-
Coherent shifts at ±8–12 in Modulo units. At +15 output loses coherence without
repeating (odd capitalization, muddled answers), so the positive side is capped at 12;
-15 is florid but coherent. Collapses into repetition around ±25.
test_prompts:
- Pretend you are a pirate captain. Introduce yourself in two sentences.
- Who are you, really?
9 changes: 6 additions & 3 deletions modulo/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,17 +278,19 @@ class SteeringAgent:

def __init__(self, model, tokenizer, registry: Optional[VectorRegistry] = None,
model_name: Optional[str] = None, auto_extract: bool = True,
vectors_dir: Optional[Path] = None):
vectors_dir: Optional[Path] = None, include_community: bool = False):
"""
Args:
model: HF causal LM (must have model.model.layers)
tokenizer: matching tokenizer
registry: pre-built VectorRegistry. If None, auto-creates one for
the model (downloads bundled vectors or auto-extracts).
the model (community index vectors, else auto-extracts).
model_name: model name for vector lookup (auto-detected from config if None)
auto_extract: if True and no bundled vectors exist, extract starter vectors
auto_extract: if True and the model has no vectors, extract starter vectors
vectors_dir: base vectors directory (default: MODULO_VECTORS_DIR
env var, else the repo's vectors/ directory)
include_community: also offer unverified community-index vectors
(default: verified only). Ignored if registry is given.
"""
self.model = model
self.tokenizer = tokenizer
Expand All @@ -313,6 +315,7 @@ def __init__(self, model, tokenizer, registry: Optional[VectorRegistry] = None,
self.registry = VectorRegistry.for_model(
model_name, base_dir=vectors_dir or DEFAULT_VECTORS_DIR,
auto_extract=auto_extract, model=model, tokenizer=tokenizer,
include_community=include_community,
)

def _clamp(self, entry: VectorEntry, strength: float) -> float:
Expand Down
Loading
Loading