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