Skip to content

About

Activation-level interventions that come with their controls — steer, ablate, patch, and SAE feature analysis for open-weight LLMs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

captain-hook

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.


Why

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 on layers[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.

The traps, and what the library does about them

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

Safety is structural, not a flag

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, drop

Removing 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.

Install

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 .

Quickstart

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)))

Localising the decision

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.

Features

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.

What this is not

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.

License

MIT.

About

Activation-level interventions that come with their controls — steer, ablate, patch, and SAE feature analysis for open-weight LLMs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages