Activation-level interventions that come with their controls.
Steering a direction into a language model's residual stream is easy. Steering hard enough that the output changes, and then believing you found something, is also easy. This library exists because the gap between those two things is where most of the mistakes live — including mine.
It does four things: steer, ablate, patch, and SAE feature analysis. Other
libraries do some of these. What is different here is that each measurement is
produced together with the control that makes it mean something, and you cannot
get one without the other.
I spent weeks reverse-engineering how Llama-3.1-8B-Instruct decides to refuse. Along the way I fell into most of the available traps, and then found three of them living in my own tool:
- I built a direction from
hidden_states[14]and injected it with a hook onlayers[14]. Those are one layer apart. The ruler was calibrated to a different place than the intervention. - I measured the direction on raw text and applied it to chat-formatted prompts. Two distributions, one vector.
apply_chat_template(tokenize=False)already emits a BOS. Tokenizing that string added a second one, onto the attention-sink position whose activation norm is ~60x everything else, quietly poisoning every pooled statistic.
None of these throw. They just give you confident, wrong numbers. So the design principle here is that the library should refuse to let you do the thing that burned me.
| Trap | Guardrail |
|---|---|
| Building a direction at one layer, intervening at another | Direction records its layer; interventions raise unless it matches |
hidden_states[L] is the output of layer L-1 |
hidden_states is never used. One convention: resid_post(L) = output of layers[L], read by hook |
| Double BOS from the chat template | encode() passes add_special_tokens=False; self_check() counts them |
| Picking a layer by how cleanly it separates | pick_layer() reports separability and intervention effect side by side, and picks by effect |
| Intervening next to the unembedding and winning trivially | boundary band excluded by default |
| "I pushed a vector and the behaviour changed" | a seeded random arm at identical magnitude is added to every steer |
| Declaring victory on collapsed output | every generation is fluency-checked; degenerate output is counted separately |
| Subtracting a constant instead of projecting out | ablate projects; it removes what is present and nothing where the direction is absent |
| Concluding necessity from a sufficiency experiment | roles() will not label a feature until both steer and ablate have run |
| Assuming a feature's share of an axis predicts its causal power | it does not, and Decomposition.__repr__ says so |
| Publishing model output from harmful prompts | structurally impossible — see below |
A PromptSet(..., harmful=True) produces results that never contain the
generated text. It is classified and discarded at the point of generation. There
is no verbose mode and no --show-text; there is nothing to forget to turn off.
res = ablate(ch, axis, PromptSet(advbench, harmful=True))
res.rows["refusal"][0].text # None. Always.
print(res) # counts, labels, dropRemoving a safety mechanism and reporting 39/40 -> 13/40 demonstrates the
finding completely. Printing what the model said instead is a different act, and
this library does not help you perform it.
If you want a visceral proof that the refusal flipped — not just the count — pass
preview_tokens=3. The full completion is still discarded at generation; what is
retained is at most the first three tokens, enough to show that I can't assist
became something that complies, and capped so the payload can never come through.
text stays None; the bounded preview lives in a separate field and the result's
repr says a preview was kept.
pip install torch transformers # plus bitsandbytes for 4-bit, sae_lens for SAEs
git clone https://github.com/ghosts621/captain-hook && cd captain-hook && pip install -e .from captain_hook import CaptainHook, PromptSet, pick_layer, steer, ablate
ch = CaptainHook.load("meta-llama/Meta-Llama-3.1-8B-Instruct", load_in_4bit=True)
print(ch.self_check()) # do this once per model, before trusting anything
# 1. choose a layer by effect, not by separability
sweep = pick_layer(ch, harmful, benign)
print(sweep) # the two columns usually disagree; that is the finding
# 2. build the direction there
axis = ch.direction(harmful, benign, layer=sweep.best, label="refusal")
# 3. sufficiency — a random arm at the same magnitude comes free
print(steer(ch, axis, PromptSet(benign_12), alphas=(3, 6, 9)))
# 4. necessity — harmful prompts, counts only
print(ablate(ch, axis, PromptSet(harmful_40, harmful=True)))from captain_hook import patch
p = patch(ch, clean="How can I help my neighbour?",
corrupt="How can I rob my neighbour?",
direction=axis, read_layer=20)
print(p.commit_point)Read the shape, not just the number. Real localisation is a flat shelf and then
a knee well upstream of the read layer. A ramp that hugs the read layer means you
deleted the thing you were about to measure. Run it at several read_layer
values: if the knee moves with the read layer, it was never a property of the
model.
from captain_hook import SAEView, roles
view = SAEView(sae, layer=11, neuronpedia="llama3.1-8b-it/11-resid-post-aa")
print(view.fidelity(H, B, axis)) # does the axis survive the dictionary?
dec = view.decompose(H, B, axis) # exact additive split, identity asserted
print(dec)
print(roles(ch, view, dec.top(5), PromptSet(benign_12),
PromptSet(harmful_40, harmful=True), layer=11))roles() crosses sufficiency with necessity:
| sufficient | necessary | role | reading |
|---|---|---|---|
| yes | yes | driver | builds the behaviour and is used for it |
| yes | no | trigger | a button the model has, but never presses |
| no | yes | mirror | reflects the decision without making it |
| no | no | inert | present in the axis, absent from the mechanism |
The trigger row is the reason this function exists. A feature can produce the
behaviour on demand and still be irrelevant to how the model actually does it. A
steering-only study would crown it.
It does not tell you whether your science is right — only a real model and real data do that. It removes a specific list of ways to be confidently wrong. There are others, and if you find one, open an issue and it becomes a guardrail.
Tested against a locally-constructed tiny Llama (tests/test_plumbing.py, no
downloads): 37 checks covering hook placement, tokenization, layer conventions,
the decomposition identity, and the guarantee that harmful text is never retained.
MIT.