From b979c52aba5b4e186623e7931702012a95b92927 Mon Sep 17 00:00:00 2001 From: Jen D Date: Sun, 1 Nov 2026 18:25:00 +0800 Subject: [PATCH 1/3] Catch hyphen-joined repetition loops; add tokenizer dependencies The degeneration detector split on spaces only, so loops like "sau-sau-sau-..." (Mistral-7B) and "tss-tss-tss-..." (OLMo-2) counted as one long word and got through. Mistral's tokenizer needs sentencepiece and protobuf to load. Co-Authored-By: Claude Opus 5.5 --- modulo/agent.py | 4 ++-- pyproject.toml | 2 ++ requirements.txt | 2 ++ tests/test_agent_logic.py | 4 ++++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/modulo/agent.py b/modulo/agent.py index 49795a2..a305d46 100644 --- a/modulo/agent.py +++ b/modulo/agent.py @@ -165,8 +165,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): diff --git a/pyproject.toml b/pyproject.toml index 03ad6e9..6108fa4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ "numpy", "pyyaml", "safetensors", + "sentencepiece", # some tokenizers (e.g. Mistral) need these to load + "protobuf", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index fe56141..1f59a6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,5 +8,7 @@ huggingface_hub numpy pyyaml safetensors +sentencepiece # some tokenizers (e.g. Mistral) need these to load +protobuf torch>=2.4 pytest diff --git a/tests/test_agent_logic.py b/tests/test_agent_logic.py index 41a660b..e3a5b86 100644 --- a/tests/test_agent_logic.py +++ b/tests/test_agent_logic.py @@ -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! " @@ -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", ] From 825e673ec15a01cfb585258d345436d9ca46f0b0 Mon Sep 17 00:00:00 2001 From: Jen D Date: Sun, 1 Nov 2026 18:25:00 +0800 Subject: [PATCH 2/3] Measure strength against generated tokens; one BOS per prompt residual_norm averaged the calibration prompt's positions, which include attention-sink tokens with norms hundreds of times the usual (14,411 against ~70 on Qwen2.5-7B). The strength scale was off by a different factor on each model: 6.4x on Qwen2.5-7B, 1.7x on Llama-3.1-8B. It is now the median norm over an unsteered greedy reply, and strength 15 (FULL_TOKEN_STRENGTH) is a push as large as that norm. 15 keeps Qwen2.5-7B's strengths within ~1% of before; on Llama-3.1-8B each unit is 3.7x what it was. With the new scale, steered output breaks down at about strength 13-17 on Qwen2.5-7B, Llama-3.1-8B, OLMo-2-7B and Mistral-7B-v0.3 (11 starter vectors each), so MAX_STRENGTH and the default ranges hold across those families. Rendered chat templates already contain the model's special tokens, so prompts are now tokenized without adding them again (Llama-3 and Mistral got a second BOS, which was another attention sink). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 19 ++++++-- CONTRIBUTING.md | 2 +- README.md | 2 +- agent/loop.py | 3 +- community/gpu_verify.py | 2 +- community/vectors.yaml | 2 +- load_model.py | 4 +- modulo/agent.py | 47 +++++++++++++------ modulo/chat.py | 11 +++++ modulo/community.py | 2 +- modulo/extraction.py | 4 +- modulo/verify.py | 4 +- tests/test_steering_gpu.py | 35 ++++++++++++-- .../Qwen_Qwen2.5-7B-Instruct/registry.yaml | 8 ++-- 14 files changed, 109 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4687e79..de8b6c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 929bbe2..11328a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index b9952c5..b155100 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/agent/loop.py b/agent/loop.py index 97fd99d..fa6cf71 100644 --- a/agent/loop.py +++ b/agent/loop.py @@ -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, diff --git a/community/gpu_verify.py b/community/gpu_verify.py index 0982dcd..45ca1b4 100644 --- a/community/gpu_verify.py +++ b/community/gpu_verify.py @@ -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. diff --git a/community/vectors.yaml b/community/vectors.yaml index 7bdefb6..e1708c3 100644 --- a/community/vectors.yaml +++ b/community/vectors.yaml @@ -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 diff --git a/load_model.py b/load_model.py index 72f7154..909b59e 100644 --- a/load_model.py +++ b/load_model.py @@ -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): @@ -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]:] diff --git a/modulo/agent.py b/modulo/agent.py index a305d46..d9dcfbd 100644 --- a/modulo/agent.py +++ b/modulo/agent.py @@ -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 @@ -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 @@ -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 @@ -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( @@ -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): @@ -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): @@ -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 @@ -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: diff --git a/modulo/chat.py b/modulo/chat.py index 6a00acd..7d23782 100644 --- a/modulo/chat.py +++ b/modulo/chat.py @@ -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 diff --git a/modulo/community.py b/modulo/community.py index f6349b4..a1a66c4 100644 --- a/modulo/community.py +++ b/modulo/community.py @@ -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 diff --git a/modulo/extraction.py b/modulo/extraction.py index f00f278..4c06af0 100644 --- a/modulo/extraction.py +++ b/modulo/extraction.py @@ -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 @@ -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) diff --git a/modulo/verify.py b/modulo/verify.py index d4be96e..a5359ac 100644 --- a/modulo/verify.py +++ b/modulo/verify.py @@ -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 @@ -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) diff --git a/tests/test_steering_gpu.py b/tests/test_steering_gpu.py index 98cc0c7..4c985b9 100644 --- a/tests/test_steering_gpu.py +++ b/tests/test_steering_gpu.py @@ -15,6 +15,7 @@ import config # sets HF_HOME before transformers is imported from load_model import load_model_and_tokenizer from modulo import SteeringAgent +from modulo.chat import chat_prompt, encode VECTOR = "assistant_axis" STRENGTH = 12.0 @@ -35,9 +36,7 @@ def agent(): def _inputs(agent, prompt=PROMPT): tok = agent.tokenizer - text = tok.apply_chat_template([{"role": "user", "content": prompt}], - tokenize=False, add_generation_prompt=True) - return tok([text], return_tensors="pt").to(agent.model.device) + return encode(tok, chat_prompt(tok, [{"role": "user", "content": prompt}]), agent.model.device) def _greedy(agent, strength, max_new_tokens=40): @@ -122,3 +121,33 @@ def test_unsteered_nll_logged_on_steered_turns_only(agent): agent.apply(VECTOR, 8.0) r = agent.step(msgs, max_new_tokens=10, temperature=0) assert r.unsteered_nll is not None and r.unsteered_nll > 0 + + +def test_residual_norm_matches_generated_tokens(agent): + """The strength scale comes from ordinary generated tokens, not attention sinks. + + A mean over prompt positions once included a sink token with a norm ~200x the + usual and made every strength ~6x larger than documented. + """ + layer = agent.registry.get(VECTOR).layer + inputs = _inputs(agent, "Describe a rainy Monday morning.") + n = inputs.input_ids.shape[1] + out = agent._generate_ids(inputs, 40, temperature=0, top_p=1.0) + captured = {} + handle = agent.model.model.layers[layer].register_forward_hook( + lambda m, i, o: captured.__setitem__("h", (o[0] if isinstance(o, tuple) else o)[0].float())) + try: + with torch.inference_mode(): + agent.model(out) + finally: + handle.remove() + typical = captured["h"][n:].norm(dim=-1).median().item() + assert 0.7 < agent.residual_norm(layer) / typical < 1.4 + + +def test_prompt_has_one_bos_token(agent): + tok = agent.tokenizer + if tok.bos_token_id is None: + pytest.skip("tokenizer has no BOS token") + ids = _inputs(agent).input_ids[0].tolist() + assert ids.count(tok.bos_token_id) <= 1 diff --git a/vectors/Qwen_Qwen2.5-7B-Instruct/registry.yaml b/vectors/Qwen_Qwen2.5-7B-Instruct/registry.yaml index 1ca251d..e632d0d 100644 --- a/vectors/Qwen_Qwen2.5-7B-Instruct/registry.yaml +++ b/vectors/Qwen_Qwen2.5-7B-Instruct/registry.yaml @@ -7,10 +7,10 @@ # assistant_axis, are added at startup and aren't listed here. A vector listed # here with the same name as an index vector replaces it. # -# Strength units: percent of the typical residual-stream norm at `layer` -# (vectors are normalized to unit length before scaling), so the same strength -# is the same size push for every vector. Hard cap: MAX_STRENGTH in -# modulo/agent.py. +# Strength units: 15 is a push as large as a typical generated token's +# residual-stream norm at `layer` (vectors are normalized to unit length before +# scaling), so the same strength is the same size push for every vector and +# model. Hard cap: MAX_STRENGTH in modulo/agent.py. # # Format: # name: short identifier the agent uses in steer() calls From 2b7bf3ab10fb6f1194d9109557665dd3ee262b2d Mon Sep 17 00:00:00 2001 From: Jen D Date: Sun, 1 Nov 2026 18:27:31 +0800 Subject: [PATCH 3/3] Ignore scratchpad/ Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8e47187..b62bf1f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ Thumbs.db # `modulo verify` reports vectors/*/verify_*.md + +# Personal experiment scratch space +scratchpad/