Coverage for transformer_lens/model_bridge/sources/inspect/transformers_provider.py: 88%
451 statements
« prev ^ index » next coverage.py v7.10.1, created at 2026-09-21 19:27 +0000
« prev ^ index » next coverage.py v7.10.1, created at 2026-09-21 19:27 +0000
1"""Our HF-transformers ``inspect_ai`` model provider, registered as ``tl_bridge``.
3The model-runner side of the Inspect driver (this file uses torch; the consuming
4``InspectDriver`` does not). On ``generate`` it reads the request from
5``config.extra_body["extra_args"]`` (token ids, which ``<layer>:<kind>`` boundaries
6to capture, and intervention specs), runs an HF causal LM with forward hooks that
7capture residual/attn/mlp boundaries and apply affine interventions, and returns a
8``ModelOutput`` whose ``metadata`` carries the activations (encoded by ``wire``)
9plus the full-sequence logits.
10"""
11from __future__ import annotations
13import contextvars
14import uuid
15from collections import defaultdict
16from contextlib import contextmanager
17from typing import Any, Iterator, Mapping
19import numpy as np
20import torch
21from inspect_ai.model import (
22 ChatCompletionChoice,
23 ChatMessageAssistant,
24 GenerateConfig,
25 Logprobs,
26 ModelOutput,
27 ModelUsage,
28 StopReason,
29 modelapi,
30)
32from . import hooks, wire
33from ._provider_base import (
34 _InspectModelAPIBase,
35 _parse_tool_calls,
36 _require_interveneable,
37 _require_served,
38 _warn_unsupported_config,
39)
41# NOT "transformer_lens" — inspect_ai ships a built-in provider by that name (the
42# reverse direction: serving a HookedTransformer as an Inspect model for generation).
43PROVIDER_NAME = "tl_bridge"
45# Per-call hook isolation. Capture/intervene hooks consult this contextvar and only fire
46# for their own call's id — so concurrent inspect_eval samples (each running with their
47# own contextvars copy via asyncio.to_thread) don't cross-pollute each other's activations.
48_current_call_id: contextvars.ContextVar[str] = contextvars.ContextVar(
49 "tl_inspect_call_id", default=""
50)
52# Decoder ModuleList by architecture family; each block's output is resid_post.
53_LAYER_PATHS = ("model.layers", "transformer.h", "gpt_neox.layers", "model.decoder.layers")
54# Attn/MLP submodule names within a block, by family.
55_ATTN_ATTRS = ("self_attn", "attn", "attention", "self_attention") # self_attention: Falcon
56_MLP_ATTRS = ("mlp", "feed_forward")
57# fc-split blocks (OPT/XGLM) have no mlp container — fc1/fc2 sit on the block directly:
58# mlp_in boundary = fc1's input, mlp_out boundary = fc2's output.
59_MLP_SPLIT_ATTRS = ("fc1", "fc2")
60# Separate q/k/v projections (Llama/Mistral/Qwen/OPT-family). Fused-qkv archs
61# (GPT-2 c_attn, Falcon/GPTNeoX query_key_value) gate q/k/v — their packed layouts
62# vary per family, so slicing them is per-arch work we don't hand-maintain here.
63_Q_PROJ_ATTRS = ("q_proj", "query")
64_K_PROJ_ATTRS = ("k_proj", "key")
65_V_PROJ_ATTRS = ("v_proj", "value")
66# Attention out-projection; its input is z (works for fused-qkv archs too).
67_O_PROJ_ATTRS = ("o_proj", "out_proj", "dense", "c_proj")
70@modelapi(name=PROVIDER_NAME)
71def transformer_lens_provider():
72 """Lazy registration hook — returns the provider class on first use."""
73 return TransformerLensTransformersModelAPI
76class TransformerLensTransformersModelAPI(_InspectModelAPIBase):
77 """HF-backed provider: residual/attn/mlp capture + interventions + full logits.
79 Inherits generate() dispatch, _messages_to_ids, _logprob_entry, and the per-turn
80 capture-config validation from :class:`_InspectModelAPIBase`; the HF-specific bits
81 here are the model load, the forward-hook capture machinery, and the structural probe.
82 """
84 # Real per-position logits via direct HF forward — loss/both via RemoteBridge work.
85 provides_sequence_logits = True
87 def __init__(
88 self,
89 model_name: str,
90 base_url: str | None = None,
91 api_key: str | None = None,
92 config: GenerateConfig = GenerateConfig(),
93 **model_args: Any,
94 ) -> None:
95 super().__init__(model_name, base_url, api_key, [], config)
96 from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
98 from transformer_lens.model_bridge.sources._hf_format import (
99 determine_architecture_from_hf_config,
100 )
101 from transformer_lens.model_bridge.sources.transformers.helpers import (
102 get_hf_model_class_for_architecture,
103 )
105 self._device = model_args.pop("device", "cpu")
106 hf_kwargs = model_args.pop("model_kwargs", {})
107 # Config-first class selection: vision/seq2seq/masked-LM archs need a different
108 # AutoModel entry point than AutoModelForCausalLM.
109 config_kwargs = {
110 k: hf_kwargs[k] for k in ("token", "trust_remote_code", "revision") if k in hf_kwargs
111 }
112 hf_config = AutoConfig.from_pretrained(model_name, **config_kwargs)
113 try:
114 model_class = get_hf_model_class_for_architecture(
115 determine_architecture_from_hf_config(hf_config)
116 )
117 except ValueError:
118 # Arch unknown to TL's registry — keep loading it as a plain HF causal LM
119 # (standalone Inspect usage; the structural probe handles the layout).
120 model_class = AutoModelForCausalLM
121 self._hf = model_class.from_pretrained(model_name, **hf_kwargs).to(self._device).eval()
122 self._tokenizer = AutoTokenizer.from_pretrained(model_name)
123 self._layers = _locate_layers(self._hf)
124 # Head-split reshape geometry; None per-field when the config lacks it (head
125 # kinds are then gated by _detect_capabilities' width checks).
126 self._geometry = _attn_geometry(self._hf.config)
127 self._kinds, self._capability_note = _detect_capabilities(
128 self._hf, self._layers, self._geometry
129 )
130 # Per-turn capture during plain generation (agent rollouts): every _generate_eval
131 # stashes these hooks in ModelOutput.metadata. Gated by the structural self-check.
132 self._eval_capture = self._parse_eval_capture(model_args)
133 for key in self._eval_capture:
134 if key.endswith(":pattern"): 134 ↛ 137line 134 didn't jump to line 137 because the condition on line 134 was never true
135 # pattern rides the forward's output_attentions, which the eval path's
136 # hf.generate doesn't thread — reject rather than silently omit.
137 raise ValueError(
138 "Per-turn eval capture of attn.hook_pattern is not supported (it needs "
139 "output_attentions on the forward). Use the driver path "
140 "(bridge.run_with_cache) for pattern capture."
141 )
143 def _generate_capture(self, input: Any, extra_args: Mapping[str, Any], config: GenerateConfig):
144 """TL-driven single forward: capture residual/attn/mlp boundaries + full logits."""
145 input_ids = extra_args.get("input_ids")
146 if input_ids is None:
147 input_ids = self._messages_to_ids(input)[0].tolist()
148 # capture/interventions are keyed by "<layer>:<kind>" (hooks.wire_key).
149 capture_keys = list(extra_args.get("capture", []))
150 for key in capture_keys:
151 _, _, kind = key.partition(":")
152 _require_served(kind, self._kinds, self._capability_note, f"capture {key!r}")
153 interventions: Mapping[str, Any] = extra_args.get("interventions", {})
154 # extra_args is a documented surface: gated/capture-only kinds must fail here,
155 # not silently no-op when no hook installs for them.
156 for key in interventions:
157 _, _, kind = key.partition(":")
158 _require_interveneable(
159 kind, self._kinds, self._capability_note, f"intervention {key!r}"
160 )
161 want_logits = bool(extra_args.get("return_logits", True))
163 capture, intervene = _plan(capture_keys, interventions)
164 # pattern comes from the forward's output_attentions, not a module hook.
165 pattern_layers = [layer for layer, kinds in capture.items() if "pattern" in kinds]
166 raw: dict[tuple[int, str], np.ndarray] = {}
167 call_id = uuid.uuid4().hex
168 token = _current_call_id.set(call_id)
169 handles = self._install_hooks(capture, intervene, raw, call_id)
170 try:
171 with torch.no_grad():
172 ids = torch.tensor([list(input_ids)], device=self._device)
173 outputs = self._hf(ids, output_attentions=bool(pattern_layers))
174 logits = outputs.logits # (1, seq, vocab)
175 finally:
176 for handle in handles:
177 handle.remove()
178 _current_call_id.reset(token)
180 attentions = getattr(outputs, "attentions", None)
181 for layer in pattern_layers:
182 attn_l = attentions[layer] if attentions is not None else None
183 if attn_l is not None: # None → driver's missing-hook warning handles it 183 ↛ 181line 183 didn't jump to line 181 because the condition on line 183 was always true
184 raw[(layer, "pattern")] = attn_l[0].detach().float().cpu().numpy()
186 captured = _assemble(raw, capture_keys)
187 metadata: dict[str, Any] = {"activations": wire.encode_activations(captured)}
188 if want_logits:
189 metadata["tl_logits"] = wire.encode_array(logits[0].float().cpu().numpy())
191 next_id = int(logits[0, -1].argmax())
192 logprobs = (
193 Logprobs(content=[self._logprob_entry(next_id, logits[0, -1], config.top_logprobs)])
194 if config.logprobs
195 else None
196 )
197 return ModelOutput(
198 model=self.model_name,
199 choices=[
200 ChatCompletionChoice(
201 message=ChatMessageAssistant(content=str(self._tokenizer.decode([next_id]))),
202 stop_reason="stop",
203 logprobs=logprobs,
204 )
205 ],
206 metadata=metadata,
207 )
209 def _generate_eval(self, input: Any, config: GenerateConfig, tools: Any):
210 """Plain Inspect generation: HF generate from the chat input (rendering ``tools``
211 into the template), honoring max_tokens/sampling, with optional per-token logprobs,
212 token usage, parsed tool calls, and per-turn activation capture (agent rollouts)."""
213 _warn_unsupported_config(config, PROVIDER_NAME)
214 ids = self._messages_to_ids(input, tools)
215 prompt_len = int(ids.shape[1])
216 max_new = int(config.max_tokens) if config.max_tokens else 16
217 temperature = config.temperature
218 do_sample = temperature is not None and temperature > 0
219 gen: dict[str, Any] = {
220 "max_new_tokens": max_new,
221 "do_sample": do_sample,
222 "return_dict_in_generate": True,
223 "output_scores": True,
224 "pad_token_id": self._tokenizer.pad_token_id or self._tokenizer.eos_token_id,
225 }
226 if config.stop_seqs:
227 # transformers turns stop_strings into a StopStringCriteria; needs the tokenizer.
228 gen["stop_strings"] = list(config.stop_seqs)
229 gen["tokenizer"] = self._tokenizer
230 if temperature is not None and temperature > 0: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true
231 gen["temperature"] = float(temperature)
232 if config.top_p is not None:
233 gen["top_p"] = float(config.top_p)
234 if config.top_k is not None:
235 gen["top_k"] = int(config.top_k)
237 # Save BOTH CPU and CUDA RNG state — get_rng_state() is CPU-only, so seeding on a
238 # CUDA model would otherwise leak its CUDA seed past this generate.
239 rng_state = None
240 cuda_rng_state = None
241 on_cuda = "cuda" in str(self._device)
242 if do_sample and config.seed is not None: 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 rng_state = torch.get_rng_state()
244 if on_cuda:
245 cuda_rng_state = torch.cuda.get_rng_state_all()
246 torch.manual_seed(int(config.seed))
247 # Install per-turn capture hooks AROUND generate (not pre-): first-write-wins lets
248 # the prompt forward populate them and decode forwards skip — no extra forward.
249 with self._eval_capture_scope() as metadata:
250 try:
251 with torch.no_grad():
252 out = self._hf.generate(ids, **gen)
253 finally:
254 if rng_state is not None: 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true
255 torch.set_rng_state(rng_state)
256 if cuda_rng_state is not None: 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true
257 torch.cuda.set_rng_state_all(cuda_rng_state)
259 new_ids = out.sequences[0, prompt_len:]
260 completion = str(self._tokenizer.decode(new_ids, skip_special_tokens=True))
261 logprobs = None
262 if config.logprobs:
263 content = [
264 self._logprob_entry(int(tok), step[0], config.top_logprobs)
265 for tok, step in zip(new_ids.tolist(), out.scores)
266 ]
267 logprobs = Logprobs(content=content)
268 n_new = int(new_ids.shape[0])
269 tool_calls = _parse_tool_calls(completion) if len(tools) else None
270 eos = self._tokenizer.eos_token_id
271 stop_reason: StopReason
272 if tool_calls: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true
273 stop_reason = "tool_calls"
274 elif n_new and eos is not None and int(new_ids[-1]) == eos: 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true
275 stop_reason = "stop"
276 else:
277 stop_reason = "max_tokens"
278 return ModelOutput(
279 model=self.model_name,
280 choices=[
281 ChatCompletionChoice(
282 message=ChatMessageAssistant(content=completion, tool_calls=tool_calls),
283 stop_reason=stop_reason,
284 logprobs=logprobs,
285 )
286 ],
287 usage=ModelUsage(
288 input_tokens=prompt_len, output_tokens=n_new, total_tokens=prompt_len + n_new
289 ),
290 metadata=metadata or None,
291 )
293 @contextmanager
294 def _eval_capture_scope(self) -> Iterator[dict[str, Any]]:
295 """Install per-turn ``capture=[...]`` hooks for the duration of a generate. First-
296 write-wins lets the prompt forward populate the raw dict (decode forwards find it
297 populated and skip), so this adds no extra forward. Yields a metadata dict (empty
298 when capture isn't configured) that the caller folds into ``ModelOutput.metadata``.
299 Contextvar-isolated so concurrent inspect_eval samples don't cross-pollute."""
300 if not self._eval_capture:
301 yield {}
302 return
303 wire_keys = list(self._eval_capture)
304 capture, _ = _plan(wire_keys, {})
305 raw: dict[tuple[int, str], np.ndarray] = {}
306 call_id = uuid.uuid4().hex
307 token = _current_call_id.set(call_id)
308 handles = self._install_hooks(capture, {}, raw, call_id)
309 metadata: dict[str, Any] = {}
310 try:
311 yield metadata
312 finally:
313 for handle in handles:
314 handle.remove()
315 _current_call_id.reset(token)
316 metadata["activations"] = wire.encode_activations(_assemble(raw, wire_keys))
318 def _install_hooks(self, capture, intervene, raw, call_id: str) -> list:
319 """Hook each block's pre/attn/mlp/post boundaries that need capture or intervention.
320 Hooks consult ``_current_call_id`` and only fire for ``call_id`` (concurrent calls)."""
321 handles = []
322 for layer, block in enumerate(self._layers):
323 cap_kinds = capture.get(layer, set())
324 iv_kinds = intervene.get(layer, {})
325 if not cap_kinds and not iv_kinds:
326 continue
327 attn = _first_attr(block, _ATTN_ATTRS)
328 _, mlp_out_mod = _locate_mlp(block)
329 # Capabilities were probed on layer 0; hybrid archs can lack the module here.
330 _require_layer_modules(layer, attn, mlp_out_mod, cap_kinds, iv_kinds)
331 if "resid_pre" in cap_kinds or "resid_pre" in iv_kinds:
332 handles.append(
333 block.register_forward_pre_hook(
334 _pre_hook(
335 layer, "resid_pre" in cap_kinds, iv_kinds.get("resid_pre"), raw, call_id
336 ),
337 with_kwargs=True,
338 )
339 )
340 if attn is not None and ("attn_out" in cap_kinds or "attn_out" in iv_kinds):
341 handles.append(
342 attn.register_forward_hook(
343 _out_hook(
344 layer,
345 "attn_out",
346 "attn_out" in cap_kinds,
347 iv_kinds.get("attn_out"),
348 raw,
349 call_id,
350 )
351 )
352 )
353 if attn is not None:
354 handles.extend(
355 self._install_head_hooks(layer, attn, cap_kinds, iv_kinds, raw, call_id)
356 )
357 if mlp_out_mod is not None and ("mlp_out" in cap_kinds or "mlp_out" in iv_kinds):
358 handles.append(
359 mlp_out_mod.register_forward_hook(
360 _out_hook(
361 layer,
362 "mlp_out",
363 "mlp_out" in cap_kinds,
364 iv_kinds.get("mlp_out"),
365 raw,
366 call_id,
367 )
368 )
369 )
370 if "resid_post" in cap_kinds or "resid_post" in iv_kinds:
371 handles.append(
372 block.register_forward_hook(
373 _out_hook(
374 layer,
375 "resid_post",
376 "resid_post" in cap_kinds,
377 iv_kinds.get("resid_post"),
378 raw,
379 call_id,
380 )
381 )
382 )
383 return handles
385 def _install_head_hooks(self, layer, attn, cap_kinds, iv_kinds, raw, call_id: str) -> list:
386 """Hooks for the head-split kinds: q/k/v on their projection outputs, z on the
387 out-projection's input. pattern isn't hooked (it rides output_attentions).
389 Interventions apply to the module's natural *flat* tensor ``(..., seq,
390 heads·d_head)`` — a spec ``value`` is scalar, ``(heads·d_head,)``, or per-position
391 ``(len(pos), heads·d_head)`` (flatten a captured head-split tensor to build one).
392 Captures are emitted head-split ``(seq, heads, d_head)`` to match the bridge's
393 ``hook_q/k/v/z``.
394 """
395 handles: list = []
396 d_head = self._geometry[2]
397 if d_head is None: # geometry undetectable → head kinds were gated at detection 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true
398 return handles
399 host = _projection_host(attn)
400 for kind, attrs in (("q", _Q_PROJ_ATTRS), ("k", _K_PROJ_ATTRS), ("v", _V_PROJ_ATTRS)):
401 if kind not in cap_kinds and kind not in iv_kinds:
402 continue
403 proj = _first_attr(host, attrs)
404 if proj is None: 404 ↛ 408line 404 didn't jump to line 408 because the condition on line 404 was never true
405 # Detection ran on layers[0]; a later layer missing the projection would
406 # silently skip a validated intervention — fail loud (capture-only misses
407 # surface through the driver's missing-hook warning instead).
408 if kind in iv_kinds:
409 raise RuntimeError(
410 f"Intervention on blocks.{layer}.attn.hook_{kind} cannot be applied: "
411 f"layer {layer} has no {kind} projection (heterogeneous layers)."
412 )
413 continue
414 handles.append(
415 proj.register_forward_hook(
416 _proj_hook(
417 layer, kind, d_head, kind in cap_kinds, iv_kinds.get(kind), raw, call_id
418 )
419 )
420 )
421 if "z" in cap_kinds or "z" in iv_kinds:
422 o_proj = _first_attr(host, _O_PROJ_ATTRS)
423 if o_proj is None and "z" in iv_kinds: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true
424 raise RuntimeError(
425 f"Intervention on blocks.{layer}.attn.hook_z cannot be applied: layer "
426 f"{layer} has no out-projection (heterogeneous layers)."
427 )
428 if o_proj is not None: 428 ↛ 435line 428 didn't jump to line 435 because the condition on line 428 was always true
429 handles.append(
430 o_proj.register_forward_pre_hook(
431 _zin_hook(layer, d_head, "z" in cap_kinds, iv_kinds.get("z"), raw, call_id),
432 with_kwargs=True,
433 )
434 )
435 return handles
438def _require_layer_modules(
439 layer: int, attn: Any, mlp_out_mod: Any, cap_kinds: Any, iv_kinds: Any
440) -> None:
441 """Fail loud when this layer lacks a targeted submodule — detection ran on layer 0,
442 so hybrid attn/SSM stacks would otherwise install nothing and silently no-op."""
443 wanted = set(cap_kinds) | set(iv_kinds)
444 if attn is None:
445 needs_attn = wanted & ({"attn_out"} | (hooks.HEAD_KINDS - {"pattern"}))
446 if needs_attn:
447 raise RuntimeError(
448 f"blocks.{layer} has no attention submodule: cannot serve "
449 f"{sorted(needs_attn)} at layer {layer} (heterogeneous layers)."
450 )
451 if mlp_out_mod is None and "mlp_out" in wanted:
452 raise RuntimeError(
453 f"blocks.{layer} has no MLP submodule: cannot serve mlp_out at layer "
454 f"{layer} (heterogeneous layers)."
455 )
458def _plan(capture_keys, interventions):
459 """Resolve wire keys → per-layer kinds to capture (resid_mid needs pre+attn) and intervene."""
460 capture: dict[int, set[str]] = defaultdict(set)
461 for key in capture_keys:
462 layer, _, kind = key.partition(":")
463 layer = int(layer)
464 if kind == "resid_mid":
465 capture[layer] |= {"resid_pre", "attn_out"} # derived = resid_pre + attn_out
466 else:
467 capture[layer].add(kind)
468 intervene: dict[int, dict[str, Any]] = defaultdict(dict)
469 for key, spec in interventions.items():
470 layer, _, kind = key.partition(":")
471 intervene[int(layer)][kind] = spec
472 return capture, intervene
475def _assemble(raw, capture_keys) -> dict[str, np.ndarray]:
476 """Build the emitted ``{wire_key: (seq, d)}`` map, deriving resid_mid as needed."""
477 out: dict[str, np.ndarray] = {}
478 for key in capture_keys:
479 layer, _, kind = key.partition(":")
480 layer = int(layer)
481 if kind == "resid_mid":
482 pre, attn = raw.get((layer, "resid_pre")), raw.get((layer, "attn_out"))
483 if pre is not None and attn is not None: 483 ↛ 478line 483 didn't jump to line 478 because the condition on line 483 was always true
484 out[key] = pre + attn
485 elif (layer, kind) in raw: 485 ↛ 478line 485 didn't jump to line 478 because the condition on line 485 was always true
486 out[key] = raw[(layer, kind)]
487 return out
490def _pre_hook(layer, want_capture, spec, raw, call_id):
491 # with_kwargs=True: hidden_states is args[0] for most decoders, but some pass it as
492 # the hidden_states kwarg — handle both so the right tensor is read/modified.
493 # First-write-wins on raw so install-around-generate captures the prompt forward
494 # (subsequent decode forwards find raw populated and skip — no extra prompt forward).
495 def hook(_module, args, kwargs):
496 if _current_call_id.get() != call_id: 496 ↛ 497line 496 didn't jump to line 497 because the condition on line 496 was never true
497 return None # different concurrent call's hook
498 kw_key = None if args else "hidden_states"
499 hidden = args[0] if args else kwargs["hidden_states"]
500 if spec is not None:
501 hidden = _apply_affine(hidden, spec)
502 if want_capture and (layer, "resid_pre") not in raw: 502 ↛ 504line 502 didn't jump to line 504 because the condition on line 502 was always true
503 raw[(layer, "resid_pre")] = hidden[0].detach().float().cpu().numpy()
504 if spec is None:
505 return None
506 if kw_key is None:
507 return (hidden, *args[1:]), kwargs
508 return args, {**kwargs, kw_key: hidden}
510 return hook
513def _out_hook(layer, kind, want_capture, spec, raw, call_id):
514 def hook(_module, _inputs, output):
515 if _current_call_id.get() != call_id:
516 return None # different concurrent call's hook
517 is_tuple = isinstance(output, tuple)
518 hidden = output[0] if is_tuple else output
519 if spec is not None:
520 hidden = _apply_affine(hidden, spec)
521 if want_capture and (layer, kind) not in raw:
522 # OPT-style blocks flatten the FFN to (batch·seq, d) — with batch_size=1
523 # that IS (seq, d) already; only 3-D (batch, seq, d) needs the batch strip.
524 flat = hidden if hidden.ndim == 2 else hidden[0]
525 raw[(layer, kind)] = flat.detach().float().cpu().numpy()
526 if spec is None:
527 return None
528 return (hidden, *output[1:]) if is_tuple else hidden
530 return hook
533def _proj_hook(layer, kind, d_head, want_capture, spec, raw, call_id):
534 """q/k/v projection output: affine on the flat ``(..., seq, heads·d_head)`` tensor,
535 captured head-split ``(seq, heads, d_head)`` (pre-RoPE — matches the bridge's
536 ``hook_q``/``hook_k``/``hook_v``). Mutations feed the downstream attention math."""
538 def hook(_module, _inputs, output):
539 if _current_call_id.get() != call_id: 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true
540 return None # different concurrent call's hook
541 hidden = output
542 if spec is not None:
543 hidden = _apply_affine(hidden, spec)
544 if want_capture and (layer, kind) not in raw: 544 ↛ 547line 544 didn't jump to line 547 because the condition on line 544 was always true
545 flat = hidden[0].detach().float().cpu()
546 raw[(layer, kind)] = flat.reshape(flat.shape[0], -1, d_head).numpy()
547 return hidden if spec is not None else None
549 return hook
552def _zin_hook(layer, d_head, want_capture, spec, raw, call_id):
553 """z — the out-projection's *input* (attention-weighted values, all heads): pre-hook,
554 affine on the flat tensor, captured head-split to match the bridge's ``hook_z``."""
556 def hook(_module, args, kwargs):
557 if _current_call_id.get() != call_id: 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true
558 return None # different concurrent call's hook
559 z = args[0]
560 if spec is not None: 560 ↛ 561line 560 didn't jump to line 561 because the condition on line 560 was never true
561 z = _apply_affine(z, spec)
562 if want_capture and (layer, "z") not in raw: 562 ↛ 565line 562 didn't jump to line 565 because the condition on line 562 was always true
563 flat = z[0].detach().float().cpu()
564 raw[(layer, "z")] = flat.reshape(flat.shape[0], -1, d_head).numpy()
565 if spec is None: 565 ↛ 567line 565 didn't jump to line 567 because the condition on line 565 was always true
566 return None
567 return (z, *args[1:]), kwargs
569 return hook
572def _affine_op(sub: torch.Tensor, spec: Mapping[str, Any]) -> torch.Tensor:
573 """One affine op: suppress→0, scale→·factor, add→+value, set→value. ``value`` broadcasts
574 (scalar, width-shaped, or per-position ``(n_pos, width)``)."""
575 op = spec["op"]
576 if op == "suppress":
577 return torch.zeros_like(sub)
578 if op == "scale": 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true
579 return sub * float(spec["factor"])
580 value = torch.as_tensor(spec["value"], dtype=sub.dtype, device=sub.device)
581 if op == "add": 581 ↛ 583line 581 didn't jump to line 583 because the condition on line 581 was always true
582 return sub + value
583 return torch.zeros_like(sub) + value # set
586def _apply_affine(t: torch.Tensor, spec: Mapping[str, Any]) -> torch.Tensor:
587 """Affine intervention on a captured tensor ``(..., seq, width)``.
589 Without ``pos`` the op spans every position (the original width-broadcast form). With
590 ``pos`` (an int or list of sequence indices) it touches only those positions — the
591 activation-patching primitive — and ``value`` may be per-position ``(len(pos), width)``
592 to transplant a captured activation (path/causal tracing) rather than a single vector.
593 """
594 pos = spec.get("pos")
595 if pos is None:
596 return _affine_op(t, spec)
597 idx = [pos] if isinstance(pos, int) else list(pos)
598 out = t.clone()
599 out[..., idx, :] = _affine_op(t[..., idx, :], spec)
600 return out
603def _detect_capabilities(
604 model: Any, layers: Any, geometry: tuple[Any, Any, Any]
605) -> tuple[frozenset, str]:
606 """Structural self-check: which kinds this model can serve faithfully.
608 resid_pre/resid_post are the block in/out (always); attn_out/mlp_out need their
609 submodules locatable; resid_mid is gated unless its derivation holds (see
610 :func:`_resid_mid_derivable`). Head-split kinds: q/k/v need separate projections whose
611 widths match ``heads·d_head``; z needs an out-projection of in-width ``n_heads·d_head``;
612 pattern needs eager attention (output_attentions is a no-op under sdpa/flash).
613 Returns (kinds, note); note explains any gating, '' if none.
614 """
615 block = layers[0]
616 attn = _first_attr(block, _ATTN_ATTRS)
617 mlp_in_mod, mlp_out_mod = _locate_mlp(block)
618 kinds = {"resid_pre", "resid_post"}
619 gated = []
620 if attn is not None:
621 kinds.add("attn_out")
622 else:
623 gated.append("attn_out (no attention submodule found)")
624 if mlp_out_mod is not None:
625 kinds.add("mlp_out")
626 else:
627 gated.append("mlp_out (no MLP submodule found)")
628 if (
629 attn is not None
630 and mlp_out_mod is not None
631 and _resid_mid_derivable(model, block, attn, mlp_in_mod, mlp_out_mod)
632 ):
633 kinds.add("resid_mid")
634 else:
635 gated.append(
636 "resid_mid (resid_pre + attn_out doesn't hold — parallel or norm-variant block)"
637 )
638 head_kinds, head_gated = _detect_head_capabilities(model, attn, geometry)
639 kinds |= head_kinds
640 gated += head_gated
641 note = (
642 ""
643 if not gated
644 else "InspectDriver: this architecture's block layout gates "
645 + ", ".join(gated)
646 + ". Remaining boundaries are served; use boot_transformers() for the gated ones."
647 )
648 return frozenset(kinds), note
651def _detect_head_capabilities(
652 model: Any, attn: Any, geometry: tuple[Any, Any, Any]
653) -> tuple[set, list]:
654 """Head-split kinds this model serves: q/k/v iff separate projections with the
655 expected widths, z iff the out-projection's in-width is ``n_heads·d_head``, pattern
656 iff attention runs eager (otherwise ``output_attentions`` returns None/garbage)."""
657 n_heads, n_kv_heads, d_head = geometry
658 kinds: set = set()
659 gated: list = []
660 if attn is None or d_head is None:
661 gated.append("q/k/v/z/pattern (no attention submodule or head geometry in config)")
662 return kinds, gated
664 host = _projection_host(attn)
665 q = _first_attr(host, _Q_PROJ_ATTRS)
666 k = _first_attr(host, _K_PROJ_ATTRS)
667 v = _first_attr(host, _V_PROJ_ATTRS)
668 expected = {"q": n_heads * d_head, "k": n_kv_heads * d_head, "v": n_kv_heads * d_head}
669 if all(
670 proj is not None and _out_width(proj) == expected[kind]
671 for kind, proj in (("q", q), ("k", k), ("v", v))
672 ):
673 kinds |= {"q", "k", "v"}
674 else:
675 gated.append("q/k/v (fused or nonstandard qkv projections)")
677 o_proj = _first_attr(host, _O_PROJ_ATTRS)
678 if o_proj is not None and _in_width(o_proj) == n_heads * d_head: 678 ↛ 681line 678 didn't jump to line 681 because the condition on line 678 was always true
679 kinds.add("z")
680 else:
681 gated.append("z (out-projection missing or nonstandard width)")
683 if getattr(model.config, "_attn_implementation", "eager") == "eager":
684 kinds.add("pattern")
685 else:
686 gated.append("pattern (attention implementation is not eager)")
687 return kinds, gated
690def _out_width(module: Any) -> Any:
691 """Output width of a projection: ``nn.Linear.out_features`` or GPT-2 ``Conv1D.nf``."""
692 out = getattr(module, "out_features", None)
693 if out is not None: 693 ↛ 695line 693 didn't jump to line 695 because the condition on line 693 was always true
694 return int(out)
695 nf = getattr(module, "nf", None) # transformers Conv1D
696 return int(nf) if nf is not None else None
699def _in_width(module: Any) -> Any:
700 """Input width of a projection: ``nn.Linear.in_features`` or Conv1D ``weight.shape[0]``."""
701 in_f = getattr(module, "in_features", None)
702 if in_f is not None:
703 return int(in_f)
704 if getattr(module, "nf", None) is not None and hasattr(module, "weight"): 704 ↛ 706line 704 didn't jump to line 706 because the condition on line 704 was always true
705 return int(module.weight.shape[0]) # Conv1D stores weight (in, out)
706 return None
709def _attn_geometry(config: Any) -> tuple[Any, Any, Any]:
710 """(n_heads, n_kv_heads, d_head) from an HF config; (None, None, None) if underivable."""
711 n_heads = getattr(config, "num_attention_heads", None) or getattr(config, "n_head", None)
712 hidden = getattr(config, "hidden_size", None) or getattr(config, "n_embd", None)
713 if n_heads is None: 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true
714 return None, None, None
715 n_kv = getattr(config, "num_key_value_heads", None) or n_heads
716 d_head = getattr(config, "head_dim", None)
717 if d_head is None and hidden is not None:
718 d_head = hidden // n_heads
719 return (int(n_heads), int(n_kv), int(d_head) if d_head is not None else None)
722def _resid_mid_derivable(
723 model: Any, block: Any, attn: Any, mlp_in_mod: Any, mlp_out_mod: Any
724) -> bool:
725 """True iff ``resid_mid = resid_pre + attn_out`` holds, via two tiny probe forwards.
726 Requires both the linear identity ``resid_post = resid_pre + attn_out + mlp_out`` (broken
727 by post-norm/multiplier blocks — Gemma2/OLMo2/Granite) and attn feeding mlp (broken by
728 parallel blocks — GPTNeoX/GPT-J, where mlp reads resid_pre directly). ``mlp_in_mod``/
729 ``mlp_out_mod`` are the same module for container archs, (fc1, fc2) for fc-split."""
730 cap: dict[str, Any] = {}
732 def grab(key: str): # type: ignore[no-untyped-def]
733 def hook(_m: Any, _i: Any, out: Any) -> None:
734 t = out[0] if isinstance(out, tuple) else out
735 cap[key] = t.detach().float()
737 return hook
739 def grab_in(key: str): # type: ignore[no-untyped-def]
740 def hook(_m: Any, args: Any, kwargs: Any) -> None:
741 t = args[0] if args else kwargs.get("hidden_states")
742 cap[key] = None if t is None else t.detach().float()
744 return hook
746 def perturb_attn(_m: Any, _i: Any, out: Any): # type: ignore[no-untyped-def]
747 is_tuple = isinstance(out, tuple)
748 h = out[0] if is_tuple else out
749 # Local generator: the probe must not reset the caller's global RNG. Non-uniform
750 # noise so layernorm's mean-subtraction can't cancel it (a constant would).
751 gen = torch.Generator(device=h.device).manual_seed(0)
752 h = h + torch.empty_like(h).normal_(generator=gen)
753 return (h, *out[1:]) if is_tuple else h
755 ids = torch.tensor([[0, 1, 2]], device=next(model.parameters()).device)
756 try:
757 with torch.no_grad():
758 handles = [
759 block.register_forward_pre_hook(grab_in("resid_pre"), with_kwargs=True),
760 attn.register_forward_hook(grab("attn_out")),
761 mlp_out_mod.register_forward_hook(grab("mlp_out")),
762 mlp_in_mod.register_forward_pre_hook(grab_in("mlp_in"), with_kwargs=True),
763 block.register_forward_hook(grab("resid_post")),
764 ]
765 model(ids)
766 for h in handles:
767 h.remove()
768 mlp_in_clean = cap.pop("mlp_in", None)
769 handles = [
770 mlp_in_mod.register_forward_pre_hook(grab_in("mlp_in"), with_kwargs=True),
771 attn.register_forward_hook(perturb_attn),
772 ]
773 model(ids)
774 for h in handles:
775 h.remove()
776 mlp_in_perturbed = cap.get("mlp_in")
777 except Exception:
778 return False # can't probe (exotic signature) → conservatively gate resid_mid
780 rp = cap.get("resid_pre")
781 ao = cap.get("attn_out")
782 mo = cap.get("mlp_out")
783 rpost = cap.get("resid_post")
784 if rp is None or ao is None or mo is None or rpost is None: 784 ↛ 785line 784 didn't jump to line 785 because the condition on line 784 was never true
785 return False
786 if mlp_in_clean is None or mlp_in_perturbed is None: 786 ↛ 787line 786 didn't jump to line 787 because the condition on line 786 was never true
787 return False
788 # OPT-style blocks flatten the FFN to (batch·seq, d); with the probe's batch=1 that
789 # is a pure reshape of the block-level (1, seq, d) — normalize before comparing.
790 if mo.shape != rpost.shape and mo.numel() == rpost.numel():
791 mo = mo.reshape(rpost.shape)
792 if not (rp.shape == ao.shape == mo.shape == rpost.shape): 792 ↛ 793line 792 didn't jump to line 793 because the condition on line 792 was never true
793 return False
794 if mlp_in_clean.shape != mlp_in_perturbed.shape or mlp_in_clean.numel() != rp.numel(): 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true
795 return False
796 # (1) sub-block outputs add to the residual without intervening norm/scale.
797 identity = (rpost - rp - ao - mo).abs().max().item()
798 identity_ok = identity <= 1e-3 * (rpost.abs().max().item() + 1e-6)
799 # (2) perturbing attn moves mlp's input (sequential, not parallel).
800 causal_ok = (mlp_in_clean - mlp_in_perturbed).abs().max().item() > 1e-6
801 return bool(identity_ok and causal_ok)
804def _locate_layers(model: Any) -> Any:
805 for path in _LAYER_PATHS: 805 ↛ 813line 805 didn't jump to line 813 because the loop on line 805 didn't complete
806 target: Any = model
807 for seg in path.split("."):
808 target = getattr(target, seg, None)
809 if target is None:
810 break
811 if target is not None:
812 return target
813 raise RuntimeError(
814 f"Could not locate decoder layers on {type(model).__name__}; tried {_LAYER_PATHS}."
815 )
818def _locate_mlp(block: Any) -> tuple[Any, Any]:
819 """The modules bounding the MLP: ``(in_module, out_module)`` — the mlp_in boundary is
820 in_module's input, mlp_out is out_module's output. Container archs return the mlp
821 module twice; fc-split blocks (OPT/XGLM: fc1/fc2 directly on the block) return
822 ``(fc1, fc2)``. ``(None, None)`` when neither layout is found (mlp_out gated)."""
823 mlp = _first_attr(block, _MLP_ATTRS)
824 if mlp is not None:
825 return mlp, mlp
826 fc1, fc2 = (getattr(block, name, None) for name in _MLP_SPLIT_ATTRS)
827 if fc1 is not None and fc2 is not None:
828 return fc1, fc2
829 return None, None
832def _projection_host(attn: Any) -> Any:
833 """The module whose direct attrs are the q/k/v/out projections. Usually ``attn``
834 itself; GPT-Neo-style blocks wrap the real attention (with its standard q_proj/
835 out_proj) one level down at ``attn.attention``. Descend only when the located module
836 has neither a q- nor an out-projection — GPTNeoX's ``block.attention`` (fused
837 query_key_value + dense) has ``dense`` directly, so it never descends."""
838 if _first_attr(attn, _Q_PROJ_ATTRS) is None and _first_attr(attn, _O_PROJ_ATTRS) is None:
839 inner = getattr(attn, "attention", None)
840 if inner is not None:
841 return inner
842 return attn
845def _first_attr(obj: Any, names: tuple[str, ...]) -> Any:
846 for name in names:
847 found = getattr(obj, name, None)
848 if found is not None:
849 return found
850 return None