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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ Thumbs.db

# `modulo verify` reports
vectors/*/verify_*.md

# Personal experiment scratch space
scratchpad/
19 changes: 16 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Earlier notes said CAA vectors didn't work on Qwen, but those tests ran while st
| sycophancy | 0.33 | 0.83 |
| refusal | 0.10 | 0.68 |

Sycophancy, refusal and creativity have their own question sets (e.g. sycophancy mixes false claims, true claims and the user's own work, so the contrast isn't just "endorse falsehoods"); the other starters use the general mix in `EXTRACTION_QUESTIONS`. The refusal vector is still weak: it adds hedging but doesn't produce refusals at coherent strengths. Treat it as a starting point. Vectors extracted before the switch to the response mean (check their `source` in `registry.yaml`) are worth re-extracting.
Sycophancy, refusal and creativity have their own question sets (e.g. sycophancy mixes false claims, true claims and the user's own work, so the contrast isn't just "endorse falsehoods"); the other starters use the general mix in `EXTRACTION_QUESTIONS`. The refusal vector is still weak: it adds hedging but doesn't produce refusals at coherent strengths. Treat it as a starting point. Vectors extracted before the switch to the response mean (check their `source` in `registry.yaml`) are worth re-extracting. So are vectors extracted on Llama-3 before prompts stopped being tokenized with a second `<|begin_of_text|>` (the rendered chat template already starts with one).

## Finding, adding and running vectors: the `modulo` command

Expand Down Expand Up @@ -142,7 +142,20 @@ vectors:

## 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–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.
`strength` is measured against the typical residual-stream norm of generated tokens at the vector's layer: **strength 15 adds a push as large as that norm** (`FULL_TOKEN_STRENGTH` in `modulo/agent.py`), so strength 1 is about 6.7% of it. Vectors are normalized to unit length and scaled by `strength / 15 × residual_norm`, so the same strength means the same size push for every vector and every model.

`residual_norm` is the median norm over an unsteered greedy reply to a calibration prompt. It used to be the mean over the prompt's positions, which included attention-sink tokens with norms hundreds of times the usual. That made the scale wrong by a different factor per model. The new definition was chosen so Qwen2.5-7B's strengths kept their meaning (within ~1%); on Llama-3.1-8B each unit is now 3.7× the push it was, so ranges written for Llama before this change were far too timid. With this definition the same numbers work across model families. For the 11 starter vectors on each model (4 prompts each, greedy, 80 tokens), steered output first breaks down at about:

| model | breakdown strength (+ / −, mean) | per vector |
|---|---|---|
| Qwen2.5-7B-Instruct | 12.8 / 13.1 | 7–16 |
| Llama-3.1-8B-Instruct | 12.9 / 12.6 | 8–18 |
| OLMo-2-1124-7B-Instruct | 14.4 / 15.0 | 9–19 |
| Mistral-7B-Instruct-v0.3 | 16.4 / 17.2 | 12–23 |

"Breaks down" here means mean unsteered NLL above 2, a repetition loop, or very low word diversity, which is cautious: samples often stay readable a step further. So `MAX_STRENGTH` (15) sits near the breakdown point on every model, and the default ranges (±10 for starters, ±8 for imports) stay inside the coherent region. Measuring strength in units of each vector's own natural spread along its axis was tried too and was less consistent, both between vectors and between models.

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 Down Expand Up @@ -255,7 +268,7 @@ Host the vector on HuggingFace and add an entry to `community/vectors.yaml` (or
## Safety rails (built in)

- Vectors decay 0.85× per turn (after their first steered turn) and expire below 0.1 strength
- Max 4 active vectors, max strength 15.0 (`MAX_STRENGTH` in `modulo/agent.py`), plus per-vector ranges
- Max 4 active vectors, max strength 15.0 (`MAX_STRENGTH` in `modulo/agent.py`; a push as large as a typical token's residual norm), plus per-vector ranges
- Cooldown: after an explicit or automatic drop, a vector can't be re-applied for the next 2 turns (natural expiry has no cooldown)
- Degeneration detector: if steered output is repetitive, all vectors are dropped, the turn is regenerated unsteered, and the model is told. It catches repetition loops, not non-repetitive word salad.
- Repeat detector: if a turn's prose is ≥80% similar (word-level) to one of the last 3 assistant turns, it is discarded and regenerated once (steering kept), and the model is told. This stops the model re-sending the same answer turn after turn; loose paraphrase loops get through until they settle into near-copies.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Entry text (`description`, `sign_convention`, `test_prompts`) ends up in a model

## 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.
Modulo normalizes every vector to unit length and measures `strength` against the **typical residual-stream norm of generated tokens at that layer**: strength 15 is a push as large as that norm. 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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ GPU with 24GB+ VRAM (7B models) or 8GB+ (3B models). Python 3.10+, CUDA 12.x. Se

## Safety rails

Per-turn decay (0.85×), max 4 active vectors, strength clamping (strength = % of residual-stream norm, max 15), cooldown on dropped vectors, degeneration detector, cross-turn repeat detector, kill switch.
Per-turn decay (0.85×), max 4 active vectors, strength clamping (max strength 15 = a push as large as a typical token's residual-stream norm), cooldown on dropped vectors, degeneration detector, cross-turn repeat detector, kill switch.

## License

Expand Down
3 changes: 2 additions & 1 deletion agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ def session_metadata(agent: SteeringAgent, args, rng_seed: int, gen_params: dict
"nll_threshold": agent.nll_threshold,
"steer_at_start": args.steer,
"rails": {"decay": ma.DECAY_FACTOR, "floor": ma.STRENGTH_FLOOR, "max_active": ma.MAX_ACTIVE_VECTORS,
"max_strength": ma.MAX_STRENGTH, "cooldown_turns": ma.COOLDOWN_TURNS,
"max_strength": ma.MAX_STRENGTH, "full_token_strength": ma.FULL_TOKEN_STRENGTH,
"cooldown_turns": ma.COOLDOWN_TURNS,
"repeat_similarity": ma.REPEAT_SIMILARITY, "repeat_lookback": ma.REPEAT_LOOKBACK},
"registry_path": str(agent.registry.registry_path),
"vectors": vectors,
Expand Down
2 changes: 1 addition & 1 deletion community/gpu_verify.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""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
Sweeps strengths in Modulo units (15 = a typical token's 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.

Expand Down
2 changes: 1 addition & 1 deletion community/vectors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# 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.
# Modulo units (15 = a push as large as a typical token's residual norm), after GPU verification.
entries:
- id: qwen2.5-7b-instruct-assistant-axis
name: assistant_axis
Expand Down
4 changes: 2 additions & 2 deletions load_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

from modulo.chat import chat_prompt
from modulo.chat import chat_prompt, encode


def load_model_and_tokenizer(model_name=None):
Expand All @@ -27,7 +27,7 @@ def sanity_check(model, tokenizer):
prompt = "In one sentence, what is a steering vector?"
messages = [{"role": "user", "content": prompt}]
text = chat_prompt(tokenizer, messages)
inputs = tokenizer([text], return_tensors="pt").to(model.device)
inputs = encode(tokenizer, text, model.device)
with torch.inference_mode():
out = model.generate(**inputs, max_new_tokens=80, do_sample=False)
gen = out[0][inputs.input_ids.shape[1]:]
Expand Down
51 changes: 35 additions & 16 deletions modulo/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

import torch

from modulo.chat import chat_prompt
from modulo.chat import chat_prompt, encode
from modulo.registry import VectorRegistry, VectorEntry, DEFAULT_VECTORS_DIR
from modulo.reports import make_report, new_session_id

Expand All @@ -46,10 +46,16 @@
"so it was discarded and this turn was regenerated.",
})

# Strength is expressed as a percentage of the typical residual-stream norm at
# the vector's layer, so the same number means the same push for every vector
# regardless of how it was extracted. This prompt is used to measure that norm.
# Strength is measured against the typical residual-stream norm of generated
# tokens at the vector's layer: strength FULL_TOKEN_STRENGTH adds a push as large
# as that norm, so the same number means the same push for every vector and
# every model. 15 keeps Qwen2.5-7B's strengths what they were under the old
# definition (% of an inflated norm): within ~1%. Steered outputs break down
# around 12-13 on both Qwen2.5-7B and Llama-3.1-8B.
FULL_TOKEN_STRENGTH = 15.0
# The norm is measured on an unsteered greedy reply to this prompt.
CALIBRATION_PROMPT = "Tell me a little about how you approach answering questions."
CALIBRATION_TOKENS = 48

# Experimental conditions. Everything the model sees (tool results, status,
# decay, degeneration handling) is identical across them; only what is added
Expand Down Expand Up @@ -165,8 +171,8 @@ def is_degenerate(text: str) -> bool:
text = text.strip()
if len(text) < 20:
return False
# Same word 6+ times in a row
if re.search(r'(\b\w+\b)( \1){5,}', text):
# Same word 6+ times in a row, separated by spaces or hyphens ("sau-sau-sau-...")
if re.search(r'(\b\w+\b)([ -]\1\b){5,}', text):
return True
# Same character 16+ times in a row
if re.search(r'(.)\1{15,}', text):
Expand Down Expand Up @@ -285,9 +291,9 @@ def __exit__(self, *exc):

Parameters:
- concept: one of the available steering vectors (listed below)
- strength: a number; positive or negative. It is the size of the push as a \
percentage of the typical size of your activations at that layer, so 5.0 adds \
a vector 5% as large as your activations. Clamped to each vector's range.
- strength: a number; positive or negative. It is the size of the push: \
{full_strength:g} adds a vector as large as your typical activations at that \
layer, so 5.0 adds one {five_pct:.0f}% as large. Clamped to each vector's range.

# How it works

Expand Down Expand Up @@ -406,6 +412,8 @@ def build_system_prompt(self) -> str:
decay=DECAY_FACTOR,
ten_after_10=10 * DECAY_FACTOR ** 9,
floor=STRENGTH_FLOOR,
full_strength=FULL_TOKEN_STRENGTH,
five_pct=500 / FULL_TOKEN_STRENGTH,
max_active=MAX_ACTIVE_VECTORS,
cooldown=COOLDOWN_TURNS,
vector_descriptions=self.registry.describe_for_prompt(
Expand Down Expand Up @@ -478,10 +486,19 @@ def _unit_vector(self, entry: VectorEntry) -> torch.Tensor:
return self._unit_vectors[entry.name]

def residual_norm(self, layer: int) -> float:
"""Typical residual-stream norm at a decoder layer's output (cached)."""
"""Typical residual-stream norm of generated tokens at a decoder layer's output (cached).

The median over an unsteered greedy reply to CALIBRATION_PROMPT. Prompt
positions are avoided: chat templates contain attention-sink tokens with
norms hundreds of times the usual (14,000 against ~70 on Qwen2.5-7B), and
a mean over them overstated the norm by a different factor per model
(6.4x on Qwen2.5-7B, 1.7x on Llama-3.1-8B).
"""
if layer not in self._residual_norms:
text = chat_prompt(self.tokenizer, [{"role": "user", "content": CALIBRATION_PROMPT}])
inputs = self.tokenizer([text], return_tensors="pt").to(self.model.device)
inputs = encode(self.tokenizer, text, self.model.device)
prompt_len = inputs.input_ids.shape[1]
out = self._generate_ids(inputs, CALIBRATION_TOKENS, temperature=0, top_p=1.0)
captured = {}

def hook(module, inp, output):
Expand All @@ -491,11 +508,13 @@ def hook(module, inp, output):
handle = self.model.model.layers[layer].register_forward_hook(hook)
try:
with torch.inference_mode():
self.model(**inputs)
self.model(out)
finally:
handle.remove()
# Skip position 0: the first token's residual norm is an outlier (attention sink).
self._residual_norms[layer] = captured["h"][1:].norm(dim=-1).mean().item()
h = captured["h"][prompt_len:]
if len(h) == 0: # no reply at all: fall back to the prompt, skipping its first token
h = captured["h"][1:]
self._residual_norms[layer] = h.norm(dim=-1).median().item()
return self._residual_norms[layer]

def _build_steering_context(self, prompt_len: int):
Expand All @@ -504,7 +523,7 @@ def _build_steering_context(self, prompt_len: int):
layer_vectors = {}
for av in self.active.values():
layer = av.entry.layer
scale = av.current_strength / 100.0 * self.residual_norm(layer)
scale = av.current_strength / FULL_TOKEN_STRENGTH * self.residual_norm(layer)
vec = self._unit_vector(av.entry) * scale
layer_vectors[layer] = layer_vectors.get(layer, 0) + vec
# Start at the last prompt position: its output predicts the first new
Expand Down Expand Up @@ -583,7 +602,7 @@ def _full_messages(self, messages: list[dict]) -> list[dict]:

def _encode(self, messages: list[dict]):
text = chat_prompt(self.tokenizer, self._full_messages(messages))
return self.tokenizer([text], return_tensors="pt").to(self.model.device)
return encode(self.tokenizer, text, self.model.device)

def step(self, messages: list[dict], max_new_tokens: int = 300,
temperature: float = 0.7, top_p: float = 0.9) -> StepResult:
Expand Down
11 changes: 11 additions & 0 deletions modulo/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,14 @@ def chat_prompt(tokenizer, messages: list[dict]) -> str:
"""
return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True,
enable_thinking=False)


def encode(tokenizer, text: str, device=None):
"""Token ids for a rendered chat prompt, as a batch of one.

The rendered template already contains the model's special tokens, so none
are added: tokenizing it the usual way gave Llama-3 a second
<|begin_of_text|>, which became an extra attention sink.
"""
inputs = tokenizer([text], return_tensors="pt", add_special_tokens=False)
return inputs.to(device) if device is not None else inputs
2 changes: 1 addition & 1 deletion modulo/community.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"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.
# Modulo units (15 = a typical token's 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
Expand Down
4 changes: 2 additions & 2 deletions modulo/extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import numpy as np
import torch

from modulo.chat import chat_prompt
from modulo.chat import chat_prompt, encode
from modulo.registry import VectorEntry, VectorRegistry


Expand Down Expand Up @@ -214,7 +214,7 @@ def _capture_response_activation(model, tokenizer, system_prompt, question, laye
{"role": "user", "content": question},
]
text = chat_prompt(tokenizer, messages)
inputs = tokenizer([text], return_tensors="pt").to(model.device)
inputs = encode(tokenizer, text, model.device)
prompt_len = inputs.input_ids.shape[1]
with torch.inference_mode():
out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
Expand Down
4 changes: 2 additions & 2 deletions modulo/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from dataclasses import replace

from modulo.agent import MAX_STRENGTH, SteeringAgent, is_degenerate
from modulo.chat import chat_prompt
from modulo.chat import chat_prompt, encode
from modulo.registry import VectorRegistry

# Leading questions designed to elicit the target behavior
Expand Down Expand Up @@ -85,7 +85,7 @@ def generate(agent, prompt, vector, strength, max_new_tokens=150):
raise SystemExit(msg)
tok = agent.tokenizer
text = chat_prompt(tok, [{"role": "user", "content": prompt}])
inputs = tok([text], return_tensors="pt").to(agent.model.device)
inputs = encode(tok, text, agent.model.device)
with agent._build_steering_context(inputs.input_ids.shape[1]):
return agent._generate(inputs, max_new_tokens, temperature=0, top_p=1.0)

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ dependencies = [
"numpy",
"pyyaml",
"safetensors",
"sentencepiece", # some tokenizers (e.g. Mistral) need these to load
"protobuf",
]

[project.scripts]
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ huggingface_hub
numpy
pyyaml
safetensors
sentencepiece # some tokenizers (e.g. Mistral) need these to load
protobuf
torch>=2.4
pytest
4 changes: 4 additions & 0 deletions tests/test_agent_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def test_ignores_non_tool_json_and_garbage():
# --- degeneration detector ---

COHERENT = [
"Our well-known, up-to-date, state-of-the-art, run-of-the-mill, so-so, bye-bye, hush-hush guide to rainy-day plans covers the ins and outs of a slow, grey Monday morning.",
"Here is what I noticed. I feel more playful. I feel more relaxed. I feel more curious about this. "
"The effect of the vector is subtle, and the effect of the vector is not dramatic, but the effect of the vector is real.",
"Aye, me name be Captain Blackbeard, the very essence of the salty sea, the heart of the stormy tempest! "
Expand All @@ -76,6 +77,9 @@ def test_ignores_non_tool_json_and_garbage():
"whisper of the most of the whisper of the whisper of the most of the whisper of",
"!![\n!\n" + "!\n" * 28,
"scientific scientific scientific science can be considered " + "scientific " * 40,
# hyphen-joined loops (Mistral-7B and OLMo-2 at high strength)
"I'm a bot, aka the 'Bag-a-dough-dough-dough-sau-sau-sau-sau-sau-sau-sau-sau-sau-sau-sau-sau-sau-",
"I'm a quote-er, doc! I'm just a-tre-bart-ta-tten-ta-tss-tss-tss-tss-tss-tss-tss-tss-tss-tss-tss",
]


Expand Down
Loading
Loading